mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-15 10:23:43 -06:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6c100342 | ||
|
|
072ec4500a | ||
|
|
58a13b5a64 | ||
|
|
281f86d602 | ||
|
|
caaa0e37f7 | ||
|
|
7064a685a8 | ||
|
|
e43c39c55e | ||
|
|
deff6cb092 | ||
|
|
67514021ae | ||
|
|
8e69890223 | ||
|
|
fbb083803b | ||
|
|
54dfb2ee00 | ||
|
|
fa7a97728b | ||
|
|
2a9fc8f241 | ||
|
|
ddfa833bc2 | ||
|
|
bf581c2376 |
@@ -1,34 +0,0 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ast-grep.yml': []
|
||||
'.github/workflows/ci.yml': []
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/release.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -23,43 +23,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
|
||||
|
||||
@@ -28,10 +28,10 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
|
||||
@@ -28,51 +28,9 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: [git-sources, credits]
|
||||
needs: credits
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
|
||||
+3
-1
@@ -23,6 +23,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `licenses/jpeg-encoder-0.6.1/` | jpeg-encoder contributors and Independent JPEG Group | [jpeg-encoder 0.6.1](https://crates.io/crates/jpeg-encoder/0.6.1) | (MIT OR Apache-2.0) AND IJG | Dependency license and SIMD notices; source hashes in SOURCE.json. |
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
|
||||
@@ -62,6 +63,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | @neronek22 (Echo Rozumu & Gadajło; AI-assisted) | Original Polish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `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 |
|
||||
@@ -76,6 +78,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `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 |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted); 46 later rows: @V1dalTheAbidal with Claude Code (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; the 46 later rows were read by their contributor, a native Greek speaker; independent native proofreading of the whole file pending |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/ka.tsv` | Davit Matchakhelidze (@devius), native speaker, with Claude Code | Original Georgian 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; terminology and doubtful strings decided by a native Georgian speaker; no font assets added (the Georgian face comes from craft-fonts) |
|
||||
|
||||
Generated
+202
-23
@@ -156,6 +156,21 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -513,6 +528,12 @@ version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -568,6 +589,16 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -614,7 +645,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -645,7 +676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -661,14 +692,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -678,7 +709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -687,17 +718,9 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -718,7 +741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"craft-dxf",
|
||||
@@ -731,23 +754,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dwg",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-dwg",
|
||||
"craft-dxf",
|
||||
"craft-image-codecs",
|
||||
"craft-pdf",
|
||||
"image",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -760,7 +784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -775,12 +799,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"craft-image-codecs",
|
||||
"lyon_tessellation",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
@@ -789,15 +814,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"chrono",
|
||||
"craft-image-codecs",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
@@ -816,7 +843,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
@@ -1143,6 +1170,15 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-dwg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf#7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-dxf"
|
||||
version = "0.1.0"
|
||||
@@ -1181,6 +1217,51 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-image-codecs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ae5020c1ed922cf7976a3a5545f05b07f466be12#ae5020c1ed922cf7976a3a5545f05b07f466be12"
|
||||
dependencies = [
|
||||
"craft-jpeg",
|
||||
"craft-metadata",
|
||||
"crc32fast",
|
||||
"exr",
|
||||
"flate2",
|
||||
"half",
|
||||
"hayro-jpeg2000",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"png",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-jpeg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ae5020c1ed922cf7976a3a5545f05b07f466be12#ae5020c1ed922cf7976a3a5545f05b07f466be12"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-metadata"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ae5020c1ed922cf7976a3a5545f05b07f466be12#ae5020c1ed922cf7976a3a5545f05b07f466be12"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"chrono",
|
||||
"craft-tiff",
|
||||
"quick-xml 0.41.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -1191,6 +1272,15 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-tiff"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ae5020c1ed922cf7976a3a5545f05b07f466be12#ae5020c1ed922cf7976a3a5545f05b07f466be12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1759,6 +1849,22 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
"half",
|
||||
"lebe",
|
||||
"miniz_oxide 0.8.9",
|
||||
"multiversion",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -2263,6 +2369,9 @@ name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
@@ -2565,9 +2674,12 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"qoi",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
@@ -2707,6 +2819,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-encoder"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -2799,6 +2917,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
@@ -2991,6 +3115,28 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076"
|
||||
dependencies = [
|
||||
"multiversion-macros",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion-macros"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -3859,6 +4005,15 @@ version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -4656,6 +4811,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-features"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5995,7 +6156,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
@@ -6248,6 +6409,15 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
@@ -6257,6 +6427,15 @@ dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpegxl"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.1"
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.1"
|
||||
version = "0.21.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -15,7 +15,8 @@ repository = "https://github.com/storytold/cadcraft"
|
||||
cadcraft-geom = { path = "crates/geom" }
|
||||
# DXF tags: the shared reader and writer (moved from crates/dxf; craft-libs#791).
|
||||
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "006e4df225fca57e95ead0fb1f91920900798a99" }
|
||||
cadcraft-dwg = { path = "crates/dwg" }
|
||||
craft-dwg = { git = "https://github.com/storytold/craft-libs", rev = "7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf" }
|
||||
craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "ae5020c1ed922cf7976a3a5545f05b07f466be12" }
|
||||
cadcraft-color = { path = "crates/color" }
|
||||
cadcraft-doc = { path = "crates/doc" }
|
||||
cadcraft-constraints = { path = "crates/constraints" }
|
||||
|
||||
@@ -11,7 +11,7 @@ Third-party material
|
||||
|
||||
Software dependencies are listed in Cargo.lock. One is under a weak-copyleft licence: the acadrust
|
||||
crate (DWG reading and writing, https://github.com/hakanaktt/acadrust) is licensed under the
|
||||
Mozilla Public License 2.0 and is used unmodified as a separate library (crates/dwg). Its source
|
||||
Mozilla Public License 2.0 and is used unmodified as a separate library via craft-dwg. Its source
|
||||
is available from crates.io and the repository above.
|
||||
|
||||
|
||||
@@ -48,3 +48,127 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
JPEG encoding dependency: jpeg-encoder 0.6.1
|
||||
|
||||
Source: https://crates.io/crates/jpeg-encoder/0.6.1
|
||||
Declared license: (MIT OR Apache-2.0) AND IJG.
|
||||
This software is based in part on the work of the Independent JPEG Group.
|
||||
|
||||
The MIT license and unaltered upstream IJG and SIMD notices follow. The Apache-2.0
|
||||
alternative is also retained in licenses/jpeg-encoder-0.6.1 at the repository root
|
||||
(https://github.com/storytold/craft-libs/tree/main/licenses/jpeg-encoder-0.6.1).
|
||||
The dependency is used unmodified; no encoder implementation is vendored here.
|
||||
|
||||
LICENSE-MIT
|
||||
|
||||
Copyright (c) 2021 Volker Ströbel <volkerstroebel@mysurdity.de>
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
Original src/fdct.rs opening notice
|
||||
|
||||
/*
|
||||
* Ported from mozjpeg to rust
|
||||
*
|
||||
* This file was part of the Independent JPEG Group's software:
|
||||
* Copyright (C) 1991-1996, Thomas G. Lane.
|
||||
* libjpeg-turbo Modifications:
|
||||
* Copyright (C) 2015, 2020, D. R. Commander.
|
||||
*
|
||||
* Conditions of distribution and use:
|
||||
* In plain English:
|
||||
*
|
||||
* 1. We don't promise that this software works. (But if you find any bugs,
|
||||
* please let us know!)
|
||||
* 2. You can use this software for whatever you want. You don't have to pay us.
|
||||
* 3. You may not pretend that you wrote this software. If you use it in a
|
||||
* program, you must acknowledge somewhere in your documentation that
|
||||
* you've used the IJG code.
|
||||
*
|
||||
* In legalese:
|
||||
*
|
||||
* The authors make NO WARRANTY or representation, either express or implied,
|
||||
* with respect to this software, its quality, accuracy, merchantability, or
|
||||
* fitness for a particular purpose. This software is provided "AS IS", and you,
|
||||
* its user, assume the entire risk as to its quality and accuracy.
|
||||
*
|
||||
* This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding.
|
||||
* All Rights Reserved except as specified below.
|
||||
*
|
||||
* Permission is hereby granted to use, copy, modify, and distribute this
|
||||
* software (or portions thereof) for any purpose, without fee, subject to these
|
||||
* conditions:
|
||||
* (1) If any part of the source code for this software is distributed, then this
|
||||
* README file must be included, with this copyright and no-warranty notice
|
||||
* unaltered; and any additions, deletions, or changes to the original files
|
||||
* must be clearly indicated in accompanying documentation.
|
||||
* (2) If only executable code is distributed, then the accompanying
|
||||
* documentation must state that "this software is based in part on the work of
|
||||
* the Independent JPEG Group".
|
||||
* (3) Permission for use of this software is granted only if the user accepts
|
||||
* full responsibility for any undesirable consequences; the authors accept
|
||||
* NO LIABILITY for damages of any kind.
|
||||
*
|
||||
* These conditions apply to any software derived from or based on the IJG code,
|
||||
* not just to the unmodified library. If you use our work, you ought to
|
||||
* acknowledge us.
|
||||
*
|
||||
* Permission is NOT granted for the use of any IJG author's name or company name
|
||||
* in advertising or publicity relating to this software or products derived from
|
||||
* it. This software may be referred to only as "the Independent JPEG Group's
|
||||
* software".
|
||||
*
|
||||
* We specifically permit and encourage the use of this software as the basis of
|
||||
* commercial products, provided that all warranty or liability claims are
|
||||
* assumed by the product vendor.
|
||||
*
|
||||
* This file contains a slower but more accurate integer implementation of the
|
||||
* forward DCT (Discrete Cosine Transform).
|
||||
*
|
||||
* A 2-D DCT can be done by 1-D DCT on each row followed by 1-D DCT
|
||||
* on each column. Direct algorithms are also available, but they are
|
||||
* much more complex and seem not to be any faster when reduced to code.
|
||||
*
|
||||
* This implementation is based on an algorithm described in
|
||||
* C. Loeffler, A. Ligtenberg and G. Moschytz, "Practical Fast 1-D DCT
|
||||
* Algorithms with 11 Multiplications", Proc. Int'l. Conf. on Acoustics,
|
||||
* Speech, and Signal Processing 1989 (ICASSP '89), pp. 988-991.
|
||||
* The primary algorithm described there uses 11 multiplies and 29 adds.
|
||||
* We use their alternate method with 12 multiplies and 32 adds.
|
||||
* The advantage of this method is that no data path contains more than one
|
||||
* multiplication; this allows a very simple and accurate implementation in
|
||||
* scaled fixed-point arithmetic, with a minimal number of shifts.
|
||||
*/
|
||||
|
||||
Original src/avx2/fdct.rs opening notice
|
||||
|
||||
/*
|
||||
* Ported from mozjpeg / jfdctint-avx2.asm to rust
|
||||
* Copyright 2009 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
* Copyright (C) 2009, 2016, 2018, 2020, D. R. Commander.
|
||||
*
|
||||
* Based on the x86 SIMD extension for IJG JPEG library
|
||||
* Copyright (C) 1999-2006, MIYASAKA Masaru.
|
||||
*/
|
||||
|
||||
@@ -118,3 +118,6 @@ sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.V1dalTheAbidal]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "cadcraft-dwg"
|
||||
description = "CADCraft DWG bridge: converts DWG to and from DXF bytes using the acadrust crate (MPL-2.0, used unmodified)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
|
||||
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
|
||||
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
|
||||
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
|
||||
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
|
||||
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
|
||||
acadrust = "0.6.6"
|
||||
@@ -1,12 +0,0 @@
|
||||
//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions
|
||||
//! there return an error that says to use DXF.
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)");
|
||||
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") {
|
||||
println!("cargo::rustc-cfg=no_dwg_bridge");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,632 +0,0 @@
|
||||
//! Another application's entities and their classes through the DWG bridge: on reading a DWG,
|
||||
//! registered entities acadrust has no model for become proxy entities of their class; on writing
|
||||
//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use acadrust::CadDocument;
|
||||
use acadrust::classes::{DxfClass, ProxyFlags};
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData};
|
||||
use acadrust::objects::{ObjectType, ProxyPayload};
|
||||
|
||||
fn class_number(index: usize) -> Result<i16, String> {
|
||||
index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into())
|
||||
}
|
||||
|
||||
/// After a DWG read: each registered entity of another application's class that acadrust keeps
|
||||
/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class
|
||||
/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object
|
||||
/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's
|
||||
/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is
|
||||
/// missing or not unique) is left out and named in the notes; one with decoded properties stays as
|
||||
/// read, also named, as its opaque data has no DXF form beside them.
|
||||
pub(super) fn prepare(doc: &mut CadDocument) -> Vec<String> {
|
||||
let classes: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
let mut numbers = HashMap::new();
|
||||
let mut identities = HashMap::new();
|
||||
for (index, class) in classes.iter().enumerate() {
|
||||
let number = class_number(index).ok();
|
||||
numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from));
|
||||
let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity);
|
||||
identities.entry(identity).and_modify(|v| *v = None).or_insert(number);
|
||||
}
|
||||
let identity = |dxf: &str, cpp: &str, entity: bool| {
|
||||
identities
|
||||
.get(&(dxf, cpp, entity))
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(i32::from)
|
||||
.ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})"))
|
||||
};
|
||||
let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() };
|
||||
let maintenance = i32::from(doc.maintenance_version);
|
||||
let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}"));
|
||||
// Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity.
|
||||
let handles: Vec<_> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(_) => Some(e.common.handle),
|
||||
ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut notes = Vec::new();
|
||||
let mut entity_count = 0usize;
|
||||
let mut object_count = 0usize;
|
||||
let mut semantic = 0usize;
|
||||
for handle in handles {
|
||||
// Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties.
|
||||
let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<Option<Option<ExtendedEntityData>>, String> {
|
||||
let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) };
|
||||
let data = match &entity.data {
|
||||
ExtendedEntityData::Proxy(data) => {
|
||||
let mut proxy = data.clone();
|
||||
proxy.class_id = remap(proxy.class_id)?;
|
||||
ExtendedEntityData::Proxy(proxy)
|
||||
}
|
||||
ExtendedEntityData::RegisteredClass(data) => {
|
||||
if !data.properties.is_empty() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?;
|
||||
let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default();
|
||||
ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: String::new(),
|
||||
version: (maintenance << 16) | dwg_version,
|
||||
dwg_version,
|
||||
maintenance_version: maintenance,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()),
|
||||
payload: data.payload.clone(),
|
||||
text_payload,
|
||||
object_ids: data.object_ids.clone(),
|
||||
})
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(Some(data)))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("application record conversion failed".into()));
|
||||
match converted {
|
||||
Ok(Some(Some(data))) => {
|
||||
if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) {
|
||||
entity.data = data;
|
||||
}
|
||||
}
|
||||
Ok(Some(None)) => semantic = semantic.saturating_add(1),
|
||||
Ok(None) => {}
|
||||
Err(why) => {
|
||||
doc.remove_entity(handle);
|
||||
entity_count = entity_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "entity", handle, &why);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.objects.retain(|handle, object| {
|
||||
if let ObjectType::ProxyObject(proxy) = object {
|
||||
match remap(proxy.class_id) {
|
||||
Ok(id) => proxy.class_id = id,
|
||||
Err(why) => {
|
||||
object_count = object_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "object", *handle, &why);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if entity_count > 0 || object_count > 0 {
|
||||
notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})"));
|
||||
}
|
||||
if semantic > 0 {
|
||||
notes.push(format!(
|
||||
"DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it"
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
fn skipped_note(notes: &mut Vec<String>, kind: &str, handle: acadrust::types::Handle, why: &str) {
|
||||
if notes.len() < 6 {
|
||||
let why: String = why.chars().take(256).collect();
|
||||
notes.push(format!("DWG: proxy {kind} {handle} left out: {why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoder's registered-class representation omits the separate R2007+ string stream.
|
||||
fn registered_text(raw: &acadrust::entities::RawRecord) -> Result<ProxyPayload, String> {
|
||||
use acadrust::io::dwg::DwgVersion;
|
||||
use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader;
|
||||
use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter;
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
if raw.version < DxfVersion::AC1021 {
|
||||
return Ok(ProxyPayload::default());
|
||||
}
|
||||
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?;
|
||||
let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?;
|
||||
if handle_bits > total_bits {
|
||||
return Err("DWG proxy text: handle stream exceeds record".into());
|
||||
}
|
||||
// Frame the verbatim record for the dependency's checked object reader.
|
||||
let mut frame = DwgBitWriter::new(version, raw.version);
|
||||
frame.write_modular_short(raw.data.len());
|
||||
if raw.version >= DxfVersion::AC1024 {
|
||||
frame.write_modular_char(handle_bits);
|
||||
}
|
||||
frame.write_bytes(&raw.data);
|
||||
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
reader.read_common_entity_data(&mut streams, kind);
|
||||
if kind == 498 {
|
||||
// An existing registered-class envelope is a proxy and carries its class name first.
|
||||
streams.read_variable_text();
|
||||
} else if kind < 500 {
|
||||
return Err("DWG proxy text: registered entity has no application class".into());
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?;
|
||||
if bits > total_bits {
|
||||
return Err("DWG proxy text: string stream exceeds record".into());
|
||||
}
|
||||
let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?;
|
||||
let mut bytes = vec![0; bits.div_ceil(8)];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
for bit in 0..8.min(bits.saturating_sub(index * 8)) {
|
||||
if streams.read_text_bit() {
|
||||
*byte |= 0x80 >> bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
Ok(ProxyPayload::from_bits(&bytes, count))
|
||||
}
|
||||
|
||||
/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed
|
||||
/// pair yields `None`, so both class scans fall back rather than guessing its identity.
|
||||
pub(super) fn text_pairs(dxf: &[u8]) -> Option<impl Iterator<Item = Option<(i32, String)>> + '_> {
|
||||
use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page};
|
||||
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let version =
|
||||
super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string);
|
||||
let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021);
|
||||
let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) {
|
||||
super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line));
|
||||
Some(std::iter::from_fn(move || {
|
||||
let code = lines.next()?;
|
||||
let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) };
|
||||
let pair = || {
|
||||
let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?;
|
||||
let text = if let Some(encoding) = encoding {
|
||||
encoding.decode(value).0.into_owned()
|
||||
} else if unicode {
|
||||
String::from_utf8_lossy(value).into_owned()
|
||||
} else {
|
||||
// The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared.
|
||||
std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string)
|
||||
};
|
||||
let text = decode_legacy_escapes(&text);
|
||||
// DXF's escaped control characters are decoded after CIF/MIF, as in the bridge.
|
||||
let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^");
|
||||
Some((code, text))
|
||||
};
|
||||
Some(pair())
|
||||
}))
|
||||
}
|
||||
|
||||
/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF
|
||||
/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (CADCraft's own
|
||||
/// bridge DXF is ASCII), and for one with a record that can't be written back as it is: no names,
|
||||
/// flags or instance count out of range, more classes than the numbers allow. Then
|
||||
/// `proxy_classes` matches the classes by name instead.
|
||||
pub(super) fn source_classes(dxf: &[u8]) -> Option<Vec<DxfClass>> {
|
||||
let pairs = text_pairs(dxf)?;
|
||||
let mut records: Vec<Vec<(i32, String)>> = Vec::new();
|
||||
let (mut section, mut in_classes) = (false, false);
|
||||
for pair in pairs {
|
||||
let (code, value) = pair?;
|
||||
match (code, value.as_str()) {
|
||||
(0, "ENDSEC") if in_classes => break,
|
||||
(0, "SECTION") => section = true,
|
||||
(2, name) if section => {
|
||||
in_classes = name == "CLASSES";
|
||||
section = false;
|
||||
}
|
||||
(0, kind) if in_classes => {
|
||||
if kind != "CLASS" {
|
||||
return None;
|
||||
}
|
||||
records.push(Vec::new());
|
||||
}
|
||||
(code, value) if in_classes => records.last_mut()?.push((code, value.to_string())),
|
||||
_ => section = false,
|
||||
}
|
||||
}
|
||||
records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect()
|
||||
}
|
||||
|
||||
/// One CLASS record's groups as the class `number`, or `None` when it can't be written back.
|
||||
fn source_class(groups: &[(i32, String)], number: i16) -> Option<DxfClass> {
|
||||
let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::<i64>().ok());
|
||||
let mut class = DxfClass::new(text(1), text(2));
|
||||
if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
class.application_name = text(3);
|
||||
class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?);
|
||||
class.instance_count = i32::try_from(integer(91)?).ok()?;
|
||||
class.was_zombie = integer(280)? != 0;
|
||||
class.is_an_entity = integer(281)? != 0;
|
||||
class.item_class_id = if class.is_an_entity { 498 } else { 499 };
|
||||
class.class_number = number;
|
||||
Some(class)
|
||||
}
|
||||
|
||||
/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in
|
||||
/// their places, with their metadata and duplicate names, so each proxy's DXF class number names
|
||||
/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number
|
||||
/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and
|
||||
/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing
|
||||
/// changed, when the classes would be more than the class numbers allow.
|
||||
pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec<DxfClass>) -> Option<(usize, usize)> {
|
||||
let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len());
|
||||
let mut any = false;
|
||||
let mut entities = Vec::new();
|
||||
for entity in doc.entities() {
|
||||
if let EntityType::Extended(e) = entity
|
||||
&& let ExtendedEntityData::Proxy(p) = &e.data
|
||||
{
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
entities.push(e.common.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut objects = Vec::new();
|
||||
for (handle, object) in &doc.objects {
|
||||
if let ObjectType::ProxyObject(p) = object {
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
objects.push(*handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Some((0, 0));
|
||||
}
|
||||
let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect();
|
||||
let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect();
|
||||
if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) {
|
||||
class_number(last).ok()?;
|
||||
}
|
||||
for handle in &entities {
|
||||
doc.remove_entity(*handle);
|
||||
}
|
||||
for handle in &objects {
|
||||
doc.objects.remove(handle);
|
||||
}
|
||||
doc.classes.clear();
|
||||
for (index, mut class) in classes.into_iter().chain(defaults).enumerate() {
|
||||
class.class_number = class_number(index).ok()?;
|
||||
doc.classes.push_preserving(class);
|
||||
}
|
||||
Some((objects.len(), entities.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
fn proxy_doc() -> CadDocument {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.version = DxfVersion::AC1032;
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id: 501,
|
||||
dxf_subclass: String::new(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
text_payload: ProxyPayload::default(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// An ASCII DXF of `groups`, `(code, value)` per pair of lines.
|
||||
fn dxf(groups: &[(i32, &str)]) -> Vec<u8> {
|
||||
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_keep_duplicates_and_metadata_before_defaults() {
|
||||
let bytes = dxf(&[
|
||||
(0, "SECTION"),
|
||||
(2, "HEADER"),
|
||||
(9, "$DIMSTYLE"),
|
||||
(2, "CLASSES"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "CLASSES"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "First"),
|
||||
(3, "Первый"),
|
||||
(90, "1025"),
|
||||
(91, "7"),
|
||||
(280, "1"),
|
||||
(281, "1"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "Second"),
|
||||
(3, "Второй"),
|
||||
(90, "128"),
|
||||
(281, "1"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
]);
|
||||
let classes = source_classes(&bytes).unwrap();
|
||||
assert_eq!(classes.len(), 2);
|
||||
assert_eq!(classes[0].application_name, "Первый");
|
||||
assert_eq!(classes[0].proxy_flags.0, 1025);
|
||||
assert_eq!(classes[0].instance_count, 7);
|
||||
assert!(classes[0].was_zombie);
|
||||
assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501));
|
||||
assert_eq!(classes[1].cpp_class_name, "Second");
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().take(2).cloned().collect::<Vec<_>>(), classes);
|
||||
// A binary DXF, and a CLASS record that can't be written back as it is, give no classes.
|
||||
assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none());
|
||||
for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] {
|
||||
let mut class = vec![(1, "A"), (2, "A")];
|
||||
class.retain(|(c, _)| *c != code);
|
||||
class.push((code, value));
|
||||
let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")];
|
||||
groups.extend(class);
|
||||
groups.extend([(0, "ENDSEC"), (0, "EOF")]);
|
||||
assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() {
|
||||
let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
let text = format!("0\nSECTION\n2\nCLASSES\n{body}");
|
||||
let classes = source_classes(text.as_bytes()).unwrap();
|
||||
assert_eq!(classes[0].dxf_name, "Объект");
|
||||
assert_eq!(classes[0].application_name, "Café🙂\\U+0041");
|
||||
|
||||
let prefix =
|
||||
b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n";
|
||||
let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat();
|
||||
assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример");
|
||||
// R2007+ text is UTF-8 even with a legacy code-page declaration.
|
||||
let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_of_missing_classes_are_left_out() {
|
||||
for id in [-1, 499, 501, i32::MAX] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = id;
|
||||
}
|
||||
let mut class = DxfClass::new_entity("ENTITY", "Entity");
|
||||
class.class_number = 500;
|
||||
assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}");
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
}
|
||||
// Class 501 is there: the proxy keeps it, in its place.
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0)));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501)));
|
||||
// Without proxies the classes stay the decoder's.
|
||||
let mut doc = CadDocument::new();
|
||||
let before: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().cloned().collect::<Vec<_>>(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() {
|
||||
use acadrust::entities::{Line, RawRecord};
|
||||
use acadrust::objects::ProxyObject;
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
for count in [1, 10] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = i32::MAX;
|
||||
}
|
||||
let template = doc.entities().next().unwrap().clone();
|
||||
for _ in 1..count {
|
||||
let mut entity = template.clone();
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
doc.add_entity(entity).unwrap();
|
||||
}
|
||||
let object = Handle::new(u64::MAX - 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() }));
|
||||
let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap();
|
||||
let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
let notes = prepare(&mut doc);
|
||||
assert_eq!(notes.len(), (count + 1).min(6) + 1);
|
||||
assert!(notes.iter().all(|n| n.starts_with("DWG:")));
|
||||
assert!(notes[0].contains(&format!("entities: {count}, objects: 1")));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") };
|
||||
assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0));
|
||||
assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0));
|
||||
assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn opaque_registered_class_ambiguity_is_not_guessed() {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM".into(),
|
||||
cpp_class_name: "Custom".into(),
|
||||
properties: vec![],
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous")));
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
assert!(class_number(usize::MAX).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() {
|
||||
use acadrust::entities::RawRecord;
|
||||
use acadrust::objects::{SemanticProperty, SemanticPropertyValue};
|
||||
for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] {
|
||||
let mut doc = CadDocument::new();
|
||||
let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
let handle = doc
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "SEMANTIC".into(),
|
||||
cpp_class_name: "Semantic".into(),
|
||||
properties: vec![SemanticProperty {
|
||||
subclass: "Semantic".into(),
|
||||
code: 1,
|
||||
value: SemanticPropertyValue::Text("value".into()),
|
||||
}],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
if payload.bit_count == 0 {
|
||||
assert!(prepare(&mut doc).is_empty());
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
} else {
|
||||
// Kept as read, and named: its opaque data has no DXF form beside the properties.
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties")));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod string_stream_tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn registered_transport_recovers_separate_non_byte_aligned_strings() {
|
||||
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut source = CadDocument::new();
|
||||
source.version = version;
|
||||
source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText"));
|
||||
let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number);
|
||||
let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19);
|
||||
let payload = ProxyPayload::from_bits(&[0x80], 1);
|
||||
source
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: "CustomText".into(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: payload.clone(),
|
||||
text_payload: text.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap();
|
||||
let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let entity = decoded.entities().next().unwrap();
|
||||
let handle = entity.common().handle;
|
||||
let raw = entity.common().raw_record.clone().unwrap();
|
||||
assert_eq!(registered_text(&raw).unwrap().data(), text.data());
|
||||
assert_eq!(registered_text(&raw).unwrap().bit_count, 19);
|
||||
let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() };
|
||||
entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM_TEXT".into(),
|
||||
cpp_class_name: "CustomText".into(),
|
||||
properties: vec![],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
});
|
||||
entity.common.raw_record = Some(raw);
|
||||
assert!(prepare(&mut decoded).is_empty());
|
||||
for _ in 0..2 {
|
||||
let mut dxf = Vec::new();
|
||||
acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap();
|
||||
let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap();
|
||||
decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let p = decoded
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(p) => Some(p),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(p.payload.bit_count, 1);
|
||||
assert_eq!(p.payload.data(), payload.data());
|
||||
assert_eq!(p.text_payload.bit_count, 19);
|
||||
assert_eq!(p.text_payload.data(), text.data());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_string_stream_boundaries_return_errors() {
|
||||
for handle_bits in [-1, 1000] {
|
||||
let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
|
||||
assert!(registered_text(&raw).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
//! The DWG reader and writer in a child process.
|
||||
//!
|
||||
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
|
||||
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
|
||||
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
|
||||
//! that reads one request from standard input and writes one reply to standard output. A file that
|
||||
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
|
||||
//!
|
||||
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
|
||||
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
|
||||
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
|
||||
//!
|
||||
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
|
||||
//! untrusted input.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::{RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::Limits;
|
||||
|
||||
/// The argument that makes the app a worker.
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
/// Bumped when the messages change: an app updated while running may start a newer copy.
|
||||
const PROTOCOL: u8 = 2;
|
||||
const DECODE: u8 = 1;
|
||||
const ENCODE: u8 = 2;
|
||||
const CRASH: u8 = 0xff;
|
||||
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
/// Most bytes accepted for what a reply carries besides the drawing.
|
||||
const MAX_NOTES_BYTES: u64 = 64 << 20;
|
||||
/// Most bytes accepted as a written DWG.
|
||||
const MAX_DWG_REPLY: u64 = 2 << 30;
|
||||
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
|
||||
const MAX_REQUEST: u64 = (2 << 30) + 256;
|
||||
|
||||
struct Worker {
|
||||
program: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
|
||||
|
||||
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
|
||||
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
|
||||
if let Ok(mut w) = WORKER.lock() {
|
||||
*w = Some(Worker { program, args });
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
|
||||
/// found, the reader and writer stay in this process.
|
||||
pub fn use_own_worker() {
|
||||
match std::env::current_exe() {
|
||||
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
|
||||
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request and return the exit code if this process was started as a worker.
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
|
||||
}
|
||||
|
||||
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
// Before starting a process, and so the same limit holds with or without one.
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return super::dwg_to_dxf_noted(bytes, limits);
|
||||
}
|
||||
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
|
||||
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
|
||||
Some(reply) => reply?,
|
||||
None => super::dwg_to_dxf_noted(bytes, limits),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
encode_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
|
||||
/// back as notes of `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
encode_kept_noted(dxf, version, None)
|
||||
}
|
||||
|
||||
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
|
||||
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
|
||||
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
// A kept version too long to send is no version CADCraft knows: it changes nothing.
|
||||
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
|
||||
let mut header = request(ENCODE, Limits::DEFAULT, version);
|
||||
push_str(&mut header, kept.unwrap_or(""));
|
||||
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))),
|
||||
None => encode_here(dxf, version, kept),
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversion in this process.
|
||||
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
match kept {
|
||||
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten_from(notes: &[String]) -> super::Unwritten {
|
||||
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
|
||||
super::Unwritten::new(),
|
||||
|mut m, (kind, n)| {
|
||||
*m.entry(kind).or_insert(0) = n;
|
||||
m
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
|
||||
#[doc(hidden)]
|
||||
pub fn crash_worker_for_test() -> Result<(), String> {
|
||||
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
|
||||
}
|
||||
|
||||
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
|
||||
let mut r = vec![PROTOCOL, op];
|
||||
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
|
||||
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
|
||||
push_str(&mut r, version);
|
||||
r
|
||||
}
|
||||
|
||||
/// `text` with a one-byte length before it; callers keep it under 256 bytes.
|
||||
fn push_str(r: &mut Vec<u8>, text: &str) {
|
||||
r.push(u8::try_from(text.len()).unwrap_or(0));
|
||||
r.extend(text.as_bytes());
|
||||
}
|
||||
|
||||
type Reply = Result<(Vec<u8>, Vec<String>), String>;
|
||||
|
||||
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
|
||||
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
|
||||
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
|
||||
let mut command = {
|
||||
let worker = WORKER.lock().ok()?;
|
||||
let worker = worker.as_ref()?;
|
||||
let mut c = Command::new(&worker.program);
|
||||
c.args(&worker.args);
|
||||
c
|
||||
};
|
||||
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("DWG worker: can't start it: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let received = std::thread::scope(|scope| {
|
||||
// Written beside the read: a worker that replies before it has read everything, or dies,
|
||||
// must block neither side. A failed write shows up as the reply going wrong.
|
||||
scope.spawn(move || {
|
||||
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
scope.spawn(move || {
|
||||
let mut reply = Vec::new();
|
||||
let read = from_child.take(reply_limit).read_to_end(&mut reply);
|
||||
let _ = tx.send(read.map(|_| reply));
|
||||
});
|
||||
let received = rx.recv_timeout(TIMEOUT);
|
||||
if matches!(received, Err(RecvTimeoutError::Timeout)) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
received
|
||||
});
|
||||
let status = child.wait();
|
||||
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
|
||||
Some(match (received, status) {
|
||||
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
|
||||
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
|
||||
_ => Err(stopped()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take `n` bytes off the front of `b`.
|
||||
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, tail) = b.split_at_checked(n)?;
|
||||
*b = tail;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn take_u32(b: &mut &[u8]) -> Option<usize> {
|
||||
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_le_bytes(bytes)).ok()
|
||||
}
|
||||
|
||||
fn take_u64(b: &mut &[u8]) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
|
||||
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
|
||||
/// of the memory the app may use: they are kept, not copied.
|
||||
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
|
||||
let total = reply.len();
|
||||
let mut b = &reply[..];
|
||||
match *take(&mut b, 1)?.first()? {
|
||||
0 => {
|
||||
let count = take_u32(&mut b)?;
|
||||
let mut notes = Vec::new();
|
||||
for _ in 0..count {
|
||||
let n = take_u32(&mut b)?;
|
||||
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
|
||||
}
|
||||
let n = usize::try_from(take_u64(&mut b)?).ok()?;
|
||||
if b.len() != n {
|
||||
return None;
|
||||
}
|
||||
reply.drain(..total - n);
|
||||
Some(Ok((reply, notes)))
|
||||
}
|
||||
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
|
||||
match reply {
|
||||
Ok((bytes, notes)) => {
|
||||
out.write_all(&[0])?;
|
||||
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
|
||||
for note in ¬es {
|
||||
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
|
||||
if u32::try_from(note.len()).is_ok() {
|
||||
out.write_all(note.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
|
||||
out.write_all(&bytes)?;
|
||||
}
|
||||
Err(e) => {
|
||||
out.write_all(&[1])?;
|
||||
out.write_all(e.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
/// What a request asks for, or why it can't be served.
|
||||
fn handle(mut b: &[u8]) -> Reply {
|
||||
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
|
||||
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
|
||||
return Err(bad());
|
||||
}
|
||||
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => {
|
||||
// The version the DXF's carried-over groups were read from; empty for none.
|
||||
let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
encode_here(b, version, (!kept.is_empty()).then_some(kept))
|
||||
.map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect()))
|
||||
}
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
|
||||
fn serve(input: impl Read, mut output: impl Write) -> i32 {
|
||||
let mut request = Vec::new();
|
||||
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
|
||||
Ok(_) => handle(&request),
|
||||
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
|
||||
};
|
||||
i32::from(write_reply(&mut output, reply).is_err())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wire(reply: Reply) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_reply(&mut out, reply).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_writer_left_out_survives_the_reply() {
|
||||
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
|
||||
assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
|
||||
let failed: Reply = Err("DWG: no".into());
|
||||
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_cut_short_or_made_up_is_refused() {
|
||||
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
|
||||
for n in 0..whole.len() {
|
||||
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
|
||||
}
|
||||
assert_eq!(parse_reply(vec![7, 0, 0]), None);
|
||||
// A note count far beyond what follows ends at the first missing note.
|
||||
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
|
||||
for request in [
|
||||
&[][..],
|
||||
&[PROTOCOL + 1, DECODE],
|
||||
&[PROTOCOL],
|
||||
&[PROTOCOL, DECODE, 1, 2, 3],
|
||||
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
] {
|
||||
assert!(handle(request).is_err(), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An encode request carries the kept version after the target one; a request without it is
|
||||
/// one it can't read, and one with it reaches the converter.
|
||||
#[test]
|
||||
fn an_encode_request_carries_the_kept_version() {
|
||||
let bad = "the DWG worker got a request it can't read (a different CADCraft version?)";
|
||||
let short = self::request(ENCODE, Limits::DEFAULT, "AC1032");
|
||||
assert_eq!(handle(&short).unwrap_err(), bad);
|
||||
for kept in ["", "AC1032"] {
|
||||
let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032");
|
||||
push_str(&mut request, kept);
|
||||
request.extend(b"not a drawing");
|
||||
let err = handle(&request).unwrap_err();
|
||||
assert_ne!(err, bad, "kept {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_reports_a_decode_failure_as_a_reply() {
|
||||
let mut request = self::request(DECODE, Limits::DEFAULT, "");
|
||||
request.extend(b"AC1032 not really a drawing");
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(serve(&request[..], &mut out), 0);
|
||||
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
||||
|
||||
pub(super) fn blockreplace_spec() -> CommandSpec {
|
||||
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
|
||||
.menu(&["Tools", "Express Tools", "Replace Block..."])
|
||||
.menu(&["Tools", "Additional Tools", "Replace Block..."])
|
||||
.alias(&["-blockreplace"])
|
||||
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
|
||||
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
|
||||
@@ -184,8 +184,10 @@ impl Interactive for BlockreplaceM {
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]).takes_block(),
|
||||
Stage::New => {
|
||||
Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]).takes_block()
|
||||
}
|
||||
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
|
||||
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ 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"])
|
||||
.menu(&["Tools", "Additional Tools", "Explode Attributes to Text"])
|
||||
.params("{handles?} (block references; other objects are left alone) → {handles}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
|
||||
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
|
||||
.menu(&["Tools", "Additional 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}",
|
||||
|
||||
@@ -557,9 +557,15 @@ fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn run_selectall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let frozen = s.viewport_frozen();
|
||||
let hs: Vec<Handle> = d
|
||||
.space(&space)
|
||||
.map(|st| st.iter().filter(|e| d.is_visible(e) && d.layer(&e.common.layer).is_none_or(|l| !l.locked)).map(|e| e.handle).collect())
|
||||
.map(|st| {
|
||||
st.iter()
|
||||
.filter(|e| crate::select::selectable(d, e, frozen) && d.layer(&e.common.layer).is_none_or(|l| !l.locked))
|
||||
.map(|e| e.handle)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let n = hs.len();
|
||||
s.set_selection(hs);
|
||||
@@ -580,16 +586,19 @@ fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let add = bool_or(p, "add", false);
|
||||
let picked: Vec<Handle> = if let Some(w) = points_param(p, "window") {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("select", "window needs 2 corners")) };
|
||||
crate::select::select_window(s.doc()?, &space, Bounds2::new(*a, *b), bool_or(p, "crossing", false))
|
||||
crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), Bounds2::new(*a, *b), bool_or(p, "crossing", false))
|
||||
} else if let Some(at) = point_param(p, "at") {
|
||||
let ap = f64_or(p, "aperture", s.pixel_size() * s.settings.pickbox * 1.5);
|
||||
crate::select::pick(s.doc()?, &space, at, ap).into_iter().collect()
|
||||
crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), at, ap).into_iter().collect()
|
||||
} else if let Some(f) = points_param(p, "fence") {
|
||||
crate::select::select_fence(s.doc()?, &space, &f)
|
||||
} else {
|
||||
crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &f)
|
||||
} else if ["handles", "handle"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
targets(s, p)?
|
||||
} else {
|
||||
// Nothing to select with: the selection stays, also what the active viewport does not draw.
|
||||
s.stored_selection()
|
||||
};
|
||||
let mut sel = if add { s.selection() } else { Vec::new() };
|
||||
let mut sel = if add { s.stored_selection() } else { Vec::new() };
|
||||
sel.extend(picked);
|
||||
s.set_selection(sel);
|
||||
let sel = s.selection();
|
||||
|
||||
@@ -363,7 +363,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, b) = (screen(Vec2::new(min.x, max.y)), screen(Vec2::new(max.x, min.y)));
|
||||
cadcraft_render::raster::blank_outside_pixels(&mut pm, a, b);
|
||||
}
|
||||
let png = pm.encode_png().map_err(|_| bad("preview", "rendering the preview failed"))?;
|
||||
let png = cadcraft_render::raster::encode_png(&pm).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
||||
let layout = space.tab_name();
|
||||
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ 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_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub(crate) use pgp::{greek_alias, specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
|
||||
|
||||
@@ -59,6 +59,12 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
store().map.get(name).cloned()
|
||||
}
|
||||
|
||||
/// The user alias that is the Greek word `word` in another case or accent.
|
||||
pub(crate) fn greek_alias(word: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(word)?;
|
||||
store().map.keys().find(|alias| same(alias)).cloned()
|
||||
}
|
||||
|
||||
/// The user aliases and the file REINIT reads, as the host's user profile keeps them:
|
||||
/// `{"aliases": {alias: command}, "file": path or null}`. Not drawing data.
|
||||
pub fn alias_profile() -> Value {
|
||||
|
||||
@@ -43,7 +43,8 @@ pub(crate) struct GripEdit {
|
||||
hot: Vec<(Handle, usize)>,
|
||||
/// Typed `GRIP`: still collecting grips ("Select grips").
|
||||
picking: bool,
|
||||
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the selection.
|
||||
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the
|
||||
/// selection that the view being edited shows.
|
||||
targets: Vec<Handle>,
|
||||
base: Vec2,
|
||||
mode: GripMode,
|
||||
@@ -69,6 +70,13 @@ fn positive(v: Option<f64>) -> Result<f64> {
|
||||
v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number."))
|
||||
}
|
||||
|
||||
/// Whether the view being edited shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer,
|
||||
/// a layer frozen in the active MSPACE viewport) takes no part in a grip edit, even when Undo or
|
||||
/// a selection command put it back into the selection.
|
||||
fn shown(s: &Session, h: Handle) -> bool {
|
||||
s.state().is_ok_and(|st| st.doc.entity(h).is_some_and(|e| st.shows(e)))
|
||||
}
|
||||
|
||||
impl GripEdit {
|
||||
fn new(s: &Session, hot: Vec<(Handle, usize)>) -> Result<Self> {
|
||||
let mut g = GripEdit {
|
||||
@@ -87,16 +95,22 @@ impl GripEdit {
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
/// Make grip `index` of `h` hot (the first one becomes the base point).
|
||||
/// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object
|
||||
/// the view being edited does not show is left out, and so are the hidden objects of the
|
||||
/// selection.
|
||||
fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> {
|
||||
if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT {
|
||||
return Ok(());
|
||||
}
|
||||
let p = s.grips_of(h)?.get(i).copied().ok_or_else(|| other("no such grip"))?;
|
||||
// After the lookup: a missing object or grip stays the error it was.
|
||||
if !shown(s, h) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.hot.is_empty() {
|
||||
self.base = p;
|
||||
self.targets.push(h);
|
||||
self.targets.extend(s.selection().into_iter().filter(|x| *x != h));
|
||||
self.targets.extend(s.selection().into_iter().filter(|x| *x != h && shown(s, *x)));
|
||||
} else if !self.targets.contains(&h) {
|
||||
self.targets.push(h);
|
||||
}
|
||||
@@ -107,13 +121,13 @@ impl GripEdit {
|
||||
/// The grips of the selected objects at `p` (typed `GRIP`): all objects with a grip there.
|
||||
fn grips_at(s: &Session, p: Vec2) -> Vec<(Handle, usize)> {
|
||||
let tol = (s.pixel_size() * (s.settings.gripsize + 3.0)).max(1e-9 * (1.0 + p.len()));
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let Ok(st) = s.state() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for h in s.selection().into_iter().take(MAX_HOT) {
|
||||
if super::curves::is_locked(s, h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let Some(e) = st.doc.entity(h).filter(|e| st.shows(e)) else { continue };
|
||||
let gs = e.kind.grips();
|
||||
// The nearest grip of this object within the tolerance.
|
||||
let best = gs
|
||||
@@ -470,7 +484,9 @@ impl Interactive for GripEdit {
|
||||
impl Session {
|
||||
/// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects
|
||||
/// several first). `hot` lists (object, grip index); the first is the base grip. The grip
|
||||
/// modes and their options then run at the command line like any prompt.
|
||||
/// modes and their options then run at the command line like any prompt. Grips of objects the
|
||||
/// view being edited does not show are left out (the first grip left is the base grip), and it
|
||||
/// is an error when none is left.
|
||||
pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
@@ -479,6 +495,9 @@ impl Session {
|
||||
return Err(other("no grip"));
|
||||
}
|
||||
let g = GripEdit::new(self, hot.to_vec())?;
|
||||
if g.hot.is_empty() {
|
||||
return Err(other("no grip"));
|
||||
}
|
||||
let label = g.label();
|
||||
let machine = crate::guard::Guarded::wrap(Box::new(g));
|
||||
let st = self.state()?;
|
||||
|
||||
@@ -28,7 +28,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
const MEMBER_PICKS: &[&str] = &["groupedit", "-group"];
|
||||
|
||||
/// `hs` plus the other members of the selectable groups they belong to (in the space being
|
||||
/// edited, visible), when PICKSTYLE has group selection on. Order: `hs` first.
|
||||
/// edited, visible, and inside an MSPACE viewport not on a layer frozen in it), when PICKSTYLE
|
||||
/// has group selection on. Order: `hs` first.
|
||||
pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
if s.settings.pickstyle & 1 == 0 || hs.is_empty() || s.running.as_ref().is_some_and(|r| MEMBER_PICKS.contains(&r.id.as_str())) {
|
||||
return hs;
|
||||
@@ -39,6 +40,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
}
|
||||
let space = s.space();
|
||||
let Some(store) = d.space(&space) else { return hs };
|
||||
let frozen = s.viewport_frozen();
|
||||
// Member → its selectable groups, built once (window selections may hold many objects).
|
||||
let mut of: HashMap<Handle, Vec<usize>> = HashMap::new();
|
||||
for (i, g) in d.groups.iter().enumerate().filter(|(_, g)| g.selectable) {
|
||||
@@ -56,7 +58,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
}
|
||||
let Some(g) = d.groups.get(*gi) else { continue };
|
||||
for m in &g.members {
|
||||
if store.get(*m).is_some_and(|e| d.is_visible(e)) && seen.insert(*m) {
|
||||
if store.get(*m).is_some_and(|e| crate::select::selectable(d, e, frozen)) && seen.insert(*m) {
|
||||
out.push(*m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,6 +533,19 @@ fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "message": format!("{n} Region(s) created.") }))
|
||||
}
|
||||
|
||||
/// Whether the upper-case `name` is a pattern: a standard one or a loaded one.
|
||||
fn pattern_exists(name: &str) -> bool {
|
||||
cadcraft_doc::library::pattern(name).is_some() || patfile::user_pattern(name).is_some()
|
||||
}
|
||||
|
||||
/// The existing pattern `name` means, typed at the pattern prompt: read upper-cased, else a loaded
|
||||
/// pattern's own name when only its Greek spelling differs in case or accent (σολιδ for ΣΟΛΙΔ).
|
||||
pub(crate) fn pattern_named(name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
let up = name.to_ascii_uppercase();
|
||||
if pattern_exists(&up) { Some(up) } else { patfile::user_pattern_name(name) }
|
||||
}
|
||||
|
||||
/// The origin sub-prompt's keywords (a point sets a new origin).
|
||||
const ORIGIN_KW: [&str; 6] = ["Bottom left", "bottom Right", "Top left", "top riGht", "Center", "Use current"];
|
||||
|
||||
@@ -597,7 +610,7 @@ impl HatchM {
|
||||
Some(n) => n,
|
||||
None => t.trim().to_ascii_uppercase(),
|
||||
};
|
||||
if cadcraft_doc::library::pattern(&up).is_some() || patfile::user_pattern(&up).is_some() {
|
||||
if pattern_exists(&up) {
|
||||
self.settings.pattern = up.clone();
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,13 @@ pub(crate) fn user_pattern(name: &str) -> Option<UserPattern> {
|
||||
store().iter().find(|p| p.name.eq_ignore_ascii_case(name)).cloned()
|
||||
}
|
||||
|
||||
/// The name of the user pattern that is the Greek word `name` in another case or accent
|
||||
/// ([`user_pattern`] folds ASCII only).
|
||||
pub(crate) fn user_pattern_name(name: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(name)?;
|
||||
store().iter().find(|p| same(&p.name)).map(|p| p.name.clone())
|
||||
}
|
||||
|
||||
/// The line families a new hatch of pattern `name` carries: a user pattern's, else none (the
|
||||
/// library's patterns are drawn and saved from the library).
|
||||
pub(crate) fn lines_for(name: &str) -> Vec<PatternLine> {
|
||||
|
||||
@@ -591,7 +591,7 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"dimStyles": d.dim_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
"blocks": d.blocks.keys().filter(|k| !k.starts_with('*')).cloned().collect::<Vec<_>>(),
|
||||
"layouts": d.layouts.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
"selection": st.shown_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.
|
||||
|
||||
@@ -21,14 +21,17 @@ impl StretchArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
|
||||
/// selection tests the drawn element lines, which sit away from the control vertices by the
|
||||
/// multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return };
|
||||
/// Add to `hs` the multilines in the edit space that a selection can take there (visible, and
|
||||
/// inside an MSPACE viewport not on a layer frozen in it) with a control vertex inside `area`. A
|
||||
/// crossing selection tests the drawn element lines, which sit away from the control vertices by
|
||||
/// the multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(s: &Session, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Ok(d) = s.doc() else { return };
|
||||
let Some(store) = d.space(&s.space()) else { return };
|
||||
let frozen = s.viewport_frozen();
|
||||
for e in store.iter() {
|
||||
if let EntityKind::MLine(m) = &e.kind
|
||||
&& d.is_visible(e)
|
||||
&& crate::select::selectable(d, e, frozen)
|
||||
&& !hs.contains(&e.handle)
|
||||
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
|
||||
{
|
||||
@@ -94,19 +97,19 @@ pub(super) fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// 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 hs = crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &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))
|
||||
(crate::select::select_polygon_in(s.doc()?, &space, s.viewport_frozen(), &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))
|
||||
(crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, true), StretchArea::Box(bx))
|
||||
};
|
||||
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
|
||||
add_mline_vertex_hits(s, &area, &mut hs);
|
||||
hs.retain(|h| !crate::cmd::curves::is_locked(s, *h));
|
||||
let doc = s.doc_mut()?;
|
||||
let areas = [area];
|
||||
@@ -173,7 +176,7 @@ impl StretchM {
|
||||
/// of two) the mode is dropped.
|
||||
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
|
||||
let space = s.space();
|
||||
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let Some(mut hs) = m.select_in(s.doc()?, &space, s.viewport_frozen()) else { return Ok(()) };
|
||||
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
|
||||
(SelectKind::Box, Some((a, b))) => b.x < a.x,
|
||||
@@ -189,7 +192,7 @@ impl StretchM {
|
||||
};
|
||||
let whole = area.is_none();
|
||||
if let (Some(a), true, false) = (&area, crossing, self.removing) {
|
||||
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
|
||||
add_mline_vertex_hits(s, a, &mut hs);
|
||||
}
|
||||
if let (Some(a), false) = (area, self.removing) {
|
||||
self.windows.push(a);
|
||||
@@ -228,10 +231,10 @@ impl StretchM {
|
||||
Some(c) => {
|
||||
let bx = cadcraft_geom::Bounds2::new(c, p);
|
||||
let space = s.space();
|
||||
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
let mut hs = crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, p.x < c.x);
|
||||
if !self.removing {
|
||||
if p.x < c.x {
|
||||
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
|
||||
add_mline_vertex_hits(s, &StretchArea::Box(bx), &mut hs);
|
||||
}
|
||||
self.windows.push(StretchArea::Box(bx));
|
||||
}
|
||||
@@ -240,7 +243,7 @@ impl StretchM {
|
||||
None => {
|
||||
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, aperture) {
|
||||
match crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), p, aperture) {
|
||||
Some(h) => self.add(s, vec![h], true),
|
||||
None => self.corner = Some(p),
|
||||
}
|
||||
@@ -251,6 +254,7 @@ impl StretchM {
|
||||
self.corner = None;
|
||||
let d = s.doc()?;
|
||||
let store = d.space(&s.space());
|
||||
let frozen = s.viewport_frozen();
|
||||
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();
|
||||
@@ -267,9 +271,14 @@ impl StretchM {
|
||||
self.removing = false;
|
||||
None
|
||||
}
|
||||
"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()),
|
||||
"all" => Some(
|
||||
store.map(|st| st.iter().filter(|e| crate::select::selectable(d, e, frozen)).map(|e| e.handle).collect()).unwrap_or_default(),
|
||||
),
|
||||
"l" | "last" => Some(store.and_then(|st| crate::select::last_in(st, frozen)).map(|h| vec![h]).unwrap_or_default()),
|
||||
"p" | "previous" => {
|
||||
let st = s.state()?;
|
||||
Some(st.drawn_in_viewport(&st.previous_selection))
|
||||
}
|
||||
_ => {
|
||||
s.echo("*Invalid selection*");
|
||||
None
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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"])
|
||||
.menu(&["Tools", "Additional 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)
|
||||
|
||||
@@ -280,7 +280,7 @@ fn run_qselect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sel = match mode.as_str() {
|
||||
"exclude" => excluded,
|
||||
"append" => {
|
||||
let mut cur = s.selection();
|
||||
let mut cur = s.stored_selection();
|
||||
for h in matched {
|
||||
if !cur.contains(&h) {
|
||||
cur.push(h);
|
||||
|
||||
@@ -99,14 +99,21 @@ pub(crate) fn similar(e: &Entity, r: &Entity, mode: u32) -> bool {
|
||||
&& (mode & NAME == 0 || same_text(object_name(e), object_name(r)))
|
||||
}
|
||||
|
||||
/// The visible objects of the current space like any of `refs`.
|
||||
/// The objects of the current space like any of `refs` that a selection can take there: visible,
|
||||
/// and inside an MSPACE viewport not on a layer frozen in it.
|
||||
fn similar_objects(s: &Session, d: &Drawing, refs: &[Handle], mode: u32) -> Vec<Handle> {
|
||||
let refs: Vec<&Entity> = refs.iter().filter_map(|h| d.entity(*h).map(|e| e.as_ref())).collect();
|
||||
if refs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(store) = d.space(&s.space()) else { return Vec::new() };
|
||||
store.iter().take(MAX_SCAN).filter(|e| d.is_visible(e) && refs.iter().any(|r| similar(e, r, mode))).map(|e| e.handle).collect()
|
||||
let frozen = s.viewport_frozen();
|
||||
store
|
||||
.iter()
|
||||
.take(MAX_SCAN)
|
||||
.filter(|e| crate::select::selectable(d, e, frozen) && refs.iter().any(|r| similar(e, r, mode)))
|
||||
.map(|e| e.handle)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mode_param(s: &Session, p: &Value) -> Result<u32> {
|
||||
|
||||
@@ -59,7 +59,7 @@ fn fence(s: &mut Session, pts: &[Vec2], extending: bool, edges: Edges, quick: bo
|
||||
let fb = Bounds2::from_points(pts.iter().copied());
|
||||
let tol = crate::select::window_tol(&fb);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_fence(d, &space, pts) {
|
||||
for h in crate::select::select_fence_in(d, &space, s.viewport_frozen(), pts) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let polys = crate::select::hit_polylines(d, e, tol);
|
||||
let mut xs: Vec<Vec2> = Vec::new();
|
||||
@@ -96,7 +96,7 @@ fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, qu
|
||||
];
|
||||
let inside = |p: Vec2| bx.expand(tol).contains(p);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_window(d, &space, bx, true) {
|
||||
for h in crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, true) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let mut best: Option<Vec2> = None;
|
||||
for pl in crate::select::hit_polylines(d, e, tol) {
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
|
||||
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
|
||||
.menu(&["Tools", "Additional Tools", "Convert Text to Mtext"])
|
||||
.params(
|
||||
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
|
||||
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
|
||||
|
||||
@@ -8,6 +8,12 @@ pub struct Snapshot {
|
||||
pub selection: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// [`DocState::drawn_in_viewport`] looks its handles up one by one while there is fewer than one
|
||||
/// of them for every `LOOKUP_COST` objects of model space, and passes over model space once
|
||||
/// otherwise. Timed on drawings of 40,000 and of 200,000 lines, the two cost the same at about
|
||||
/// one handle for every 47 and for every 18 objects.
|
||||
const LOOKUP_COST: usize = 32;
|
||||
|
||||
/// One open drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
@@ -119,13 +125,65 @@ impl DocState {
|
||||
}
|
||||
/// The active MSPACE viewport, if it still exists in the current layout.
|
||||
pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> {
|
||||
self.active_viewport_ref().map(|(h, v)| (h, v.clone()))
|
||||
}
|
||||
/// [`DocState::active_viewport`] without copying the viewport.
|
||||
fn active_viewport_ref(&self) -> Option<(Handle, &cadcraft_doc::Viewport)> {
|
||||
let h = self.mspace?;
|
||||
let e = self.doc.space(&self.space)?.get(h)?;
|
||||
match &e.kind {
|
||||
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())),
|
||||
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The layers frozen in the active MSPACE viewport (VPLAYER, "VP Freeze"): that viewport does
|
||||
/// not draw their objects, so picks, selections and grips made through it leave them out.
|
||||
/// Empty in paper space and on the Model tab.
|
||||
pub fn viewport_frozen(&self) -> &[String] {
|
||||
match self.active_viewport_ref() {
|
||||
Some((_, v)) => &v.frozen_layers,
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
/// Whether `e` is a model-space object the active MSPACE viewport does not draw: it is on a
|
||||
/// layer frozen in that viewport ([`DocState::viewport_frozen`]). Objects on the sheet are not
|
||||
/// drawn through the viewport, so its frozen layers do not hide them.
|
||||
pub fn hidden_in_viewport(&self, e: &cadcraft_doc::Entity) -> bool {
|
||||
crate::select::on_frozen_layer(self.viewport_frozen(), e) && self.doc.model.contains(e.handle)
|
||||
}
|
||||
/// Whether the view being edited shows `e`: the drawing shows it ([`Drawing::is_visible`]),
|
||||
/// and the active MSPACE viewport, if there is one, draws it
|
||||
/// ([`DocState::hidden_in_viewport`]).
|
||||
pub fn shows(&self, e: &cadcraft_doc::Entity) -> bool {
|
||||
self.doc.is_visible(e) && !self.hidden_in_viewport(e)
|
||||
}
|
||||
/// `handles` without the objects the active MSPACE viewport does not draw
|
||||
/// ([`DocState::hidden_in_viewport`]): a set of objects made earlier (the selection, the
|
||||
/// Previous set) as that viewport takes it. All of them anywhere else.
|
||||
pub(crate) fn drawn_in_viewport(&self, handles: &[Handle]) -> Vec<Handle> {
|
||||
let frozen = self.viewport_frozen();
|
||||
if frozen.is_empty() || handles.is_empty() {
|
||||
return handles.to_vec();
|
||||
}
|
||||
let model = &self.doc.model;
|
||||
let hidden = |e: &Arc<cadcraft_doc::Entity>| crate::select::on_frozen_layer(frozen, e);
|
||||
// The UI asks for the selection several times a frame. A few handles are looked up one
|
||||
// by one; beyond that, one pass over model space costs less than the lookups.
|
||||
if handles.len().saturating_mul(LOOKUP_COST) < model.len() {
|
||||
return handles.iter().copied().filter(|h| !model.get(*h).is_some_and(hidden)).collect();
|
||||
}
|
||||
let mut gone: Vec<Handle> = model.iter().filter(|e| hidden(e)).map(|e| e.handle).collect();
|
||||
gone.sort_unstable();
|
||||
handles.iter().copied().filter(|h| gone.binary_search(h).is_err()).collect()
|
||||
}
|
||||
/// The selection as the view being edited has it: [`DocState::selection`] without the objects
|
||||
/// the active MSPACE viewport does not draw. Those stay in [`DocState::selection`] until the
|
||||
/// selection is replaced or cleared, and count as selected again once that viewport draws
|
||||
/// them; until then nothing that starts from the selection (a command, a grip, a palette)
|
||||
/// edits them through that viewport.
|
||||
pub fn shown_selection(&self) -> Vec<Handle> {
|
||||
self.drawn_in_viewport(&self.selection)
|
||||
}
|
||||
pub fn paper_view(&self) -> View {
|
||||
self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Words typed on a Greek keyboard layout.
|
||||
//!
|
||||
//! With the Greek layout active, the keys that type LINE produce ΛΙΝΕ. Command names, aliases and
|
||||
//! prompt keywords are Latin, so a Greek speaker would have to switch layouts for every command.
|
||||
//! [`latin_keys`] reads a word as the Latin keys that produced it (the layout puts each Greek
|
||||
//! letter on the key of the Latin letter it sounds like): the command line then accepts ΛΙΝΕ and
|
||||
//! ΣΤΡΕΤΨΗ as LINE and STRETCH. Accents (the layout's dead key) are ignored.
|
||||
|
||||
/// The Latin key a Greek letter is typed on (lower case, accents removed).
|
||||
fn key_of(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'α' | 'ά' => 'a',
|
||||
'β' => 'b',
|
||||
'γ' => 'g',
|
||||
'δ' => 'd',
|
||||
'ε' | 'έ' => 'e',
|
||||
'ζ' => 'z',
|
||||
'η' | 'ή' => 'h',
|
||||
'θ' => 'u',
|
||||
'ι' | 'ί' | 'ϊ' | 'ΐ' => 'i',
|
||||
'κ' => 'k',
|
||||
'λ' => 'l',
|
||||
'μ' => 'm',
|
||||
'ν' => 'n',
|
||||
'ξ' => 'j',
|
||||
'ο' | 'ό' => 'o',
|
||||
'π' => 'p',
|
||||
'ρ' => 'r',
|
||||
'σ' => 's',
|
||||
// The W key types the final sigma.
|
||||
'ς' => 'w',
|
||||
'τ' => 't',
|
||||
'υ' | 'ύ' | 'ϋ' | 'ΰ' => 'y',
|
||||
'φ' => 'f',
|
||||
'χ' => 'x',
|
||||
'ψ' => 'c',
|
||||
'ω' | 'ώ' => 'v',
|
||||
// The Q key types the semicolon, which Greek text writes as ";" or U+037E.
|
||||
';' | '\u{37e}' => 'q',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `c` is a Greek letter (the basic and accented ranges the layout types).
|
||||
fn is_greek_letter(c: char) -> bool {
|
||||
matches!(c, '\u{386}'..='\u{3ce}') && c != '\u{387}'
|
||||
}
|
||||
|
||||
/// `word` read as the Latin keys of a Greek-layout keyboard, lower case: `Some` only when the word
|
||||
/// has a Greek letter and every character is a Greek letter the layout types, the semicolon on
|
||||
/// the Q key, or ASCII (itself, in lower case). A combining tonos or dialytika, the accent of
|
||||
/// decomposed text, is ignored like the accent of a precomposed letter, but only straight after
|
||||
/// the Greek letter it accents and once each: anywhere else the word is not Greek-layout typing.
|
||||
pub fn latin_keys(word: &str) -> Option<String> {
|
||||
let mut greek = false;
|
||||
// What the previous Greek letter already carries (tonos, dialytika); `None` when the previous
|
||||
// character is not a Greek letter, which leaves a combining mark nothing to accent.
|
||||
let mut marks: Option<(bool, bool)> = None;
|
||||
let mut out = String::with_capacity(word.len());
|
||||
for c in word.chars() {
|
||||
if matches!(c, '\u{301}' | '\u{308}') {
|
||||
let (tonos, dialytika) = marks.as_mut()?;
|
||||
if std::mem::replace(if c == '\u{301}' { tonos } else { dialytika }, true) {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c.is_ascii() {
|
||||
// The Q key types the ASCII semicolon in the Greek layout; every other ASCII character is itself.
|
||||
out.push(if c == ';' { 'q' } else { c.to_ascii_lowercase() });
|
||||
marks = None;
|
||||
continue;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
out.push(key_of(lower)?);
|
||||
let letter = is_greek_letter(lower);
|
||||
greek |= letter;
|
||||
marks = letter.then_some((false, false));
|
||||
}
|
||||
greek.then_some(out)
|
||||
}
|
||||
|
||||
/// `word` with its case, accents and final sigma folded away, when it is Greek-layout typing: every
|
||||
/// spelling of one Greek word gives the same string. It is the word's keys, with the final sigma
|
||||
/// read as the letter sigma instead of the W key.
|
||||
pub fn greek_fold(word: &str) -> Option<String> {
|
||||
latin_keys(&word.replace('ς', "σ"))
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are one Greek word, whatever their case, accents or final sigma.
|
||||
pub fn same_greek_word(a: &str, b: &str) -> bool {
|
||||
same_greek_word_as(b).is_some_and(|same| same(a))
|
||||
}
|
||||
|
||||
/// A test for "is the Greek word `word`, whatever the case, accents or final sigma", or `None`
|
||||
/// when `word` is not Greek-layout typing. `word` is folded here, once: looking a typed word up
|
||||
/// among many names costs its length once, not once for every name.
|
||||
pub fn same_greek_word_as(word: &str) -> Option<impl Fn(&str) -> bool> {
|
||||
let fold = greek_fold(word)?;
|
||||
Some(move |name: &str| greek_fold(name).as_deref() == Some(fold.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{latin_keys, same_greek_word};
|
||||
|
||||
/// One Greek word compares equal in any case, accent or final sigma; another word, or a Latin
|
||||
/// one, does not.
|
||||
#[test]
|
||||
fn one_greek_word_is_the_same_in_any_case_accent_or_final_sigma() {
|
||||
for (a, b) in [("ΣΟΛΙΔ", "σολιδ"), ("ΣΟΛΙΔ", "σολίδ"), ("ΛΑΣ", "λας"), ("ΛΑΣ", "λασ"), ("ΛΙ\u{301}ΝΕ", "λινε")]
|
||||
{
|
||||
assert!(same_greek_word(a, b), "{a} {b}");
|
||||
}
|
||||
for (a, b) in [("ΣΟΛΙΔ", "ΣΟΛΙ"), ("ΣΟΛΙΔ", "solid"), ("solid", "SOLID")] {
|
||||
assert!(!same_greek_word(a, b), "{a} {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A character the layout does not type is never taken for the Greek letter Unicode upper-cases
|
||||
/// it to: the micro sign is not μ, the long s and the sharp s are not σ, an alpha with an iota
|
||||
/// subscript is not αι, the theta symbol is not θ.
|
||||
#[test]
|
||||
fn a_character_the_layout_does_not_type_is_no_spelling_of_a_greek_letter() {
|
||||
let pairs = [("\u{b5}", "μ"), ("Λ\u{17f}", "ΛΣ"), ("Λ\u{df}", "ΛΣΣ"), ("\u{1fb3}", "αι"), ("\u{3d1}", "θ")];
|
||||
let same: Vec<_> = pairs.iter().filter(|(a, b)| same_greek_word(a, b) || same_greek_word(b, a)).collect();
|
||||
assert!(same.is_empty(), "{same:?}");
|
||||
}
|
||||
|
||||
/// Greek-layout typing reads as its Latin keys: plain, accented and decomposed letters, the Q
|
||||
/// and W keys, ASCII prefixes.
|
||||
#[test]
|
||||
fn greek_layout_commands_read_as_their_latin_keys() {
|
||||
assert_eq!(latin_keys("ΛΙΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("λινε").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΣΤΡΕΤΨΗ").as_deref(), Some("stretch"));
|
||||
assert_eq!(latin_keys("ΜΙΡΡΟΡ").as_deref(), Some("mirror"));
|
||||
assert_eq!(latin_keys("ΘΝΔΟ").as_deref(), Some("undo"));
|
||||
// The Q key types ";", the W key the final sigma.
|
||||
assert_eq!(latin_keys(";ΣΑΩΕ").as_deref(), Some("qsave"));
|
||||
assert_eq!(latin_keys("ςΒΛΟΨΚ").as_deref(), Some("wblock"));
|
||||
// Accented letters (the layout's dead key) read as the plain letter.
|
||||
assert_eq!(latin_keys("ΛΊΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΆΡΡΑΥ").as_deref(), Some("array"));
|
||||
// The same accents written as a combining mark after the letter (decomposed text).
|
||||
assert_eq!(latin_keys("ΛΙ\u{301}ΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙΝΕ\u{301}").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙ\u{308}ΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙ\u{308}\u{301}ΝΕ").as_deref(), Some("line"), "dialytika and tonos on one letter");
|
||||
// Prefixes and digits are kept.
|
||||
assert_eq!(latin_keys("-ΛΑΥΕΡ").as_deref(), Some("-layer"));
|
||||
assert_eq!(latin_keys("_ΛΙΝΕ").as_deref(), Some("_line"));
|
||||
}
|
||||
|
||||
/// A word with no Greek letter, or with a character the layout does not type, is not read.
|
||||
#[test]
|
||||
fn words_that_are_not_greek_layout_typing_are_left_alone() {
|
||||
assert_eq!(latin_keys("line"), None, "no Greek letter");
|
||||
assert_eq!(latin_keys(""), None);
|
||||
assert_eq!(latin_keys(";"), None, "a semicolon alone is not a word");
|
||||
assert_eq!(latin_keys("ΛΙΝΕ Ж"), None, "a letter the layout does not type");
|
||||
assert_eq!(latin_keys("ΛΙΝΕ 日本"), None);
|
||||
assert_eq!(latin_keys("\u{387}"), None, "the ano teleia is punctuation, not a letter");
|
||||
}
|
||||
|
||||
/// A combining tonos or dialytika counts only straight after a Greek letter, and once each.
|
||||
#[test]
|
||||
fn a_combining_mark_must_follow_the_greek_letter_it_accents() {
|
||||
assert_eq!(latin_keys("\u{301}ΛΙΝΕ"), None, "no letter before the mark");
|
||||
assert_eq!(latin_keys("\u{308}ΛΙΝΕ"), None);
|
||||
assert_eq!(latin_keys("ΛΙΝΕ-\u{301}"), None, "after punctuation");
|
||||
assert_eq!(latin_keys("a\u{301}ΛΙΝΕ"), None, "after a Latin letter");
|
||||
assert_eq!(latin_keys("Λ\u{301}\u{301}ΙΝΕ"), None, "a second tonos on one letter");
|
||||
assert_eq!(latin_keys("Λ\u{308}\u{308}ΙΝΕ"), None, "a second dialytika on one letter");
|
||||
}
|
||||
|
||||
/// A 100 000-letter word and odd code points are read or refused; nothing panics.
|
||||
#[test]
|
||||
fn hostile_text_never_panics() {
|
||||
let long = "Ω".repeat(100_000);
|
||||
assert_eq!(latin_keys(&long).map(|s| s.len()), Some(100_000));
|
||||
for s in ["\u{0}", "\u{301}", "ΛΙΝΕ\u{301}", "\u{10ffff}", "ς\u{3c2}\u{3a3}"] {
|
||||
let _ = latin_keys(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod finite;
|
||||
pub mod grip_dims;
|
||||
pub mod grips;
|
||||
mod guard;
|
||||
pub mod keyboard;
|
||||
pub mod lisp;
|
||||
pub mod mtext_edit;
|
||||
pub mod pointmod;
|
||||
@@ -236,11 +237,25 @@ impl Session {
|
||||
pub fn space(&self) -> Space {
|
||||
self.state().map(DocState::edit_space).unwrap_or_default()
|
||||
}
|
||||
/// The layers frozen in the active MSPACE viewport ([`DocState::viewport_frozen`]): what a
|
||||
/// pick or a selection in [`Session::space`] leaves out. Empty with no drawing open.
|
||||
pub fn viewport_frozen(&self) -> &[String] {
|
||||
self.state().map(DocState::viewport_frozen).unwrap_or_default()
|
||||
}
|
||||
/// The tab being displayed: Model or a layout, regardless of MSPACE.
|
||||
pub fn layout_space(&self) -> Space {
|
||||
self.state().map(|s| s.space.clone()).unwrap_or_default()
|
||||
}
|
||||
/// The selected objects as the view being edited has them ([`DocState::shown_selection`]):
|
||||
/// inside an MSPACE viewport, without the objects on layers frozen in that viewport. Commands,
|
||||
/// grips and palettes start from this; [`DocState::selection`] holds every selected object.
|
||||
pub fn selection(&self) -> Vec<Handle> {
|
||||
self.state().map(DocState::shown_selection).unwrap_or_default()
|
||||
}
|
||||
/// Every selected object ([`DocState::selection`]), also the ones the active MSPACE viewport
|
||||
/// does not draw, which [`Session::selection`] leaves out. Adding to the selection or removing
|
||||
/// from it starts from this, so that it keeps them.
|
||||
pub(crate) fn stored_selection(&self) -> Vec<Handle> {
|
||||
self.state().map(|s| s.selection.clone()).unwrap_or_default()
|
||||
}
|
||||
pub fn set_selection(&mut self, sel: Vec<Handle>) {
|
||||
|
||||
@@ -651,15 +651,19 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
Some("X") => all(s, true),
|
||||
Some("L") => {
|
||||
let order = handles_in_order(ctx);
|
||||
order.last().copied().into_iter().collect()
|
||||
// Inside an MSPACE viewport, the objects that viewport does not draw are passed over.
|
||||
let st = ctx.s.state().ok();
|
||||
let drawn = |h: &Handle| st.is_none_or(|st| st.doc.entity(*h).is_none_or(|e| !st.hidden_in_viewport(e)));
|
||||
order.iter().rev().copied().find(drawn).into_iter().collect()
|
||||
}
|
||||
Some("P") => ctx.s.state().map(|st| st.previous_selection.clone()).unwrap_or_default(),
|
||||
Some("P") => ctx.s.state().map(|st| st.drawn_in_viewport(&st.previous_selection)).unwrap_or_default(),
|
||||
Some("I") => ctx.s.selection(),
|
||||
Some("W") | Some("C") => {
|
||||
let (p1, p2) = (point(0)?, point(1)?);
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
crate::select::select_window(d, &space, cadcraft_geom::Bounds2::from_points([p1, p2]), mode.as_deref() == Some("C"))
|
||||
let bx = cadcraft_geom::Bounds2::from_points([p1, p2]);
|
||||
crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, mode.as_deref() == Some("C"))
|
||||
}
|
||||
Some("F") | Some("WP") | Some("CP") => {
|
||||
let poly: Vec<Vec2> =
|
||||
@@ -667,8 +671,8 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
match mode.as_deref() {
|
||||
Some("F") => crate::select::select_fence(d, &space, &poly),
|
||||
m => crate::select::select_polygon(d, &space, &poly, m == Some("CP")),
|
||||
Some("F") => crate::select::select_fence_in(d, &space, s.viewport_frozen(), &poly),
|
||||
m => crate::select::select_polygon_in(d, &space, s.viewport_frozen(), &poly, m == Some("CP")),
|
||||
}
|
||||
}
|
||||
Some(":S") | Some("A") | None => {
|
||||
@@ -684,7 +688,7 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let s = &*ctx.s;
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
crate::select::pick(d, &space, p, ap).into_iter().collect()
|
||||
crate::select::pick_in(d, &space, s.viewport_frozen(), p, ap).into_iter().collect()
|
||||
}
|
||||
}
|
||||
Some(m) => return Err(LispError::new(format!("bad argument value: ssget mode {m}"))),
|
||||
|
||||
@@ -141,7 +141,7 @@ impl GetRequest {
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let space = s.state().map(|st| st.edit_space()).map_err(|e| Again::Msg(e.to_string()))?;
|
||||
let d = s.doc().map_err(|e| Again::Msg(e.to_string()))?;
|
||||
match crate::select::pick(d, &space, Vec2::new(p[0], p[1]), ap) {
|
||||
match crate::select::pick_in(d, &space, s.viewport_frozen(), Vec2::new(p[0], p[1]), ap) {
|
||||
Some(h) => Ok(Value::list(vec![Value::Ename(h), Value::point(&p)])),
|
||||
None => Err("Missed, try again.".into()),
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ pub struct Prompt {
|
||||
/// the routine writes, so it cannot be read for the word "Select" (#1598).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub picks_one: bool,
|
||||
/// The prompt takes a block's name as well as its keywords: a Greek word that is an existing
|
||||
/// block's name is that block, not a keyword read from its Latin keys.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub takes_block: bool,
|
||||
}
|
||||
|
||||
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||
@@ -136,6 +140,11 @@ impl Prompt {
|
||||
self.picker = Some(p);
|
||||
self
|
||||
}
|
||||
/// Takes a block's name besides its keywords (the `takes_block` field).
|
||||
pub fn takes_block(mut self) -> Self {
|
||||
self.takes_block = true;
|
||||
self
|
||||
}
|
||||
pub fn kw(mut self, k: &[&str]) -> Self {
|
||||
self.keywords = k.iter().map(ToString::to_string).collect();
|
||||
self
|
||||
@@ -215,6 +224,24 @@ impl Prompt {
|
||||
return None;
|
||||
}
|
||||
let tl = t.to_ascii_lowercase();
|
||||
// A keyword typed with the Greek keyboard layout active (ΘΝΔΟ for Undo) is its Latin keys, unless
|
||||
// the prompt offers that spelling itself (a Greek keyword in a LISP program's list): its word
|
||||
// or a prefix of it as typed (ΘΝ for ΘΝΔΟ, not UN for Undo) is read before the Latin keys.
|
||||
if let Some(latin) = crate::keyboard::latin_keys(t) {
|
||||
// Greek letters have case, accents and a final sigma, which the ASCII-only folding above does not see.
|
||||
let typed = crate::keyboard::greek_fold(t).unwrap_or_else(|| latin.clone());
|
||||
let greek = || self.keywords.iter().filter_map(|k| crate::keyboard::greek_fold(k).map(|fold| (k, fold)));
|
||||
return greek()
|
||||
.find(|(_, k)| *k == typed)
|
||||
.or_else(|| greek().find(|(_, k)| k.starts_with(&typed)))
|
||||
.map(|(k, _)| k.clone())
|
||||
.or_else(|| self.match_latin_keyword(&latin));
|
||||
}
|
||||
self.match_latin_keyword(&tl)
|
||||
}
|
||||
|
||||
/// [`Prompt::match_keyword`] for text already read as Latin letters, in lower case.
|
||||
fn match_latin_keyword(&self, tl: &str) -> Option<String> {
|
||||
let keys = self.keywords.iter().map(|k| {
|
||||
let shortcut: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect::<String>().to_ascii_lowercase();
|
||||
let shortcut =
|
||||
@@ -227,8 +254,8 @@ impl Prompt {
|
||||
// CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale.
|
||||
let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1);
|
||||
keys.clone()
|
||||
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl)))
|
||||
.find(|(_, kl, sc)| tl == kl.as_str() || tl == sc.as_str())
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(tl)))
|
||||
.map(|(k, _, _)| k.clone())
|
||||
}
|
||||
}
|
||||
@@ -394,6 +421,29 @@ mod tests {
|
||||
assert_eq!(parse_point("1,2,3,4", last), None);
|
||||
}
|
||||
|
||||
/// A keyword the prompt offers in Greek is matched as spelled (any case, accent or final sigma,
|
||||
/// or a prefix) before the Latin reading of the same keys; without one, the keys answer the
|
||||
/// Latin keyword.
|
||||
#[test]
|
||||
fn a_greek_keyword_is_matched_as_spelled_before_its_latin_reading() {
|
||||
// ΘΝΔΟ is the keys U-N-D-O, so it answers Undo, unless the prompt offers the Greek word itself
|
||||
// (a LISP program's keyword list).
|
||||
let both = Prompt::new("", Accept::TEXT).kw(&["Undo", "ΘΝΔΟ"]);
|
||||
assert_eq!(both.match_keyword("ΘΝΔΟ").as_deref(), Some("ΘΝΔΟ"));
|
||||
assert_eq!(both.match_keyword("θνδο").as_deref(), Some("ΘΝΔΟ"), "Greek case is folded like Latin case");
|
||||
assert_eq!(both.match_keyword("Undo").as_deref(), Some("Undo"));
|
||||
// A prefix of the Greek word as typed (ΘΝ) is that keyword, not the Latin prefix UN of Undo.
|
||||
assert_eq!(both.match_keyword("ΘΝ").as_deref(), Some("ΘΝΔΟ"));
|
||||
assert_eq!(both.match_keyword("θν").as_deref(), Some("ΘΝΔΟ"), "in either case");
|
||||
assert_eq!(both.match_keyword("θνδό").as_deref(), Some("ΘΝΔΟ"), "with an accent");
|
||||
let sigma = Prompt::new("", Accept::TEXT).kw(&["Last", "ΛΑΣΤ"]);
|
||||
assert_eq!(sigma.match_keyword("λας").as_deref(), Some("ΛΑΣΤ"), "a final sigma folds too");
|
||||
let latin = Prompt::new("", Accept::TEXT).kw(&["Undo"]);
|
||||
assert_eq!(latin.match_keyword("ΘΝ").as_deref(), Some("Undo"));
|
||||
assert_eq!(latin.match_keyword("ΘΝΔΟ").as_deref(), Some("Undo"));
|
||||
assert_eq!(latin.match_keyword("Θ").as_deref(), Some("Undo"), "a Greek-layout shortcut still works");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords() {
|
||||
let p = Prompt::new("Specify next point", Accept::POINT).kw(&["Undo", "Close"]);
|
||||
|
||||
+101
-21
@@ -152,8 +152,28 @@ fn in_box(pts: impl Iterator<Item = Vec2>, angle: f64, p: Vec2) -> bool {
|
||||
!b.is_empty() && b.contains(turn(p))
|
||||
}
|
||||
|
||||
fn selectable(d: &Drawing, e: &Entity) -> bool {
|
||||
d.is_visible(e)
|
||||
/// Whether `e` is on one of the layers in `frozen`: the layers frozen in a layout viewport, which
|
||||
/// does not draw their objects. The names are compared as the renderer compares them.
|
||||
pub(crate) fn on_frozen_layer(frozen: &[String], e: &Entity) -> bool {
|
||||
frozen.iter().any(|l| l.eq_ignore_ascii_case(&e.common.layer))
|
||||
}
|
||||
|
||||
/// The last object of `store`, in draw order, that is not on one of the layers in `frozen`, the
|
||||
/// layers frozen in the layout viewport the selection is made through: what the Last option
|
||||
/// takes there. With no such layers it is the last object, whether the drawing shows it or not.
|
||||
pub(crate) fn last_in(store: &cadcraft_doc::EntityStore, frozen: &[String]) -> Option<Handle> {
|
||||
if frozen.is_empty() {
|
||||
return store.last().map(|e| e.handle);
|
||||
}
|
||||
store.iter().filter(|e| !on_frozen_layer(frozen, e)).last().map(|e| e.handle)
|
||||
}
|
||||
|
||||
/// Whether a selection can take `e`: the drawing shows it ([`Drawing::is_visible`]) and it is not
|
||||
/// on one of the layers in `frozen`, the layers frozen in the layout viewport the selection is
|
||||
/// made through ([`crate::DocState::viewport_frozen`]; empty anywhere else). A viewport shows
|
||||
/// model space, so `frozen` goes with a selection in model space only.
|
||||
pub(crate) fn selectable(d: &Drawing, e: &Entity, frozen: &[String]) -> bool {
|
||||
d.is_visible(e) && !on_frozen_layer(frozen, e)
|
||||
}
|
||||
|
||||
/// Entities to test, in the order to test them, each with its bounds when already known.
|
||||
@@ -196,15 +216,28 @@ pub(crate) fn candidates<'a>(
|
||||
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
|
||||
/// picked, and DRAWORDER changes which).
|
||||
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
pick_with(d, space, &ix, p, aperture)
|
||||
pick_in(d, space, &[], p, aperture)
|
||||
}
|
||||
|
||||
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
/// [`pick`] through a layout viewport: the objects on the layers in `frozen`, the layers frozen
|
||||
/// in that viewport, are not drawn there and are not picked.
|
||||
pub fn pick_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
pick_with(d, space, &ix, frozen, p, aperture)
|
||||
}
|
||||
|
||||
pub(crate) fn pick_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
p: Vec2,
|
||||
aperture: f64,
|
||||
) -> Option<Handle> {
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
// Candidates come topmost first: the first within the aperture is the one.
|
||||
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
|
||||
if !selectable(d, e) {
|
||||
if !selectable(d, e, frozen) {
|
||||
continue;
|
||||
}
|
||||
let infinite = is_infinite(e);
|
||||
@@ -221,11 +254,16 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
|
||||
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
|
||||
/// the objects selection cycling lists.
|
||||
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
pick_all_in(d, space, &[], p, aperture)
|
||||
}
|
||||
|
||||
/// [`pick_all`] through a layout viewport that freezes the layers in `frozen` ([`pick_in`]).
|
||||
pub fn pick_all_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
|
||||
cands
|
||||
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.filter(|(e, known)| selectable(d, e, frozen) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
|
||||
.map(|(e, _)| e.handle)
|
||||
.collect()
|
||||
@@ -253,14 +291,27 @@ fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
|
||||
|
||||
/// Window selection (entirely inside) or crossing selection (inside or touching).
|
||||
pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_window_with(d, space, &ix, bx, crossing)
|
||||
select_window_in(d, space, &[], bx, crossing)
|
||||
}
|
||||
|
||||
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
/// [`select_window`] through a layout viewport: the objects on the layers in `frozen`, the layers
|
||||
/// frozen in that viewport, are not drawn there and are not selected.
|
||||
pub fn select_window_in(d: &Drawing, space: &Space, frozen: &[String], bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_window_with(d, space, &ix, frozen, bx, crossing)
|
||||
}
|
||||
|
||||
pub(crate) fn select_window_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
bx: Bounds2,
|
||||
crossing: bool,
|
||||
) -> Vec<Handle> {
|
||||
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
|
||||
let tol = window_tol(&bx);
|
||||
cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
|
||||
cands.filter(|(e, known)| selectable(d, e, frozen) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The tessellation tolerance window and crossing tests use for a box.
|
||||
@@ -306,11 +357,23 @@ pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option<Bounds2>, bx: &Bo
|
||||
|
||||
/// Fence selection: entities crossed by the fence polyline.
|
||||
pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_fence_with(d, space, &ix, fence)
|
||||
select_fence_in(d, space, &[], fence)
|
||||
}
|
||||
|
||||
pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, fence: &[Vec2]) -> Vec<Handle> {
|
||||
/// [`select_fence`] through a layout viewport that freezes the layers in `frozen`
|
||||
/// ([`select_window_in`]).
|
||||
pub fn select_fence_in(d: &Drawing, space: &Space, frozen: &[String], fence: &[Vec2]) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_fence_with(d, space, &ix, frozen, fence)
|
||||
}
|
||||
|
||||
pub(crate) fn select_fence_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
fence: &[Vec2],
|
||||
) -> Vec<Handle> {
|
||||
let fb = Bounds2::from_points(fence.iter().copied());
|
||||
let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0;
|
||||
let probe = fb.expand(tol);
|
||||
@@ -318,7 +381,7 @@ pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::syn
|
||||
let mut out = Vec::new();
|
||||
for (e, known) in cands {
|
||||
// Infinite objects have no useful bounds; like window selection, test their geometry directly.
|
||||
if !selectable(d, e) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
|
||||
if !selectable(d, e, frozen) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
|
||||
continue;
|
||||
}
|
||||
let polys = hit_polylines(d, e, tol);
|
||||
@@ -339,6 +402,12 @@ pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::syn
|
||||
/// Window polygon selection (entities entirely inside `poly`) or crossing polygon selection
|
||||
/// (inside or crossing its edges). The polygon closes back to its first point.
|
||||
pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool) -> Vec<Handle> {
|
||||
select_polygon_in(d, space, &[], poly, crossing)
|
||||
}
|
||||
|
||||
/// [`select_polygon`] through a layout viewport that freezes the layers in `frozen`
|
||||
/// ([`select_window_in`]).
|
||||
pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[Vec2], crossing: bool) -> Vec<Handle> {
|
||||
let (Some(first), true) = (poly.first(), poly.len() >= 3) else { return Vec::new() };
|
||||
let ix = spatial::index(d, space);
|
||||
let pb = Bounds2::from_points(poly.iter().copied());
|
||||
@@ -349,7 +418,7 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
|
||||
let mut out = Vec::new();
|
||||
for (e, known) in cands {
|
||||
let infinite = is_infinite(e);
|
||||
if !selectable(d, e) || (infinite && !crossing) {
|
||||
if !selectable(d, e, frozen) || (infinite && !crossing) {
|
||||
continue;
|
||||
}
|
||||
let eb = bounds_of(d, e, known);
|
||||
@@ -378,9 +447,15 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
|
||||
/// The visible objects in `space` of the group called `name` (any case), or `None` when the
|
||||
/// drawing has no such group.
|
||||
pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle>> {
|
||||
select_group_in(d, space, &[], name)
|
||||
}
|
||||
|
||||
/// [`select_group`] through a layout viewport that freezes the layers in `frozen`: the group's
|
||||
/// objects on those layers are left out.
|
||||
pub fn select_group_in(d: &Drawing, space: &Space, frozen: &[String], name: &str) -> Option<Vec<Handle>> {
|
||||
let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?;
|
||||
let store = d.space(space)?;
|
||||
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect())
|
||||
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| selectable(d, e, frozen))).collect())
|
||||
}
|
||||
|
||||
/// A selection mode typed at "Select objects", collecting its points before it selects.
|
||||
@@ -454,14 +529,19 @@ impl SelectMode {
|
||||
}
|
||||
/// The objects the mode selects, or `None` while it has too few points.
|
||||
pub fn select(&self, d: &Drawing, space: &Space) -> Option<Vec<Handle>> {
|
||||
self.select_in(d, space, &[])
|
||||
}
|
||||
/// [`SelectMode::select`] through a layout viewport: the objects on the layers in `frozen`,
|
||||
/// the layers frozen in that viewport, are not drawn there and are not selected.
|
||||
pub fn select_in(&self, d: &Drawing, space: &Space, frozen: &[String]) -> Option<Vec<Handle>> {
|
||||
match (self.kind, self.pts.as_slice()) {
|
||||
(SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => {
|
||||
Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
|
||||
Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
|
||||
}
|
||||
(SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)),
|
||||
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)),
|
||||
(SelectKind::Box, [a, b, ..]) => Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), b.x < a.x)),
|
||||
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence_in(d, space, frozen, pts)),
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {
|
||||
Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon))
|
||||
Some(select_polygon_in(d, space, frozen, pts, self.kind == SelectKind::CPolygon))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -116,9 +116,26 @@ impl Session {
|
||||
};
|
||||
}
|
||||
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
|
||||
let selection_mode = prompt.accept.select
|
||||
&& matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`). A word typed with
|
||||
// the Greek keyboard layout active is read by its keys there too: Λ is Last at COUNT's prompt,
|
||||
// not List.
|
||||
let selection_mode = prompt.accept.select && {
|
||||
let word = tt.strip_prefix('_').unwrap_or(tt);
|
||||
let keys = crate::keyboard::latin_keys(word).unwrap_or_else(|| word.to_ascii_lowercase());
|
||||
matches!(keys.as_str(), "all" | "l" | "last" | "p" | "previous")
|
||||
};
|
||||
// A Greek word that is an existing name at a prompt that takes one is that name, in any case:
|
||||
// ΣΟΛΙΔ is also the keys of Solid.
|
||||
let existing = if matches!(prompt.picker, Some(crate::Picker::HatchPattern(_))) {
|
||||
crate::keyboard::greek_fold(tt).and_then(|_| cmd::hatch::pattern_named(tt))
|
||||
} else if prompt.takes_block {
|
||||
self.greek_block_name(tt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(name) = existing {
|
||||
return self.input(Input::Text(name));
|
||||
}
|
||||
// A leading underscore asks for a keyword by its English name, whatever the interface
|
||||
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
|
||||
// overrides such as `_cen` were taken above.)
|
||||
|
||||
@@ -8,6 +8,39 @@ use super::{
|
||||
impl Session {
|
||||
// ---------------- interactive commands ----------------
|
||||
|
||||
/// Whether `name`, as typed, is a command, an alias or a LISP command: the lookups
|
||||
/// [`Session::start`] dispatches by, so only ASCII is lower-cased and a Greek alias stays as stored.
|
||||
fn names_a_command(&self, name: &str) -> bool {
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
let dashed = name.trim_start_matches(['_', '.']);
|
||||
find_command(&cmd::resolve_alias(dashed)).is_some()
|
||||
|| find_command(&cmd::resolve_alias(&lower)).is_some()
|
||||
|| self.lisp_command(&lower).is_some()
|
||||
}
|
||||
|
||||
/// The alias or LISP command that is the Greek word `name` in another case or accent (λινε for
|
||||
/// c:ΛΙΝΕ): those lookups fold ASCII only.
|
||||
fn greek_command_name(&self, name: &str) -> Option<String> {
|
||||
let bare = name.trim_start_matches(['_', '.', '-']);
|
||||
let same = crate::keyboard::same_greek_word_as(bare)?;
|
||||
let lisp = || self.lisp_commands().into_iter().find(|c| same(c) && self.lisp_command(c).is_some());
|
||||
cmd::file::greek_alias(bare).or_else(lisp)
|
||||
}
|
||||
|
||||
/// Whether the Greek word `name` is a command of its own, which [`Session::start`] runs instead
|
||||
/// of the Latin name its keys type.
|
||||
pub fn greek_word_is_a_command(&self, name: &str) -> bool {
|
||||
self.names_a_command(name) || self.greek_command_name(name).is_some()
|
||||
}
|
||||
|
||||
/// The existing block that is the Greek word `word`: as spelled, else in another case or accent
|
||||
/// (μ for Μ).
|
||||
pub(super) fn greek_block_name(&self, word: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(word)?;
|
||||
let blocks = &self.doc().ok()?.blocks;
|
||||
blocks.keys().find(|k| *k == word).or_else(|| blocks.keys().find(|k| same(k))).cloned()
|
||||
}
|
||||
|
||||
/// Start a command by name or alias as if typed at the command line.
|
||||
pub fn start(&mut self, name: &str) -> Result<()> {
|
||||
self.last_polar = None;
|
||||
@@ -17,6 +50,12 @@ impl Session {
|
||||
Some(n) => (true, n),
|
||||
None => (false, name),
|
||||
};
|
||||
// A name typed with the Greek keyboard layout active (ΛΙΝΕ for LINE) is the Latin name's keys,
|
||||
// unless it is a command, alias or LISP function of its own, in any case or accent.
|
||||
let typed = name;
|
||||
let latin =
|
||||
crate::keyboard::latin_keys(name).filter(|_| !self.names_a_command(name)).map(|latin| self.greek_command_name(name).unwrap_or(latin));
|
||||
let name = latin.as_deref().unwrap_or(name);
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
|
||||
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
|
||||
@@ -37,7 +76,7 @@ impl Session {
|
||||
self.start(if transparent { "'setvar" } else { "setvar" })?;
|
||||
return self.input(Input::Text(var));
|
||||
}
|
||||
return Err(EngineError::UnknownCommand(name.to_string()));
|
||||
return Err(EngineError::UnknownCommand(typed.to_string()));
|
||||
};
|
||||
if transparent && spec.transparent && self.running.is_some() {
|
||||
// Transparent commands (zoom, pan…) run without cancelling the active one.
|
||||
@@ -175,17 +214,33 @@ impl Session {
|
||||
Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)),
|
||||
other => other,
|
||||
};
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& let Some(m) = select::SelectMode::from_keyword(t)
|
||||
// A selection word typed with the Greek keyboard layout active (ΑΛΛ for ALL) is looked up as its
|
||||
// Latin keys. Only the lookup is: text that is no selection word (a block name at BEDIT's prompt,
|
||||
// which also takes a selection) reaches the command as typed. The name of an existing block at
|
||||
// a prompt that takes a name reaches it as stored, in whatever case it was typed: a block
|
||||
// called Μ is that block there, not Multiple.
|
||||
let block = match &input {
|
||||
Input::Text(t) if prompt.accept.text => self.greek_block_name(t.trim()),
|
||||
_ => None,
|
||||
};
|
||||
let is_block = block.is_some();
|
||||
let input = block.map_or(input, Input::Text);
|
||||
let word = match &input {
|
||||
Input::Text(t) => Some(crate::keyboard::latin_keys(t.trim()).filter(|_| !is_block).unwrap_or_else(|| t.clone())),
|
||||
Input::Keyword(k) => Some(k.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(w) = &word
|
||||
&& let Some(m) = select::SelectMode::from_keyword(w)
|
||||
{
|
||||
self.pending_window = None;
|
||||
self.select_mode = Some(m);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
|
||||
if let Some(w) = &word
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(w.trim()))
|
||||
{
|
||||
match t.trim().to_ascii_lowercase().as_str() {
|
||||
match w.trim().to_ascii_lowercase().as_str() {
|
||||
"si" | "single" => {
|
||||
self.select_single = true;
|
||||
return Ok(None);
|
||||
@@ -202,10 +257,10 @@ impl Session {
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
|
||||
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
|
||||
}
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
|
||||
Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))),
|
||||
None => {
|
||||
self.pending_window = Some(PendingWindow { corner: p, during_command: true });
|
||||
@@ -213,14 +268,18 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Text(ref t) | Input::Keyword(ref t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
Input::Text(_) | Input::Keyword(_) => {
|
||||
let tl = word.as_deref().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
let d = self.doc()?;
|
||||
let store = d.space(&space);
|
||||
let frozen = self.viewport_frozen();
|
||||
let picked = match tl.as_str() {
|
||||
"all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()),
|
||||
"l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]),
|
||||
"p" | "previous" => Some(self.state()?.previous_selection.clone()),
|
||||
"all" => store.map(|s| s.iter().filter(|e| select::selectable(d, e, frozen)).map(|e| e.handle).collect()),
|
||||
"l" | "last" => store.and_then(|s| select::last_in(s, frozen)).map(|h| vec![h]),
|
||||
"p" | "previous" => {
|
||||
let st = self.state()?;
|
||||
Some(st.drawn_in_viewport(&st.previous_selection))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match picked {
|
||||
@@ -241,8 +300,9 @@ impl Session {
|
||||
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
|
||||
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
|
||||
if mode.kind == select::SelectKind::Group {
|
||||
let space = self.space();
|
||||
return match input {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group_in(self.doc()?, &space, self.viewport_frozen(), &name) {
|
||||
Some(hs) => Ok(Some(Input::Pick(hs))),
|
||||
None => {
|
||||
self.echo(format!("No group named \"{}\".", name.trim()));
|
||||
@@ -262,14 +322,14 @@ impl Session {
|
||||
match input {
|
||||
Input::Point(p) => {
|
||||
if mode.push(p) {
|
||||
return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));
|
||||
return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick));
|
||||
}
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
mode.pts.pop();
|
||||
}
|
||||
// Enter ends a fence or polygon; with too few points the mode is dropped.
|
||||
Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)),
|
||||
Input::Enter => return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick)),
|
||||
Input::Cancel | Input::Pick(_) => return Ok(Some(input)),
|
||||
_ => self.echo("Invalid point."),
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ impl Session {
|
||||
let pickadd = self.settings.pickadd;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = cmd::groups::expand(self, hs);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
|
||||
if shift && pickadd {
|
||||
sel.retain(|h| !hs.contains(h));
|
||||
} else {
|
||||
@@ -44,7 +44,7 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
|
||||
Some(h) => self.idle_pick(h, shift),
|
||||
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
|
||||
}
|
||||
@@ -56,7 +56,7 @@ impl Session {
|
||||
let pickadd = self.settings.pickadd;
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
@@ -93,7 +93,7 @@ impl Session {
|
||||
return Vec::new();
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
|
||||
self.doc().map(|d| select::pick_all_in(d, &self.space(), self.viewport_frozen(), p, ap)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
|
||||
@@ -155,7 +155,8 @@ impl Session {
|
||||
|
||||
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
|
||||
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
|
||||
/// object under `p` within `aperture`.
|
||||
/// object under `p` within `aperture`. Inside an MSPACE viewport a pick in model space finds
|
||||
/// what that viewport draws: not the objects on the layers frozen in it.
|
||||
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
|
||||
let d = self.doc()?;
|
||||
if let Some((h, q)) = self.pick_hint
|
||||
@@ -163,7 +164,9 @@ impl Session {
|
||||
{
|
||||
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
|
||||
}
|
||||
Ok(select::pick(d, space, p, aperture))
|
||||
// The viewport shows model space only: its frozen layers hide nothing on the sheet.
|
||||
let frozen = if *space == Space::Model { self.viewport_frozen() } else { &[] };
|
||||
Ok(select::pick_in(d, space, frozen, p, aperture))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,8 +424,8 @@ mod tests {
|
||||
for k in 0..400 {
|
||||
let p = Vec2::new(r.r(-5.0, 105.0), r.r(-5.0, 105.0));
|
||||
let ap = [0.05, 0.3, 1.5][k % 3];
|
||||
let a = select::pick_with(&d, &Space::Model, &ix, p, ap);
|
||||
let b = select::pick_with(&d, &Space::Model, &None, p, ap);
|
||||
let a = select::pick_with(&d, &Space::Model, &ix, &[], p, ap);
|
||||
let b = select::pick_with(&d, &Space::Model, &None, &[], p, ap);
|
||||
assert_eq!(a, b, "seed {seed} p {p:?} ap {ap}");
|
||||
hits += usize::from(a.is_some());
|
||||
}
|
||||
@@ -444,12 +444,15 @@ mod tests {
|
||||
let b = a + Vec2::new(r.r(-40.0, 40.0), r.r(-40.0, 40.0));
|
||||
let bx = Bounds2::new(a, b);
|
||||
for crossing in [false, true] {
|
||||
let x = select::select_window_with(&d, &Space::Model, &ix, bx, crossing);
|
||||
let y = select::select_window_with(&d, &Space::Model, &None, bx, crossing);
|
||||
let x = select::select_window_with(&d, &Space::Model, &ix, &[], bx, crossing);
|
||||
let y = select::select_window_with(&d, &Space::Model, &None, &[], bx, crossing);
|
||||
assert_eq!(x, y, "seed {seed} box {bx:?} crossing {crossing}");
|
||||
}
|
||||
let fence: Vec<Vec2> = (0..(2 + k % 3)).map(|_| Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))).collect();
|
||||
assert_eq!(select::select_fence_with(&d, &Space::Model, &ix, &fence), select::select_fence_with(&d, &Space::Model, &None, &fence));
|
||||
assert_eq!(
|
||||
select::select_fence_with(&d, &Space::Model, &ix, &[], &fence),
|
||||
select::select_fence_with(&d, &Space::Model, &None, &[], &fence)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -484,10 +487,13 @@ mod tests {
|
||||
for (p, ap) in
|
||||
[(Vec2::new(f64::NAN, 1.0), 1.0), (Vec2::new(1.0, 1.0), f64::NAN), (Vec2::new(f64::INFINITY, 0.0), 1.0), (Vec2::new(50.0, 50.0), 1e300)]
|
||||
{
|
||||
assert_eq!(select::pick_with(&d, &Space::Model, &ix, p, ap), select::pick_with(&d, &Space::Model, &None, p, ap));
|
||||
assert_eq!(select::pick_with(&d, &Space::Model, &ix, &[], p, ap), select::pick_with(&d, &Space::Model, &None, &[], p, ap));
|
||||
}
|
||||
let bx = Bounds2 { min: Vec2::new(f64::NEG_INFINITY, 0.0), max: Vec2::new(f64::INFINITY, 50.0) };
|
||||
assert_eq!(select::select_window_with(&d, &Space::Model, &ix, bx, true), select::select_window_with(&d, &Space::Model, &None, bx, true));
|
||||
assert_eq!(
|
||||
select::select_window_with(&d, &Space::Model, &ix, &[], bx, true),
|
||||
select::select_window_with(&d, &Space::Model, &None, &[], bx, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1551,3 +1551,13 @@ fn setvar_rejects_non_finite_text_for_real_variables() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_tools_keeps_compatible_command_ids() {
|
||||
assert_eq!(crate::cmd::resolve_alias("-blockreplace"), "blockreplace");
|
||||
for id in ["blockreplace", "burst", "breakline", "mocoro", "txt2mtxt"] {
|
||||
let command = crate::cmd::find_command(id).expect("existing command id");
|
||||
assert!(command.menu.contains(&"Additional Tools"), "{id}: {:?}", command.menu);
|
||||
assert!(!command.menu.contains(&"Express Tools"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! A word typed with the Greek keyboard layout that is an alias of its own (ALIASEDIT) is that
|
||||
//! alias, not the Latin name its keys type. The aliases are process-wide, so this file keeps to
|
||||
//! one test at a time.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// The alias store is process-wide: each test holds this while it changes the aliases.
|
||||
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// A Greek word the user made an alias runs the alias's command, not the command its keys type.
|
||||
#[test]
|
||||
fn a_greek_alias_starts_its_command_not_the_latin_keys_command() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
// ΛΙΝΕ types LINE, but the user made it an alias of CIRCLE: aliases are stored as typed (only
|
||||
// ASCII is lower-cased), and the alias wins.
|
||||
s.execute("aliasedit", &json!({ "set": { "ΛΙΝΕ": "circle" } })).unwrap();
|
||||
let ran = s.script(
|
||||
"ΛΙΝΕ 0,0 5
|
||||
",
|
||||
);
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
ran.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!((circles, lines), (1, 0), "the alias drew the circle, LINE was not started");
|
||||
}
|
||||
|
||||
/// The alias is found when it is typed in lower case or with an accent too.
|
||||
#[test]
|
||||
fn a_greek_alias_starts_its_command_in_any_case_or_accent() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
s.execute("aliasedit", &json!({ "set": { "ΛΙΝΕ": "circle" } })).unwrap();
|
||||
let ran: Vec<_> = ["λινε", "λίνε"].iter().map(|typed| s.script(&format!("{typed} 0,0 5\n"))).collect();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert!(ran.iter().all(Result::is_ok), "{ran:?}");
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!((circles, lines), (2, 0), "the alias drew both circles, LINE was not started");
|
||||
}
|
||||
|
||||
/// 5 000 Greek aliases and a pasted word of a million letters that is none of them: looking it up
|
||||
/// costs the word's length once, not once for every alias.
|
||||
#[test]
|
||||
fn a_long_greek_word_is_folded_once_however_many_greek_aliases_there_are() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
let set: serde_json::Map<String, serde_json::Value> = (0..5_000).map(|i| (format!("ΞΞ{i}"), json!("line"))).collect();
|
||||
s.execute("aliasedit", &json!({ "set": set })).unwrap();
|
||||
let word = "Ω".repeat(1_000_000);
|
||||
let t = std::time::Instant::now();
|
||||
let started = s.start(&word);
|
||||
let took = t.elapsed();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert!(started.is_err(), "no command has those keys");
|
||||
assert!(took.as_secs() < 5, "{took:?}");
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! A command or keyword typed with the Greek keyboard layout active (ΛΙΝΕ for LINE, ΘΝΔΟ for
|
||||
//! Undo) works as its Latin name: the layout types each Greek letter on the Latin key it sounds
|
||||
//! like.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// How many lines the drawing's model space holds.
|
||||
fn lines(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count()
|
||||
}
|
||||
|
||||
/// ΛΙΝΕ draws a line like LINE, in lower case and with accents too; `;ΣΑΩΕ` starts QSAVE; a
|
||||
/// Greek word that is no command's keys is reported as typed.
|
||||
#[test]
|
||||
fn greek_layout_commands_start_like_the_latin_ones() {
|
||||
let mut s = Session::new();
|
||||
s.script("ΛΙΝΕ 0,0 10,0\n\n").unwrap();
|
||||
assert_eq!(lines(&s), 1, "{}", s.prompt_text());
|
||||
// Accents the layout's dead key adds are ignored; lower case works too.
|
||||
s.script("λίνε 0,5 10,5\n\n").unwrap();
|
||||
assert_eq!(lines(&s), 2);
|
||||
// The Q key types a semicolon: QSAVE is ";ΣΑΩΕ", which only starts the command (no file needed
|
||||
// here: it reports, it never fails as an unknown command).
|
||||
let err = s.start(";ΣΑΩΕ").map(|_| String::new()).unwrap_or_else(|e| e.to_string());
|
||||
assert!(!err.contains("unknown command"), "{err}");
|
||||
// A Greek word that is no command's keys is reported as typed, not as the Latin letters read from it.
|
||||
let err = s.start("ΧΥΖ").unwrap_err().to_string();
|
||||
assert!(err.contains("ΧΥΖ") && !err.contains("xyz"), "{err}");
|
||||
}
|
||||
|
||||
/// A LISP command with a Greek name runs as itself, not as the command its keys type.
|
||||
#[test]
|
||||
fn a_greek_lisp_command_runs_as_itself_not_as_the_latin_keys_command() {
|
||||
let mut s = Session::new();
|
||||
// ΛΙΝΕ types LINE, but the routine defines a command of that Greek name: it is the command typed.
|
||||
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
|
||||
s.cmdline("ΛΙΝΕ").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!((circles, lines(&s)), (1, 0), "the routine drew the circle, LINE was not started");
|
||||
}
|
||||
|
||||
/// ΑΛΛ at "Select objects" selects everything, as ALL does.
|
||||
#[test]
|
||||
fn greek_layout_selection_words_select_like_the_latin_ones() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [20, 0], "radius": 5 })).unwrap();
|
||||
// ΑΛΛ is the keys A-L-L: ALL at the "Select objects" prompt.
|
||||
s.script(
|
||||
"ERASE ΑΛΛ
|
||||
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(s.doc().unwrap().model.is_empty(), "ΑΛΛ selected nothing");
|
||||
}
|
||||
|
||||
/// Ν and Υ answer MIRROR's "Erase source objects?" as N and Y do.
|
||||
#[test]
|
||||
fn greek_layout_keywords_match_prompt_options() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
s.script("ΜΙΡΡΟΡ ALL\n\n0,0 10,0\n").unwrap();
|
||||
// ... the last prompt asks whether to delete the sources: N (Greek layout: Ν) keeps them. Text
|
||||
// the prompt does not know keeps them too, so what Ν is read as is checked itself.
|
||||
let no = s.current_prompt().and_then(|p| p.match_keyword("Ν"));
|
||||
assert_eq!(no.as_deref(), Some("No"), "{}", s.prompt_text());
|
||||
s.script("Ν\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!(circles, 2, "the circle and its mirror image");
|
||||
|
||||
// Υ (the Y key) answers Yes, which erases the source: unmatched Greek text would count as No, so
|
||||
// only a recognised keyword gives this result.
|
||||
let mut yes = Session::new();
|
||||
yes.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
yes.script(
|
||||
"ΜΙΡΡΟΡ ALL
|
||||
|
||||
0,0 10,0
|
||||
Υ
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(yes.running.is_none(), "{}", yes.prompt_text());
|
||||
let circles = yes.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!(circles, 1, "Υ answered Yes and the source was erased");
|
||||
}
|
||||
|
||||
/// A Greek block name typed at BEDIT's "block name or select" opens that block: it is not read
|
||||
/// as a selection word.
|
||||
#[test]
|
||||
fn a_greek_block_name_typed_at_a_name_or_select_prompt_reaches_the_command_as_typed() {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "ΠΟΡΤΑ", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
// BEDIT asks for a block name or a selected reference. ΠΟΡΤΑ is also the keys P-O-R-T-A, which a
|
||||
// selection word would be read from, but no selection word is meant here: the name is typed as it is.
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline("ΠΟΡΤΑ").unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some("ΠΟΡΤΑ"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// Μ is the M key, Multiple at a selection prompt; once a block has that name, typing it at
|
||||
/// BEDIT opens the block.
|
||||
#[test]
|
||||
fn a_block_named_like_a_greek_selection_word_opens_at_a_name_or_select_prompt() {
|
||||
// The Greek capital Mu (U+039C) is the M key: Multiple at a selection prompt.
|
||||
let mu = "\u{39c}";
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
// With no block of that name it stays the selection word, which BEDIT's prompt waits through.
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(mu).unwrap();
|
||||
assert!(s.running.is_some(), "Multiple keeps the prompt: {}", s.prompt_text());
|
||||
assert!(s.state().unwrap().block_editor().is_none());
|
||||
s.cancel();
|
||||
// Once a block has that name, typing it opens that block.
|
||||
s.execute("block", &json!({ "name": mu, "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(mu).unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some(mu), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// ΣΟΛΙΔ is the keys of Solid at HATCH's pattern prompt; once a loaded pattern has that name,
|
||||
/// typing it in any case or accent chooses that pattern.
|
||||
#[test]
|
||||
fn a_loaded_pattern_named_like_the_greek_keys_of_solid_is_that_pattern_at_the_pattern_prompt() {
|
||||
// ΣΟΛΙΔ is the keys S-O-L-I-D: Solid at HATCH's pattern prompt, which takes no scale or angle.
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.start("hatch").unwrap();
|
||||
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
|
||||
s.cmdline("ΣΟΛΙΔ").unwrap();
|
||||
assert!(!s.prompt_text().contains("scale"), "Solid: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
// A pattern file that defines a pattern of that name: typing the name chooses that pattern, which asks its scale.
|
||||
s.execute("hatch.patterns", &json!({ "text": "*ΣΟΛΙΔ, test\n0,0,0,0,1\n" })).unwrap();
|
||||
let asked = ["ΣΟΛΙΔ", "σολιδ", "σολίδ"].map(|typed| {
|
||||
s.start("hatch").unwrap();
|
||||
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let asked = s.prompt_text();
|
||||
s.cancel();
|
||||
asked
|
||||
});
|
||||
s.execute("hatch.patterns", &json!({ "clear": true })).unwrap();
|
||||
assert!(asked.iter().all(|a| a.contains("scale")), "the loaded pattern, in any spelling: {asked:?}");
|
||||
}
|
||||
|
||||
/// A LISP command with a Greek name runs when typed in upper case, lower case or with an accent.
|
||||
#[test]
|
||||
fn a_greek_lisp_command_runs_in_any_case_or_accent() {
|
||||
for typed in ["ΛΙΝΕ", "λινε", "λίνε"] {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!((circles, lines(&s)), (1, 0), "{typed}: {}", s.prompt_text());
|
||||
}
|
||||
}
|
||||
|
||||
/// A block called Μ opens at BEDIT when its name is typed in either case.
|
||||
#[test]
|
||||
fn a_block_opens_in_any_case_or_accent_at_a_prompt_that_also_takes_a_selection() {
|
||||
for typed in ["\u{39c}", "\u{3bc}"] {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "\u{39c}", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some("\u{39c}"), "{typed}: {}", s.prompt_text());
|
||||
}
|
||||
}
|
||||
|
||||
/// Σ is the S key, Select at BLOCKREPLACE; once a block has that name, typing it in either case
|
||||
/// names the block.
|
||||
#[test]
|
||||
fn a_block_named_like_the_keys_of_select_is_that_block_at_blockreplace() {
|
||||
let mut s = Session::new();
|
||||
// Σ is the S key: the Select option of BLOCKREPLACE, while no block has that name.
|
||||
s.start("BLOCKREPLACE").unwrap();
|
||||
s.cmdline("Σ").unwrap();
|
||||
assert!(s.prompt_text().contains("Select a block reference"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "Σ", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "Σ", "at": [0, 0] })).unwrap();
|
||||
for typed in ["Σ", "σ"] {
|
||||
s.start("BLOCKREPLACE").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let asked = s.prompt_text();
|
||||
s.cancel();
|
||||
assert!(asked.contains("replacement block for Σ"), "{typed}: {asked}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Λ is the L key. At COUNT's "Select object to count or [List/Spaces]" the Latin L is the selection
|
||||
/// word Last, not the List option: the Greek key is read the same way, and so is the whole word.
|
||||
#[test]
|
||||
fn a_greek_selection_word_wins_over_a_keyword_on_the_same_key() {
|
||||
let answer = |typed: &str| {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap();
|
||||
s.start("COUNT").unwrap();
|
||||
let from = s.log.len();
|
||||
s.cmdline(typed).unwrap();
|
||||
s.log[from..].join("\n")
|
||||
};
|
||||
let last = answer("L");
|
||||
assert!(last.contains("like the selected one"), "L counts the objects like the last one: {last}");
|
||||
assert_ne!(last, answer("List"));
|
||||
for typed in ["Λ", "λ", "ΛΑΣΤ"] {
|
||||
assert_eq!(answer(typed), last, "{typed}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! A hidden object that is back in the selection takes no part in a grip edit: Undo, `sssetfirst`
|
||||
//! or a control call can restore it there, and the grips it shares with a visible object must not
|
||||
//! drag it along.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// Draws a line from `a` to `b` and returns its handle.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
|
||||
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The two ends of the line `h`.
|
||||
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
||||
k => panic!("not a line: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two lines sharing the end (0, 0), the second one hidden, both in the selection.
|
||||
fn shared_corner() -> (Session, Handle, Handle) {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
s.execute("hideobjects", &json!({ "handles": [hidden.hex()] })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(!d.is_visible(d.entity(hidden).unwrap()), "the second line must be hidden");
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
(s, shown, hidden)
|
||||
}
|
||||
|
||||
/// The index of the grip of `h` at `p`.
|
||||
fn grip_at(s: &Session, h: Handle, p: Vec2) -> usize {
|
||||
s.grips_of(h).unwrap().iter().position(|g| g.near(p, 1e-9)).unwrap()
|
||||
}
|
||||
|
||||
/// Stretching the shared corner moves the visible line only; the hidden one stays where it was.
|
||||
#[test]
|
||||
fn a_hidden_selected_line_is_not_stretched_with_a_shared_grip() {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// The Move mode of a grip edit moves the visible selection only.
|
||||
#[test]
|
||||
fn a_hidden_selected_line_is_not_moved_by_the_move_mode_of_a_grip_edit() {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
let i = grip_at(&s, shown, Vec2::new(10.0, 0.0));
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("** MOVE **"), "{}", s.prompt_text());
|
||||
s.cmdline("12,5").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(2.0, 5.0), 1e-9) && end.near(Vec2::new(12.0, 5.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// The grip of a hidden object is never made hot, so a grip edit cannot start on it.
|
||||
#[test]
|
||||
fn the_grip_of_a_hidden_object_cannot_be_made_hot() {
|
||||
let (mut s, _, hidden) = shared_corner();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let err = s.start_grip_edit(&[(hidden, 0)]).unwrap_err().to_string();
|
||||
assert!(err.contains("no grip"), "{err}");
|
||||
assert!(s.running.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
|
||||
}
|
||||
|
||||
/// A hidden object's grip among the hot grips is left out, wherever it stands in the list: the
|
||||
/// first grip the drawing shows is the base grip, and only the visible line is stretched.
|
||||
#[test]
|
||||
fn a_hidden_grip_among_the_hot_grips_is_left_out() {
|
||||
for hidden_first in [false, true] {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
let mut hot = vec![(shown, grip_at(&s, shown, Vec2::new(0.0, 0.0))), (hidden, grip_at(&s, hidden, Vec2::new(0.0, 10.0)))];
|
||||
if hidden_first {
|
||||
hot.reverse();
|
||||
}
|
||||
s.start_grip_edit(&hot).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "hidden first: {hidden_first}: {start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(
|
||||
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
|
||||
"hidden first: {hidden_first}: the hidden line moved: {start:?} {end:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only what the drawing does not show is left out: a grip of an object that does not exist is
|
||||
/// still an error, also after a good one.
|
||||
#[test]
|
||||
fn a_missing_object_among_the_hot_grips_is_still_an_error() {
|
||||
let (mut s, shown, _) = shared_corner();
|
||||
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
|
||||
let err = s.start_grip_edit(&[(shown, i), (Handle(0xFFFF), 0)]).unwrap_err().to_string();
|
||||
assert!(err.contains("no such object"), "{err}");
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
/// A selected line on a layer that is off is not shown either, and is not stretched with a
|
||||
/// shared grip.
|
||||
#[test]
|
||||
fn a_selected_line_on_a_layer_that_is_off_is_not_stretched_with_a_shared_grip() {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("layer.new", &json!({ "name": "Guides", "current": true })).unwrap();
|
||||
let off = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
|
||||
s.execute("layer.set", &json!({ "name": "Guides", "on": false })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(!d.is_visible(d.entity(off).unwrap()), "the second line must be on a layer that is off");
|
||||
s.set_selection(vec![shown, off]);
|
||||
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, off);
|
||||
assert!(
|
||||
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
|
||||
"the line on the layer that is off moved: {start:?} {end:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The typed GRIP command finds no grip where only a hidden object has one, and at the shared
|
||||
/// corner it makes only the visible line's grip hot.
|
||||
#[test]
|
||||
fn the_typed_grip_command_finds_no_grip_of_a_hidden_object() {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
s.cmdline("grip").unwrap();
|
||||
assert!(s.prompt_text().contains("Select grip"), "{}", s.prompt_text());
|
||||
// Only the hidden line has a grip at (0, 10): the command says so and asks again.
|
||||
s.cmdline("0,10").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("No grip of a selected object there")), "{:?}", s.log);
|
||||
s.cmdline("0,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("** STRETCH **"), "{}", s.prompt_text());
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
|
||||
}
|
||||
@@ -52,6 +52,7 @@ mod extend_polyline_arc_end;
|
||||
mod fillet_chamfer;
|
||||
mod fix_holds_grips;
|
||||
mod followups_626;
|
||||
mod greek_keyboard;
|
||||
mod grip_dim_input;
|
||||
mod grip_options;
|
||||
mod grip_selection;
|
||||
@@ -62,6 +63,7 @@ mod hatch_dialog_helpers;
|
||||
mod hatch_numeric_validation;
|
||||
mod hatch_options;
|
||||
mod helix;
|
||||
mod hidden_grip_targets;
|
||||
mod inquiry_keywords;
|
||||
mod inquiry_variables;
|
||||
mod inquiry_z;
|
||||
@@ -165,6 +167,7 @@ mod trim_extend_options;
|
||||
mod undo_count;
|
||||
mod undo_layout_tab;
|
||||
mod viewport_clip;
|
||||
mod viewport_frozen_selection;
|
||||
mod vplayer_prompts;
|
||||
mod vports;
|
||||
mod wipeout_grips;
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
//! Inside an MSPACE viewport an object on a layer frozen in that viewport (VPLAYER, "VP Freeze")
|
||||
//! is not drawn there, so the selections made through that viewport leave it out: picks, windows,
|
||||
//! the typed selection modes, the commands that select for themselves, and, for an object that is
|
||||
//! already selected, its grips and the commands that start from the selection. In a viewport that
|
||||
//! does not freeze the layer, in paper space and on the Model tab everything is as before.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle, Space};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Draws a line from `a` to `b` on the current layer and returns its handle.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
|
||||
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Puts `h` on the layer "Hidden", which [`viewports`] freezes in its first viewport.
|
||||
fn hide(s: &mut Session, h: Handle) {
|
||||
if s.doc().unwrap().layer("Hidden").is_none() {
|
||||
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
}
|
||||
s.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
|
||||
}
|
||||
|
||||
/// Two viewports on Layout1: the first freezes the layer "Hidden", the second freezes nothing.
|
||||
/// The session is left in paper space.
|
||||
fn viewports(s: &mut Session) -> (String, String) {
|
||||
if s.doc().unwrap().layer("Hidden").is_none() {
|
||||
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
}
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let r = s.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let (a, b) = (r["viewports"][0].as_str().unwrap().to_string(), r["viewports"][1].as_str().unwrap().to_string());
|
||||
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Makes `viewport` the active MSPACE viewport.
|
||||
fn enter(s: &mut Session, viewport: &str) {
|
||||
s.execute("mspace", &json!({ "handle": viewport })).unwrap();
|
||||
assert_eq!(s.space(), Space::Model, "inside a viewport the edit space is model space");
|
||||
}
|
||||
|
||||
/// What most tests draw: `shown` on layer 0 and, drawn after it and so on top of it, `hidden` on
|
||||
/// the layer "Hidden", both from (0, 0) to (10, 0); and the two viewports of [`viewports`].
|
||||
struct Sheet {
|
||||
s: Session,
|
||||
shown: Handle,
|
||||
hidden: Handle,
|
||||
/// The viewport that freezes "Hidden".
|
||||
a: String,
|
||||
/// The viewport that freezes nothing.
|
||||
b: String,
|
||||
}
|
||||
|
||||
/// The drawing of [`Sheet`], in paper space.
|
||||
fn sheet() -> Sheet {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
hide(&mut s, hidden);
|
||||
assert_eq!(s.doc().unwrap().entity(shown).unwrap().common.layer, "0");
|
||||
let (a, b) = viewports(&mut s);
|
||||
Sheet { s, shown, hidden, a, b }
|
||||
}
|
||||
|
||||
/// The two ends of the line `h`.
|
||||
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
||||
k => panic!("not a line: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the line `h` still runs from (0, 0) to (10, 0).
|
||||
fn untouched(s: &Session, h: Handle) -> bool {
|
||||
let (a, b) = ends(s, h);
|
||||
a.near(Vec2::new(0.0, 0.0), 1e-9) && b.near(Vec2::new(10.0, 0.0), 1e-9)
|
||||
}
|
||||
|
||||
/// Whether the drawing still has the object `h`.
|
||||
fn exists(s: &Session, h: Handle) -> bool {
|
||||
s.doc().unwrap().entity(h).is_some()
|
||||
}
|
||||
|
||||
/// Every selected object, in handle order: the stored selection, which also holds the objects
|
||||
/// the active viewport does not draw ([`Session::selection`] leaves those out).
|
||||
fn selected(s: &Session) -> Vec<Handle> {
|
||||
let mut v = s.state().unwrap().selection.clone();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Whether the line `h` was erased or no longer runs from (0, 0) to (10, 0).
|
||||
fn edited(s: &Session, h: Handle) -> bool {
|
||||
!exists(s, h) || !untouched(s, h)
|
||||
}
|
||||
|
||||
/// The index of the grip of `h` at the origin.
|
||||
fn origin_grip(s: &Session, h: Handle) -> usize {
|
||||
s.grips_of(h).unwrap().iter().position(|g| g.near(Vec2::new(0.0, 0.0), 1e-9)).unwrap()
|
||||
}
|
||||
|
||||
/// `hs` in handle order.
|
||||
fn sorted(mut hs: Vec<Handle>) -> Vec<Handle> {
|
||||
hs.sort();
|
||||
hs
|
||||
}
|
||||
|
||||
/// Runs `text` as a script and ends whatever prompt it leaves open with Enter.
|
||||
fn run(s: &mut Session, text: &str) {
|
||||
s.script(text).unwrap();
|
||||
for _ in 0..4 {
|
||||
if s.running.is_none() {
|
||||
break;
|
||||
}
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "`{text}` left a prompt open: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// The JSON `select` command replaces the selection with what `how` finds.
|
||||
fn select(s: &mut Session, how: Value) -> Vec<Handle> {
|
||||
s.set_selection(Vec::new());
|
||||
s.execute("select", &how).unwrap();
|
||||
selected(s)
|
||||
}
|
||||
|
||||
/// A pick takes the topmost object the viewport draws: the frozen line lies on top of the other
|
||||
/// one, and a pick at the same point finds the line under it. Where the layer is not frozen the
|
||||
/// pick finds the line on top, as it always did.
|
||||
#[test]
|
||||
fn a_pick_finds_the_topmost_object_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let at = json!({ "at": [5, 0], "aperture": 0.5 });
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(select(&mut s, at.clone()), [shown], "the viewport freezes the layer of the line on top");
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(select(&mut s, at.clone()), [hidden], "this viewport draws both: the one on top is picked");
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
assert_eq!(select(&mut s, at), [hidden], "the Model tab draws both");
|
||||
}
|
||||
|
||||
/// Window, crossing and fence selection through the viewport leave the frozen line out, and take
|
||||
/// both lines through the viewport that draws both.
|
||||
#[test]
|
||||
fn a_window_a_crossing_and_a_fence_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let ways =
|
||||
[json!({ "window": [[-1, -1], [11, 1]] }), json!({ "window": [[4, -1], [6, 1]], "crossing": true }), json!({ "fence": [[5, -1], [5, 1]] })];
|
||||
for how in &ways {
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(select(&mut s, how.clone()), [shown], "{how}");
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(select(&mut s, how.clone()), sorted(vec![shown, hidden]), "{how}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks with no command running: a click on the two lines selects the one the viewport draws,
|
||||
/// and a window dragged around them takes only that one.
|
||||
#[test]
|
||||
fn idle_clicks_and_windows_select_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
s.set_selection(Vec::new());
|
||||
// A click on nothing opens a window; the next click closes it (left to right: a window).
|
||||
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
|
||||
assert!(s.pending_window.is_some(), "the first click must be on nothing");
|
||||
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
|
||||
enter(&mut s, &b);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [hidden], "this viewport draws both: the one on top is picked");
|
||||
s.set_selection(Vec::new());
|
||||
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
|
||||
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// Selection cycling lists the objects under the pick box that the viewport draws.
|
||||
#[test]
|
||||
fn selection_cycling_lists_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [shown]);
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [hidden, shown], "topmost first");
|
||||
}
|
||||
|
||||
/// Every way of answering "Select objects" leaves the frozen line out: ERASE through the
|
||||
/// viewport that freezes its layer erases the other line only, and through the other viewport
|
||||
/// both.
|
||||
#[test]
|
||||
fn the_answers_to_select_objects_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
|
||||
// Group selection off: a pick takes the object alone, so each answer is tested by itself.
|
||||
s.settings.pickstyle = 0;
|
||||
let answers = [
|
||||
"5,0",
|
||||
"-1,-1 11,1",
|
||||
"ALL",
|
||||
"W -1,-1 11,1",
|
||||
"C 6,1 4,-1",
|
||||
"BOX 6,1 4,-1",
|
||||
"F 5,-1 5,1\n",
|
||||
"WP -1,-1 11,-1 11,1 -1,1\n",
|
||||
"CP 4,-1 6,-1 5,1\n",
|
||||
"G PAIR",
|
||||
];
|
||||
for answer in answers {
|
||||
enter(&mut s, &a);
|
||||
run(&mut s, &format!("ERASE {answer}\n"));
|
||||
assert!(!exists(&s, shown), "`{answer}` must erase the line the viewport draws");
|
||||
assert!(exists(&s, hidden), "`{answer}` erased a line the viewport does not draw");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
|
||||
enter(&mut s, &b);
|
||||
run(&mut s, &format!("ERASE {answer}\n"));
|
||||
// A pick takes one object, the one on top; every other answer takes both.
|
||||
assert_eq!((exists(&s, shown), exists(&s, hidden)), (answer == "5,0", false), "`{answer}` where both are drawn");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
}
|
||||
}
|
||||
|
||||
/// With group selection on, a pick brings the members of its group that the viewport draws.
|
||||
#[test]
|
||||
fn a_pick_brings_the_group_members_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
|
||||
s.settings.pickstyle = 1;
|
||||
s.set_selection(Vec::new());
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
enter(&mut s, &b);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// Select All and Select Similar take what the viewport draws.
|
||||
#[test]
|
||||
fn select_all_and_select_similar_take_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// Mode 0: any object of the same type is similar, whatever its layer.
|
||||
let similar = json!({ "handles": [shown.hex()], "mode": 0 });
|
||||
enter(&mut s, &a);
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
s.execute("selectsimilar", &similar).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
enter(&mut s, &b);
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
s.execute("selectsimilar", &similar).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// STRETCH selects for itself: its crossing window, polygon and fence, called with JSON or
|
||||
/// typed, and its typed All, Last and Previous, move the line the viewport draws and leave the
|
||||
/// frozen one where it was.
|
||||
#[test]
|
||||
fn stretch_moves_what_the_viewport_draws() {
|
||||
let calls = [
|
||||
json!({ "window": [[9, -1], [11, 1]], "delta": [0, 5] }),
|
||||
json!({ "polygon": [[9, -1], [11, -1], [11, 1], [9, 1]], "delta": [0, 5] }),
|
||||
json!({ "fence": [[5, -1], [5, 1]], "delta": [0, 5] }),
|
||||
];
|
||||
for call in &calls {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("stretch", call).unwrap();
|
||||
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must move");
|
||||
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.execute("stretch", call).unwrap();
|
||||
assert!(!untouched(&s, hidden), "{call}: where both are drawn both move");
|
||||
}
|
||||
// Typed: a crossing window, named and implied, All, a pick, Last, and Previous (both lines).
|
||||
for objects in ["C 11,1 9,-1", "11,1 9,-1", "ALL", "5,0", "L", "P"] {
|
||||
let typed = format!("STRETCH {objects}\n\n0,0 0,5\n");
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &typed);
|
||||
assert!(!untouched(&s, shown), "{typed:?}: the line the viewport draws must move");
|
||||
assert!(untouched(&s, hidden), "{typed:?}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &typed);
|
||||
assert!(!untouched(&s, hidden), "{typed:?}: where both are drawn the one on top moves");
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline whose control vertex is inside STRETCH's crossing window is stretched although
|
||||
/// the window misses its drawn lines, but not when the viewport does not draw it.
|
||||
#[test]
|
||||
fn stretch_leaves_a_multiline_the_viewport_does_not_draw() {
|
||||
/// The control vertices of the multiline `h`.
|
||||
fn vertices(s: &Session, h: Handle) -> Vec<Vec2> {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::MLine(m) => m.vertices.iter().map(|v| v.p.xy()).collect(),
|
||||
k => panic!("not a multiline: {k:?}"),
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
// Zero justification at scale 2: the element lines run one unit either side of the vertices.
|
||||
let r = s.execute("mline", &json!({ "points": [[0, 0], [10, 0]], "scale": 2, "justification": "zero" })).unwrap();
|
||||
let mline = Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap();
|
||||
hide(&mut s, mline);
|
||||
let (a, b) = viewports(&mut s);
|
||||
let call = json!({ "window": [[9.9, -0.1], [10.1, 0.1]], "delta": [0, 5] });
|
||||
enter(&mut s, &a);
|
||||
s.execute("stretch", &call).unwrap();
|
||||
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 0.0), 1e-9), "a multiline the viewport does not draw was stretched");
|
||||
enter(&mut s, &b);
|
||||
s.execute("stretch", &call).unwrap();
|
||||
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 5.0), 1e-9), "{:?}", vertices(&s, mline));
|
||||
}
|
||||
|
||||
/// TRIM's fence and crossing window trim the line the viewport draws and leave the frozen one.
|
||||
#[test]
|
||||
fn trim_by_fence_and_crossing_leaves_what_the_viewport_does_not_draw() {
|
||||
for call in [json!({ "fence": [[8, -1], [8, 1]] }), json!({ "crossing": [[7.5, -1], [8.5, 1]] })] {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// The cutting edge, on a layer every viewport draws.
|
||||
line(&mut s, [5.0, -5.0], [5.0, 5.0]);
|
||||
hide(&mut s, hidden);
|
||||
let (a, b) = viewports(&mut s);
|
||||
enter(&mut s, &a);
|
||||
s.execute("trim", &call).unwrap();
|
||||
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must be trimmed");
|
||||
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw was trimmed: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.execute("trim", &call).unwrap();
|
||||
assert!(!untouched(&s, hidden), "{call}: where both are drawn both are trimmed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A prompt that picks one object (BREAK's "Select object") finds the one the viewport draws.
|
||||
#[test]
|
||||
fn a_prompt_for_one_object_picks_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
run(&mut s, "BREAK 4,0 6,0\n");
|
||||
assert!(untouched(&s, hidden), "a line the viewport does not draw was broken: {:?}", ends(&s, hidden));
|
||||
assert!(!untouched(&s, shown), "the line the viewport draws must be broken");
|
||||
enter(&mut s, &b);
|
||||
run(&mut s, "BREAK 4,0 6,0\n");
|
||||
assert!(!untouched(&s, hidden), "where both are drawn the one on top is broken");
|
||||
}
|
||||
|
||||
/// LEADER's Copy option picks the text to copy: at a point where two texts lie, the one the
|
||||
/// viewport draws.
|
||||
#[test]
|
||||
fn leader_copies_the_text_the_viewport_draws() {
|
||||
/// The contents of the multiline texts in model space: LEADER's copies.
|
||||
fn copies(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect()
|
||||
}
|
||||
let mut s = Session::new();
|
||||
for text in ["SHOWN", "HIDDEN"] {
|
||||
s.execute("text", &json!({ "at": [0, 0], "text": text, "height": 2.5 })).unwrap();
|
||||
}
|
||||
// The text drawn last lies on top.
|
||||
let hidden = *s.doc().unwrap().model.handles().last().unwrap();
|
||||
hide(&mut s, hidden);
|
||||
let (a, b) = viewports(&mut s);
|
||||
let leader = "(command \"_.LEADER\" '(20.0 20.0) '(30.0 25.0) \"\" \"\" \"_C\" '(1.0 1.0))";
|
||||
enter(&mut s, &a);
|
||||
s.cmdline(leader).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(copies(&s), ["SHOWN"], "the viewport freezes the layer of the text on top");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(copies(&s).is_empty());
|
||||
enter(&mut s, &b);
|
||||
s.cmdline(leader).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(copies(&s), ["HIDDEN"], "this viewport draws both: the one on top is picked");
|
||||
}
|
||||
|
||||
/// AutoLISP selects through the viewport too: `ssget` with a window, a crossing window, a fence,
|
||||
/// a polygon, a crossing polygon or a point leaves the frozen line out, and `entsel` picks the
|
||||
/// line the viewport draws. The sets made earlier (the implied selection, `ssgetfirst`, Previous)
|
||||
/// come without the frozen line, and Last is the last object the viewport draws.
|
||||
#[test]
|
||||
fn lisp_selects_what_the_viewport_draws() {
|
||||
use cadcraft_engine::lisp::value::Value as Lisp;
|
||||
/// The objects of the selection set that `code` returns (none for nil), in handle order.
|
||||
fn set(s: &mut Session, code: &str) -> Vec<Handle> {
|
||||
match s.lisp_eval(code, false, true, Vec::new()).unwrap() {
|
||||
Some(Ok(Lisp::PickSet(set))) => sorted(set.items.lock().unwrap().clone()),
|
||||
Some(Ok(Lisp::Nil)) => Vec::new(),
|
||||
other => panic!("`{code}` returned {other:?}"),
|
||||
}
|
||||
}
|
||||
/// The object `entsel` returns for a pick at (5, 0).
|
||||
fn entsel(s: &mut Session) -> Handle {
|
||||
s.cmdline("(setq picked (car (entsel)))").unwrap();
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 0.0))).unwrap();
|
||||
match s.lisp_var("picked") {
|
||||
Lisp::Ename(h) => h,
|
||||
other => panic!("entsel returned {other:?}: {}", s.prompt_text()),
|
||||
}
|
||||
}
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let areas = [
|
||||
"(ssget \"W\" '(-1 -1) '(11 1))",
|
||||
"(ssget \"C\" '(4 -1) '(6 1))",
|
||||
"(ssget \"F\" '((5 -1) (5 1)))",
|
||||
"(ssget \"WP\" '((-1 -1) (11 -1) (11 1) (-1 1)))",
|
||||
"(ssget \"CP\" '((4 -1) (6 -1) (5 1)))",
|
||||
];
|
||||
enter(&mut s, &a);
|
||||
for code in areas {
|
||||
assert_eq!(set(&mut s, code), [shown], "{code}");
|
||||
}
|
||||
assert_eq!(set(&mut s, "(ssget '(5 0))"), [shown], "a point picks the object the viewport draws");
|
||||
assert_eq!(entsel(&mut s), shown);
|
||||
enter(&mut s, &b);
|
||||
for code in areas {
|
||||
assert_eq!(set(&mut s, code), sorted(vec![shown, hidden]), "{code}");
|
||||
}
|
||||
assert_eq!(set(&mut s, "(ssget '(5 0))"), [hidden], "this viewport draws both: the one on top is picked");
|
||||
assert_eq!(entsel(&mut s), hidden);
|
||||
|
||||
let earlier = ["(ssget \"I\")", "(ssget)", "(cadr (ssgetfirst))", "(ssget \"P\")"];
|
||||
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
|
||||
enter(&mut s, viewport);
|
||||
for code in earlier {
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
assert_eq!(set(&mut s, code), expected, "{code}");
|
||||
}
|
||||
}
|
||||
// Last: of two lines made after everything else, the later one is on the layer the first
|
||||
// viewport freezes, so through that viewport Last is the earlier one.
|
||||
s.set_selection(Vec::new());
|
||||
enter(&mut s, &b);
|
||||
let drawn = line(&mut s, [0.0, 2.0], [10.0, 2.0]);
|
||||
let last = line(&mut s, [0.0, 3.0], [10.0, 3.0]);
|
||||
hide(&mut s, last);
|
||||
assert_eq!(set(&mut s, "(ssget \"L\")"), [last]);
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(set(&mut s, "(ssget \"L\")"), [drawn], "Last must pass over an object the viewport does not draw");
|
||||
}
|
||||
|
||||
/// The grips of a selected object the viewport does not draw take no part in a grip edit: a
|
||||
/// shared grip stretches only the line that is drawn. Through the other viewport both stretch.
|
||||
#[test]
|
||||
fn a_selected_line_the_viewport_does_not_draw_is_not_stretched_with_a_shared_grip() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
let i = origin_grip(&s, shown);
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
|
||||
assert!(untouched(&s, hidden), "a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
// Its own grip starts no edit there.
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let j = origin_grip(&s, hidden);
|
||||
assert!(s.start_grip_edit(&[(hidden, j)]).is_err());
|
||||
assert!(s.running.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
|
||||
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
|
||||
assert!(ends(&s, hidden).0.near(Vec2::new(3.0, 4.0), 1e-9), "where both are drawn both stretch: {:?}", ends(&s, hidden));
|
||||
}
|
||||
|
||||
/// A layer frozen in the viewport while its object is selected: from then on the object's grips
|
||||
/// are not there, and thawing the layer in the viewport brings them back.
|
||||
#[test]
|
||||
fn freezing_a_layer_in_the_viewport_removes_the_grips_of_a_selected_object() {
|
||||
let Sheet { mut s, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![hidden]);
|
||||
// The typed GRIP command finds a grip of the selected line at its start.
|
||||
let grip_there = |s: &mut Session| {
|
||||
s.cmdline("grip").unwrap();
|
||||
s.cmdline("0,0").unwrap();
|
||||
let none = s.log.iter().any(|l| l.contains("No grip of a selected object there"));
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
s.log.clear();
|
||||
!none
|
||||
};
|
||||
assert!(grip_there(&mut s), "the viewport draws the line: its grip is there");
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(!grip_there(&mut s), "the viewport no longer draws the line");
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(grip_there(&mut s), "thawed in the viewport: the grip is back");
|
||||
}
|
||||
|
||||
/// What the edited view shows: the frozen layer hides its model-space objects inside that
|
||||
/// viewport only, and never an object on the sheet.
|
||||
#[test]
|
||||
fn only_the_viewport_that_freezes_a_layer_hides_its_model_objects() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// A line on the sheet, on the layer the first viewport freezes.
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
assert_eq!(s.doc().unwrap().space_of(on_sheet), Some(Space::Paper("Layout1".into())));
|
||||
let shows = |s: &Session, h: Handle| {
|
||||
let st = s.state().unwrap();
|
||||
st.shows(st.doc.entity(h).unwrap())
|
||||
};
|
||||
assert!(s.viewport_frozen().is_empty(), "paper space: no viewport is active");
|
||||
assert!(shows(&s, hidden) && shows(&s, shown) && shows(&s, on_sheet));
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(s.viewport_frozen(), ["Hidden".to_string()]);
|
||||
assert!(!shows(&s, hidden), "frozen in the active viewport");
|
||||
assert!(shows(&s, shown));
|
||||
assert!(shows(&s, on_sheet), "the viewport draws model space: its frozen layers hide nothing on the sheet");
|
||||
// A prompt that picks one object on the sheet while the viewport is active finds it.
|
||||
let sheet_space = Space::Paper("Layout1".into());
|
||||
assert_eq!(s.pick_object(&sheet_space, Vec2::new(1.5, 1.0), 0.5).unwrap(), Some(on_sheet));
|
||||
assert_eq!(s.pick_object(&Space::Model, Vec2::new(5.0, 0.0), 0.5).unwrap(), Some(shown), "in model space the frozen line is left out");
|
||||
enter(&mut s, &b);
|
||||
assert!(s.viewport_frozen().is_empty());
|
||||
assert!(shows(&s, hidden));
|
||||
// Layer names are not case sensitive: frozen under another spelling, the line is hidden too.
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["HIDDEN"] })).unwrap();
|
||||
assert_eq!(s.viewport_frozen(), ["HIDDEN".to_string()]);
|
||||
assert!(!shows(&s, hidden));
|
||||
assert_eq!(select(&mut s, json!({ "at": [5, 0], "aperture": 0.5 })), [shown]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["hidden"] })).unwrap();
|
||||
assert!(shows(&s, hidden));
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
assert!(s.viewport_frozen().is_empty(), "the Model tab has no layout viewport");
|
||||
assert!(shows(&s, hidden));
|
||||
}
|
||||
|
||||
/// An object that is selected when its layer is frozen in the active viewport stays selected, but
|
||||
/// nothing that starts from the selection takes it there: a typed command with the selection
|
||||
/// made before it (PICKFIRST), a JSON command that names no objects, and Delete.
|
||||
#[test]
|
||||
fn a_command_leaves_out_a_selected_object_the_viewport_does_not_draw() {
|
||||
/// One way of editing the selection.
|
||||
type Edit = fn(&mut Session);
|
||||
let edits: [(&str, Edit); 5] = [
|
||||
("typed ERASE", |s: &mut Session| run(s, "ERASE\n")),
|
||||
("typed MOVE", |s: &mut Session| run(s, "MOVE 0,0 0,5\n")),
|
||||
("JSON erase", |s: &mut Session| drop(s.execute("erase", &json!({})).unwrap())),
|
||||
("JSON move", |s: &mut Session| drop(s.execute("move", &json!({ "delta": [0, 5] })).unwrap())),
|
||||
("Delete", |s: &mut Session| drop(s.execute("erase.selection", &json!({})).unwrap())),
|
||||
];
|
||||
for (name, edit) in edits {
|
||||
// Where both lines are drawn, both are edited.
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
edit(&mut s);
|
||||
assert!(edited(&s, shown) && edited(&s, hidden), "{name}: where both are drawn both are edited");
|
||||
|
||||
// The layer is frozen in the active viewport while both lines are selected.
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "{name}: both lines must still be selected");
|
||||
assert_eq!(s.selection(), [shown], "{name}: the selection as the viewport has it");
|
||||
edit(&mut s);
|
||||
assert!(edited(&s, shown), "{name}: the line the viewport draws must be edited");
|
||||
assert!(!edited(&s, hidden), "{name}: a line the viewport does not draw was edited: {:?}", ends(&s, hidden));
|
||||
}
|
||||
|
||||
// With only that line selected there is nothing to start from.
|
||||
let Sheet { mut s, hidden, a, b, .. } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(s.selection().is_empty());
|
||||
assert!(s.execute("erase.selection", &json!({})).is_err(), "Delete must find nothing selected");
|
||||
s.script("ERASE\n").unwrap();
|
||||
assert!(s.running.is_some() && s.prompt_text().contains("Select objects"), "ERASE must ask for objects: {}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
assert!(!edited(&s, hidden));
|
||||
// The line is still selected, and a viewport that draws it has it again.
|
||||
s.set_selection(vec![hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [a], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(s.selection(), [hidden], "thawed in the viewport: selected as before");
|
||||
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
assert!(s.selection().is_empty());
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![hidden]);
|
||||
run(&mut s, "ERASE\n");
|
||||
assert!(!exists(&s, hidden), "where the line is drawn the selection made before ERASE is erased");
|
||||
}
|
||||
|
||||
/// The Previous and Last options of "Select objects" take what the viewport draws: of the set a
|
||||
/// command used before, and the last object that is not frozen there.
|
||||
#[test]
|
||||
fn previous_and_last_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
for option in ["P", "L"] {
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(Vec::new());
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &format!("ERASE {option}\n"));
|
||||
assert!(!exists(&s, shown), "`{option}` must erase the line the viewport draws");
|
||||
assert!(exists(&s, hidden), "`{option}` erased a line the viewport does not draw");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(Vec::new());
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &format!("ERASE {option}\n"));
|
||||
// Previous is both lines; Last is the one made last, the line on the layer "Hidden".
|
||||
assert_eq!((exists(&s, shown), exists(&s, hidden)), (option == "L", false), "`{option}` where both are drawn");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON grip edits that cover the selection (`grip.stretch` with hot grips, and
|
||||
/// [`Session::grip_edit`]) drag the grips of the other selected objects on the same point, but
|
||||
/// not the grip of a selected line the viewport does not draw.
|
||||
#[test]
|
||||
fn the_grip_edits_that_cover_the_selection_leave_out_what_the_viewport_does_not_draw() {
|
||||
use cadcraft_engine::grips::GripMode;
|
||||
/// Drags grip `i` of the line `h` to (3, 4).
|
||||
type Edit = fn(&mut Session, Handle, usize);
|
||||
let edits: [(&str, Edit); 2] = [
|
||||
("grip.stretch", |s: &mut Session, h: Handle, i: usize| {
|
||||
drop(s.execute("grip.stretch", &json!({ "grips": [[h.hex(), i]], "to": [3, 4] })).unwrap())
|
||||
}),
|
||||
("grip_edit", |s: &mut Session, h: Handle, i: usize| s.grip_edit(h, i, Vec2::new(3.0, 4.0), GripMode::Stretch).unwrap()),
|
||||
];
|
||||
for (name, edit) in edits {
|
||||
for frozen in [true, false] {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, if frozen { &a } else { &b });
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
let i = origin_grip(&s, shown);
|
||||
edit(&mut s, shown, i);
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{name}: {:?}", ends(&s, shown));
|
||||
assert_eq!(untouched(&s, hidden), frozen, "{name}, layer frozen in the viewport: {frozen}: {:?}", ends(&s, hidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the session reports as the selection is the selection as the viewport has it, while
|
||||
/// every selected object stays in the stored selection.
|
||||
#[test]
|
||||
fn the_reported_selection_leaves_out_what_the_viewport_does_not_draw() {
|
||||
/// The handles in `list`, a JSON array of hex strings, in handle order.
|
||||
fn handles(list: &Value) -> Vec<Handle> {
|
||||
sorted(list.as_array().unwrap().iter().map(|h| Handle::parse_hex(h.as_str().unwrap()).unwrap()).collect())
|
||||
}
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
|
||||
enter(&mut s, viewport);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "both lines are selected");
|
||||
assert_eq!(sorted(s.selection()), expected);
|
||||
assert_eq!(handles(&s.summary()["selection"]), expected);
|
||||
let inspect = s.execute("drawing.inspect", &json!({})).unwrap();
|
||||
assert_eq!(handles(&inspect["selection"]), expected);
|
||||
}
|
||||
// A selected object on the sheet is never left out, whatever its layer.
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden, on_sheet]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, on_sheet]));
|
||||
// Where no viewport is active nothing is left out: in paper space and on the Model tab.
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
s.set_selection(vec![shown, hidden, on_sheet]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, on_sheet]), "paper space");
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden]), "the Model tab");
|
||||
}
|
||||
|
||||
/// Adding to the selection or removing from it inside the viewport keeps a selected object the
|
||||
/// viewport does not draw: nothing drops it behind the user's back, and it counts again once its
|
||||
/// layer is thawed there.
|
||||
#[test]
|
||||
fn changing_the_selection_keeps_a_selected_object_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
// A third line, on layer 0 like `shown`, away from the other two.
|
||||
let other = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
// Selects `hidden` where it is drawn, then freezes its layer in the active viewport.
|
||||
let freeze = |s: &mut Session| {
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(selected(s), [hidden], "the line must still be selected");
|
||||
assert!(s.selection().is_empty(), "the viewport does not draw it");
|
||||
};
|
||||
|
||||
// A click adds (PICKADD), Shift+click removes.
|
||||
freeze(&mut s);
|
||||
s.idle_click(Vec2::new(5.0, 5.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a click dropped the selected line the viewport does not draw");
|
||||
assert_eq!(s.selection(), [other]);
|
||||
s.idle_click(Vec2::new(5.0, 5.0), true).unwrap();
|
||||
assert_eq!(selected(&s), [hidden], "Shift+click must remove only the line clicked");
|
||||
|
||||
// A window adds.
|
||||
freeze(&mut s);
|
||||
s.idle_click(Vec2::new(-1.0, 4.0), false).unwrap();
|
||||
assert!(s.pending_window.is_some(), "the first click must be on nothing");
|
||||
s.idle_click(Vec2::new(11.0, 6.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a window dropped the selected line the viewport does not draw");
|
||||
|
||||
// The JSON `select` with `add`, and QSELECT appending the objects on layer 0.
|
||||
freeze(&mut s);
|
||||
s.execute("select", &json!({ "at": [5, 5], "aperture": 0.5, "add": true })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with `add`");
|
||||
// Given nothing to select with, `select` leaves the selection as it is.
|
||||
s.execute("select", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with nothing to select with");
|
||||
freeze(&mut s);
|
||||
s.execute("qselect", &json!({ "layer": "0", "mode": "append" })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden, other]), "QSELECT appending");
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, other]));
|
||||
|
||||
// Thawed in the viewport, the line counts as selected again.
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, other]));
|
||||
}
|
||||
|
||||
/// [`Session::selection`] looks a few selected objects up one by one and, when there are many,
|
||||
/// passes over model space once. Both leave out the same objects and keep the order of the
|
||||
/// selection: in a drawing of some hundreds of lines whose draw order is not the order of their
|
||||
/// handles, a selection of a few lines and the selection of every line.
|
||||
#[test]
|
||||
fn a_small_and_a_large_selection_leave_out_the_same_objects() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// A line on the sheet, on the layer the first viewport freezes: no viewport hides it.
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
assert!(!s.doc().unwrap().model.contains(on_sheet), "the line must be on the sheet");
|
||||
enter(&mut s, &b);
|
||||
let more: Vec<Handle> = (0..400).map(|i| line(&mut s, [f64::from(i), 5.0], [f64::from(i), 6.0])).collect();
|
||||
// Every second one of them goes on the layer the first viewport freezes.
|
||||
let frozen: Vec<String> = more.iter().step_by(2).map(|h| h.hex()).collect();
|
||||
s.execute("properties.set", &json!({ "handles": frozen, "layer": "Hidden" })).unwrap();
|
||||
// The frozen line made last goes to the back: it is now the first object in draw order.
|
||||
s.execute("draworder", &json!({ "handles": [more[398].hex()], "order": "back" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.iter().next().map(|e| e.handle), Some(more[398]));
|
||||
let all: Vec<Handle> = [shown, hidden].into_iter().chain(more.iter().copied()).collect();
|
||||
let drawn: Vec<Handle> = std::iter::once(shown).chain(more.iter().copied().skip(1).step_by(2)).collect();
|
||||
assert_eq!(s.doc().unwrap().model.len(), all.len());
|
||||
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(s.selection(), [shown], "two selected lines of some hundreds");
|
||||
s.set_selection(vec![hidden, on_sheet, more[0], more[1]]);
|
||||
assert_eq!(s.selection(), [on_sheet, more[1]], "a line on the sheet is never left out");
|
||||
s.set_selection(all.clone());
|
||||
assert_eq!(s.selection(), drawn, "every line selected");
|
||||
assert_eq!(s.state().unwrap().selection, all, "every line must still be selected");
|
||||
|
||||
// The viewport that freezes nothing has them all, few or many.
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(s.selection(), [shown, hidden]);
|
||||
s.set_selection(all.clone());
|
||||
assert_eq!(s.selection(), all);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-dwg = { workspace = true }
|
||||
craft-dwg = { workspace = true }
|
||||
craft-image-codecs = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -22,14 +23,14 @@ serde = { workspace = true }
|
||||
miniz_oxide = "0.9"
|
||||
craft-pdf = { workspace = true, features = ["write"] }
|
||||
quick-xml = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
image = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
# Reads plotted PDFs back.
|
||||
craft-pdf = { workspace = true, features = ["read"] }
|
||||
|
||||
# Inspect DWG output only where cadcraft-dwg builds its native bridge. An unconditional
|
||||
# Inspect DWG output only where craft-dwg builds its native bridge. An unconditional
|
||||
# dev-dependency also builds acadrust during --all-targets, breaking FreeBSD aarch64 (#2526).
|
||||
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dev-dependencies]
|
||||
acadrust = "0.6.6"
|
||||
|
||||
@@ -344,7 +344,10 @@ fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo
|
||||
fn write_insert_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) {
|
||||
if let EntityKind::Insert(i) = &e.kind {
|
||||
w.s(0, "INSERT");
|
||||
common(w, e, owner, paper, "AcDbBlockReference");
|
||||
// A reference repeated in rows or columns (MINSERT, groups 70/71) is an AcDbMInsertBlock;
|
||||
// readers that go by the marker draw only the first copy of an AcDbBlockReference.
|
||||
let array = i.cols > 1 || i.rows > 1;
|
||||
common(w, e, owner, paper, if array { "AcDbMInsertBlock" } else { "AcDbBlockReference" });
|
||||
if !i.attribs.is_empty() {
|
||||
w.i(66, 1);
|
||||
}
|
||||
@@ -354,7 +357,7 @@ fn write_insert_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: b
|
||||
w.f(42, i.scale.y);
|
||||
w.f(43, i.scale.z);
|
||||
w.f(50, i.rotation.to_degrees());
|
||||
if i.cols > 1 || i.rows > 1 {
|
||||
if array {
|
||||
w.i(70, i64::from(i.cols));
|
||||
w.i(71, i64::from(i.rows));
|
||||
w.f(44, i.col_spacing);
|
||||
|
||||
+23
-10
@@ -61,11 +61,24 @@ pub enum IoError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, IoError>;
|
||||
|
||||
/// How a program runs the DWG reader and writer in a child process (`cadcraft_dwg::worker`).
|
||||
/// Restore CADCraft's public diagnostics after the shared byte bridge supplies generic wording.
|
||||
fn cadcraft_dwg_message(message: &str) -> String {
|
||||
message
|
||||
.replace("accepted by this bridge", "CADCraft opens")
|
||||
.replace("types this bridge can't preserve", "types CADCraft doesn't keep")
|
||||
.replace("this bridge writes", "CADCraft writes")
|
||||
.replace("accepted for DWG conversion", "CADCraft converts to DWG")
|
||||
}
|
||||
|
||||
fn dwg_error(message: String) -> IoError {
|
||||
IoError::Format(cadcraft_dwg_message(&message))
|
||||
}
|
||||
|
||||
/// How a program runs the DWG reader and writer in a child process (`craft_dwg::worker`).
|
||||
pub mod dwg_worker {
|
||||
pub use cadcraft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
|
||||
pub use craft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
pub use cadcraft_dwg::{crash_worker_for_test, use_worker};
|
||||
pub use craft_dwg::{crash_worker_for_test, use_worker};
|
||||
}
|
||||
|
||||
fn ext(name: impl AsRef<Path>) -> String {
|
||||
@@ -81,10 +94,10 @@ pub fn read(bytes: &[u8], name: impl AsRef<Path>) -> Result<Drawing> {
|
||||
/// [`read`], also returning the DXF rendition a DWG was read through (`None` for other files),
|
||||
/// which the round-trip harness counts.
|
||||
pub(crate) fn read_source(bytes: &[u8], name: &Path) -> Result<(Drawing, Option<Vec<u8>>)> {
|
||||
if cadcraft_dwg::is_dwg(bytes) {
|
||||
let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
|
||||
if craft_dwg::is_dwg(bytes) {
|
||||
let (dxf, notes) = craft_dwg::decode(bytes, craft_dwg::Limits::DEFAULT).map_err(dwg_error)?;
|
||||
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
|
||||
d.read_notes = notes;
|
||||
d.read_notes = notes.into_iter().map(|note| cadcraft_dwg_message(¬e)).collect();
|
||||
return Ok((d, Some(dxf)));
|
||||
}
|
||||
let d = match (Format::from_path(name), ext(name).as_str()) {
|
||||
@@ -132,21 +145,21 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
|
||||
/// [`write_with`], also returning what the file leaves out of the drawing: for a DWG, how many
|
||||
/// records of each DXF type the DWG writer can't write (other programs' custom entities and
|
||||
/// objects); for other formats, nothing.
|
||||
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
|
||||
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, craft_dwg::Unwritten)> {
|
||||
let name = name.as_ref();
|
||||
if matches!(Format::from_path(name), Some(Format::Dwg)) && !opts.binary {
|
||||
return write_dwg(d, opts.version.unwrap_or_else(|| save_version(d).max(Version::R2004)));
|
||||
}
|
||||
write_with(d, name, opts).map(|b| (b, cadcraft_dwg::Unwritten::new()))
|
||||
write_with(d, name, opts).map(|b| (b, craft_dwg::Unwritten::new()))
|
||||
}
|
||||
|
||||
/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`, in the DWG worker when
|
||||
/// there is one. The objects CADCraft keeps without modelling them are still in the groups of the
|
||||
/// file they came from (`Foreign::version`, not `$ACADVER`, which SAVEAS changes), and the bridge
|
||||
/// reads them as that version.
|
||||
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
|
||||
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, craft_dwg::Unwritten)> {
|
||||
let bridge = dxf_write::dwg_bridge(d, version);
|
||||
cadcraft_dwg::encode_kept_noted(bridge.as_bytes(), version.code(), d.foreign.version.as_deref()).map_err(IoError::Format)
|
||||
craft_dwg::encode_kept_noted(bridge.as_bytes(), version.code(), d.foreign.version.as_deref()).map_err(dwg_error)
|
||||
}
|
||||
|
||||
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
|
||||
|
||||
@@ -116,7 +116,7 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
// PDF clips a placed plot to the printable area: blank the rest of the sheet.
|
||||
raster::blank_outside_printable(&mut pm, &sheet.printable, k);
|
||||
}
|
||||
pm.encode_png().map_err(|_| IoError::Format("render failed".into()))
|
||||
raster::encode_png(&pm).ok_or_else(|| IoError::Format("render failed".into()))
|
||||
}
|
||||
|
||||
fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
|
||||
+31
-43
@@ -2,11 +2,9 @@
|
||||
//! from the content. Files are hostile input: the size is read from the header and checked
|
||||
//! before any pixel is decoded.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::Raster;
|
||||
|
||||
use crate::{IoError, Result};
|
||||
use cadcraft_doc::Raster;
|
||||
use craft_image_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType};
|
||||
|
||||
/// Largest image file read (bytes).
|
||||
pub const MAX_IMAGE_BYTES: usize = 256 << 20;
|
||||
@@ -17,22 +15,18 @@ fn bad(e: impl std::fmt::Display) -> IoError {
|
||||
IoError::Format(format!("image: {e}"))
|
||||
}
|
||||
|
||||
/// A reader over an image file, once it is small enough and of a format we read.
|
||||
fn reader(bytes: &[u8]) -> Result<image::ImageReader<Cursor<&[u8]>>> {
|
||||
/// The width and height in pixels of the first stored image, read without decoding pixels.
|
||||
pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> {
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err(bad(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20)));
|
||||
}
|
||||
// The format comes from the file's first bytes, not from its name.
|
||||
let file = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(bad)?;
|
||||
if file.format().is_none() {
|
||||
let format = craft_image_codecs::detect(bytes).ok_or_else(|| bad("not a PNG, JPEG, BMP or TIFF file"))?;
|
||||
if !matches!(format, Format::Png | Format::Jpeg | Format::Bmp | Format::Tiff) {
|
||||
return Err(bad("not a PNG, JPEG, BMP or TIFF file"));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// The width and height in pixels of the image in `bytes`, read from its header.
|
||||
pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> {
|
||||
let (width, height) = reader(bytes)?.into_dimensions().map_err(bad)?;
|
||||
// Match ImageReader's original header budget; check pixels ourselves for the existing diagnostic.
|
||||
let limits = Limits::new(u32::MAX, u32::MAX, u64::MAX, 512 << 20);
|
||||
let (width, height) = craft_image_codecs::probe_stored_size(bytes, &limits).map_err(bad)?;
|
||||
if width == 0 || height == 0 {
|
||||
return Err(bad("the image is empty"));
|
||||
}
|
||||
@@ -42,38 +36,32 @@ pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> {
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
/// Decode the image in `bytes`.
|
||||
/// Decode the first image, keeping its stored orientation.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Raster> {
|
||||
// The size is checked first, from the header alone.
|
||||
let (width, height) = probe(bytes)?;
|
||||
let mut file = reader(bytes)?;
|
||||
file.limits(decode_limits(width, height));
|
||||
let pixels = file.decode().map_err(bad)?.into_rgba8();
|
||||
Raster::new(pixels.width(), pixels.height(), pixels.into_raw()).ok_or_else(|| bad("the image is empty"))
|
||||
}
|
||||
|
||||
/// What the decoder may allocate for an image of this size: the pixels themselves (up to 16
|
||||
/// bytes each before they become 8-bit RGBA) and its own working buffers.
|
||||
fn decode_limits(width: u32, height: u32) -> image::Limits {
|
||||
let pixels = u64::from(width).saturating_mul(u64::from(height));
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(width);
|
||||
limits.max_image_height = Some(height);
|
||||
limits.max_alloc = Some(pixels.saturating_mul(16).saturating_add(64 << 20));
|
||||
limits
|
||||
let limits = Limits::new(width, height, MAX_IMAGE_PIXELS, pixels.saturating_mul(16).saturating_add(64 << 20));
|
||||
let opts = DecodeOptions { limits, keep_orientation: true };
|
||||
let format = craft_image_codecs::detect(bytes).ok_or_else(|| bad("not an image"))?;
|
||||
let image = if format == Format::Tiff {
|
||||
craft_image_codecs::decode_tiff_page(bytes, 0, &opts)
|
||||
} else {
|
||||
craft_image_codecs::decode_as_with(format, bytes, &opts)
|
||||
}
|
||||
.map_err(bad)?;
|
||||
if image.warnings.iter().any(|w| matches!(w, DecodeWarning::Truncated { .. })) {
|
||||
return Err(bad("image data is truncated"));
|
||||
}
|
||||
if image.dimensions() != (width, height) {
|
||||
return Err(bad("decoded image size does not match its header"));
|
||||
}
|
||||
let rgba = if image.layout() == ChannelLayout::Rgba && image.sample_type() == SampleType::U8 { image.into_data() } else { image.to_rgba8() };
|
||||
Raster::new(width, height, rgba).ok_or_else(|| bad("the image is empty"))
|
||||
}
|
||||
|
||||
/// `raster` as a PNG file.
|
||||
/// `raster` as a PNG file, encoded in rows to avoid a full extra image copy.
|
||||
pub fn encode_png(raster: &Raster) -> Result<Vec<u8>> {
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image::write_buffer_with_format(
|
||||
&mut out,
|
||||
raster.rgba(),
|
||||
raster.width(),
|
||||
raster.height(),
|
||||
image::ExtendedColorType::Rgba8,
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.map_err(bad)?;
|
||||
Ok(out.into_inner())
|
||||
let mut out = Vec::new();
|
||||
cadcraft_render::raster::write_rgba_png(&mut out, raster.width(), raster.height(), raster.rgba()).ok_or_else(|| bad("PNG encoding failed"))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ impl Format {
|
||||
}
|
||||
/// The format of a file's bytes: DWG by its magic, otherwise DXF.
|
||||
pub fn of(bytes: &[u8]) -> Format {
|
||||
if cadcraft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
|
||||
if craft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
|
||||
}
|
||||
fn other(self) -> Format {
|
||||
match self {
|
||||
@@ -279,7 +279,7 @@ pub fn check_bytes(bytes: &[u8], rel: &str, opts: &CorpusOptions) -> FileReport
|
||||
let format = Format::of(bytes);
|
||||
let mut f = FileReport { path: rel.to_string(), format: Some(format), bytes: bytes.len() as u64, ..FileReport::default() };
|
||||
if format == Format::Dwg {
|
||||
f.version = cadcraft_dwg::version(bytes).unwrap_or_default();
|
||||
f.version = craft_dwg::version(bytes).unwrap_or_default();
|
||||
}
|
||||
let start = Instant::now();
|
||||
let name = format!("source.{}", format.ext());
|
||||
|
||||
+42
-22
@@ -797,7 +797,7 @@ fn landscape_media_keeps_its_size_order_and_rotation() {
|
||||
assert_eq!(p.paper, media);
|
||||
|
||||
let dwg = write(&d, "t.dwg").unwrap();
|
||||
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
|
||||
let back = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
|
||||
assert_eq!(plot_paper_groups(&back, "Layout1"), (420.0, 297.0, rotation), "DWG, landscape {landscape}");
|
||||
let r = read(&dwg, "t.dwg").unwrap();
|
||||
let p = &r.layout("Layout1").unwrap().page;
|
||||
@@ -844,7 +844,7 @@ fn a_files_own_paper_groups_are_written_back() {
|
||||
assert_eq!((p.width_mm, p.height_mm, p.landscape), (pw, ph, landscape), "{media} {groups:?} read");
|
||||
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DXF");
|
||||
let dwg = write(&r, "t.dwg").unwrap();
|
||||
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
|
||||
let back = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
|
||||
assert_eq!(plot_paper_groups(&back, "Layout1"), groups, "{media} {groups:?} DWG");
|
||||
let r = read(&dwg, "t.dwg").unwrap();
|
||||
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DWG read and saved again");
|
||||
@@ -1234,7 +1234,7 @@ fn viewport_frozen_layers_survive_dwg() {
|
||||
let back = read(&dwg, "t.dwg").unwrap();
|
||||
assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]);
|
||||
// The DWG holds them in group 331, what AutoCAD reads.
|
||||
let p = pairs(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
|
||||
let p = pairs(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
|
||||
assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]);
|
||||
}
|
||||
|
||||
@@ -1376,8 +1376,8 @@ fn generated_arrowhead_blocks_are_not_kept_as_user_blocks() {
|
||||
for (_, name) in crate::dxf_ext::ARROWS.iter().filter(|(k, _)| *k != cadcraft_render::Arrowhead::ClosedFilled) {
|
||||
assert!(dxf.contains(&format!("\r\n{name}\r\n")), "{name} is written");
|
||||
}
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let from_dwg = read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
|
||||
assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{what}: {:?}", back.blocks.keys().collect::<Vec<_>>());
|
||||
assert!(roundtrip(&back).blocks.keys().all(|k| !k.starts_with('_')), "{what}: second save");
|
||||
@@ -1408,8 +1408,8 @@ fn file_arrowhead_blocks_with_standard_names_are_kept() {
|
||||
d.dim_styles.push(DimStyle { name: "Dots".into(), arrow_block1: "_Dot".into(), arrow_block2: "_DotSmall".into(), ..DimStyle::default() });
|
||||
let kinds = |d: &Drawing, name: &str| d.block(name).map(|b| b.entities.iter().map(|e| e.kind.clone()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let dxf = write_dxf(&d);
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let from_dwg = read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
|
||||
for name in ["_Dot", "_DotSmall"] {
|
||||
assert_eq!(kinds(&back, name), kinds(&d, name), "{what}: {name}");
|
||||
@@ -1479,8 +1479,8 @@ fn many_entities(n: usize) -> Drawing {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ac1024_dwg(n: usize) -> Vec<u8> {
|
||||
let dxf = write_dxf(&many_entities(n)).replacen("AC1015", "AC1024", 1);
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1024"));
|
||||
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some("AC1024"));
|
||||
dwg
|
||||
}
|
||||
|
||||
@@ -1492,7 +1492,7 @@ fn ac1024_dwg(n: usize) -> Vec<u8> {
|
||||
fn ac1024_dwg_with_many_entities_opens() {
|
||||
let n = 30_000;
|
||||
let dwg = ac1024_dwg(n);
|
||||
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
|
||||
let dxf = craft_dwg::dwg_to_dxf(&dwg).unwrap();
|
||||
let tags = craft_dxf::parse(&dxf).unwrap().len();
|
||||
assert!(dxf.len() < dwg.len() * 20, "DXF {} bytes from {} DWG bytes", dxf.len(), dwg.len());
|
||||
assert!(tags < dwg.len(), "{tags} group codes from {} DWG bytes", dwg.len());
|
||||
@@ -1509,16 +1509,16 @@ fn ac1024_dwg_with_many_entities_opens() {
|
||||
#[test]
|
||||
fn oversized_dwg_and_dxf_are_rejected_early() {
|
||||
let dwg = ac1024_dwg(3000);
|
||||
let full = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().len();
|
||||
let full = craft_dwg::dwg_to_dxf(&dwg).unwrap().len();
|
||||
assert!(full > 400_000, "{full}");
|
||||
let limits = cadcraft_dwg::Limits { max_dxf_bytes: 300_000, ..cadcraft_dwg::Limits::DEFAULT };
|
||||
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
|
||||
let limits = craft_dwg::Limits { max_dxf_bytes: 300_000, ..craft_dwg::Limits::DEFAULT };
|
||||
let e = craft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
|
||||
assert!(e.starts_with("DWG: converting this 0."), "{e}");
|
||||
assert!(e.contains("MB file produced more than 0.3 MB of drawing data (the limit)"), "{e}");
|
||||
|
||||
let limits = cadcraft_dwg::Limits { max_dwg_bytes: 100_000, ..cadcraft_dwg::Limits::DEFAULT };
|
||||
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
|
||||
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB CADCraft opens"), "{e}");
|
||||
let limits = craft_dwg::Limits { max_dwg_bytes: 100_000, ..craft_dwg::Limits::DEFAULT };
|
||||
let e = craft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
|
||||
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB accepted by this bridge"), "{e}");
|
||||
|
||||
// Lines that aren't even group codes: the size is reported before anything is parsed.
|
||||
let text = "not a group code\nx\n".repeat(60);
|
||||
@@ -1526,6 +1526,26 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
|
||||
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_dwg_diagnostics_keep_cadcraft_vocabulary() {
|
||||
assert_eq!(
|
||||
cadcraft_dwg_message("DWG: the file is 0.1 MB, larger than the 0.1 MB accepted by this bridge"),
|
||||
"DWG: the file is 0.1 MB, larger than the 0.1 MB CADCraft opens"
|
||||
);
|
||||
assert_eq!(
|
||||
cadcraft_dwg_message("DWG: 2 objects of types this bridge can't preserve were left out (FOO × 2)"),
|
||||
"DWG: 2 objects of types CADCraft doesn't keep were left out (FOO × 2)"
|
||||
);
|
||||
assert_eq!(
|
||||
cadcraft_dwg_message("DWG version `AC1015` can't be written (this bridge writes AC1018+)"),
|
||||
"DWG version `AC1015` can't be written (CADCraft writes AC1018+)"
|
||||
);
|
||||
assert_eq!(
|
||||
cadcraft_dwg_message("DXF: the input is 1024 MB, larger than the 1024 MB accepted for DWG conversion"),
|
||||
"DXF: the input is 1024 MB, larger than the 1024 MB CADCraft converts to DWG"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the
|
||||
/// rest opens, and the drawing's read notes say how many records were left out and of which
|
||||
/// types, so a partial read never passes for a complete one. A complete read has no notes.
|
||||
@@ -2336,8 +2356,8 @@ fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() {
|
||||
_ => panic!("not a dimension"),
|
||||
}
|
||||
// And through DWG.
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
|
||||
let back = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
|
||||
let back = read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect();
|
||||
assert_eq!(dims.len(), 1);
|
||||
assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind);
|
||||
@@ -3568,7 +3588,7 @@ fn proxies_keep_their_class_in_dwg() {
|
||||
fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData};
|
||||
use acadrust::objects::ObjectType;
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(&proxy_file(), "AC1032").unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg_as(&proxy_file(), "AC1032").unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let class_of = |id: i32| doc.classes.iter().find(|c| i32::from(c.class_number) == id).map(|c| c.dxf_name.clone());
|
||||
let mut objects: Vec<Option<String>> = doc
|
||||
@@ -3599,11 +3619,11 @@ fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_proxy_left_out_of_the_dwg_is_counted_as_unwritten() {
|
||||
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&proxy_file(), "AC1032").unwrap();
|
||||
let (_, unwritten) = craft_dwg::dxf_to_dwg_noted(&proxy_file(), "AC1032").unwrap();
|
||||
assert_eq!(unwritten, [("ACAD_PROXY_OBJECT".to_string(), 1)].into_iter().collect());
|
||||
let d = read(&proxy_file(), "p.dxf").unwrap();
|
||||
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
|
||||
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&binary, "AC1032").unwrap();
|
||||
let (_, unwritten) = craft_dwg::dxf_to_dwg_noted(&binary, "AC1032").unwrap();
|
||||
// CADCraft's own save already left the stray proxy out; the binary DXF's other three go.
|
||||
assert_eq!(unwritten, [("ACAD_PROXY_ENTITY".to_string(), 1), ("ACAD_PROXY_OBJECT".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
@@ -3620,7 +3640,7 @@ fn a_binary_dxf_leaves_its_proxies_out_of_the_dwg() {
|
||||
let d = read(&proxy_file(), "p.dxf").unwrap();
|
||||
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
|
||||
assert!(binary.starts_with(b"AutoCAD Binary DXF"));
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(&binary, "AC1032").unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg_as(&binary, "AC1032").unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let proxies = doc.objects.values().filter(|o| matches!(o, ObjectType::ProxyObject(_))).count()
|
||||
+ doc.entities().filter(|e| matches!(e, EntityType::Extended(x) if matches!(x.data, ExtendedEntityData::Proxy(_)))).count();
|
||||
|
||||
@@ -285,7 +285,7 @@ fn standard_dimassoc_associates_in_dxf_and_dwg() {
|
||||
let back = read_dxf(plain.as_bytes()).unwrap();
|
||||
assert_standard_assoc(&c, &back, "DXF");
|
||||
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(plain.as_bytes(), Version::R2018.code()).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg_as(plain.as_bytes(), Version::R2018.code()).unwrap();
|
||||
let back = read(&dwg, "case.dwg").unwrap();
|
||||
assert_standard_assoc(&c, &back, "DWG");
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ fn an_ac1018_dwg_gets_the_variable_dictionary_autocad_always_writes() {
|
||||
#[test]
|
||||
fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() {
|
||||
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n1\n9\n$HIDETEXT\n70\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
|
||||
let dwg = craft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
|
||||
let doc = read_dwg(&dwg);
|
||||
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
|
||||
assert_eq!(value(&vars, "DIMASSOC"), Some("1"), "DIMASSOC in {vars:?}");
|
||||
@@ -107,7 +107,7 @@ fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() {
|
||||
#[test]
|
||||
fn impossible_header_values_fall_back_to_the_defaults() {
|
||||
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n9\n9\n$HIDETEXT\n70\n7\n0\nENDSEC\n0\nEOF\n";
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
|
||||
let dwg = craft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
|
||||
let doc = read_dwg(&dwg);
|
||||
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
|
||||
assert_eq!(value(&vars, "DIMASSOC"), Some("2"), "DIMASSOC in {vars:?}");
|
||||
@@ -139,7 +139,7 @@ fn a_variable_dictionary_the_drawing_has_is_left_alone() {
|
||||
assert!(source.set_drawing_variable("CMLEADERSTYLE", "Notes"));
|
||||
let dxf = acadrust::DxfWriter::new(&source).write_to_vec().expect("write a DXF with the dictionary");
|
||||
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(&dxf, "AC1018").expect("write the DWG");
|
||||
let dwg = craft_dwg::dxf_to_dwg_as(&dxf, "AC1018").expect("write the DWG");
|
||||
let doc = read_dwg(&dwg);
|
||||
let vars = variable_dictionary(&doc).expect("the drawing's dictionary is kept");
|
||||
assert_eq!(vars.len(), 1, "{vars:?}");
|
||||
|
||||
@@ -155,7 +155,7 @@ fn a_rotated_linear_dimension_stores_its_projected_length() {
|
||||
Vec3::new(-40.0, 105.0, 0.0),
|
||||
);
|
||||
let dwg = write_with(&d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
|
||||
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dxf = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let t = tags(&dxf);
|
||||
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
|
||||
let m: f64 = t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap();
|
||||
@@ -242,7 +242,7 @@ fn justified_text_made_here_writes_its_baseline_start_for_every_justification()
|
||||
/// Group 42 of a dimension in the DXF AutoCAD reads from `d`'s DWG.
|
||||
fn stored_measurement(d: &Drawing) -> f64 {
|
||||
let dwg = write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
|
||||
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dxf = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let t = tags(&dxf);
|
||||
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
|
||||
t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap()
|
||||
|
||||
@@ -93,7 +93,7 @@ fn dwg_keeps_the_solids_geometry() {
|
||||
let d = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap();
|
||||
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
|
||||
// The DWG read back (as the bridge's DXF) binds the same bytes to both solids.
|
||||
let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let back = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let mut got = asm_data(&back);
|
||||
got.sort();
|
||||
assert_eq!(got, vec![("2A".to_string(), SAB_HEX.to_string()), ("2B".to_string(), SAB_HEX.to_string())]);
|
||||
|
||||
@@ -248,7 +248,7 @@ fn hostile_multibyte_never_panics() {
|
||||
#[test]
|
||||
fn pre_2007_dwg_text_decodes_through_the_bridge() {
|
||||
let dxf = ascii_dxf("AC1015", "ANSI_1251", b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2");
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(&dxf).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg(&dxf).unwrap();
|
||||
let d = cadcraft_io::read(&dwg, "x.dwg").unwrap();
|
||||
assert_eq!(names(&d), ("Слой".into(), "Блок".into(), "Привет".into()));
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ fn dwg_keeps_true_colours_and_transparency() {
|
||||
);
|
||||
|
||||
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
|
||||
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1032"));
|
||||
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some("AC1032"));
|
||||
let header = records(&cadcraft_io::write_dxf(&d), "SECTION");
|
||||
assert_eq!(header.first().and_then(|h| group(h, "1")), Some("AC1015"), "the DXF stays R2000");
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ fn dwg_at_every_version_reads_back() {
|
||||
let d = sample();
|
||||
for v in Version::ALL {
|
||||
let dwg = write_with(&d, "x.dwg", &opts(v, false)).unwrap();
|
||||
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some(v.code()), "{v}");
|
||||
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some(v.code()), "{v}");
|
||||
let back = read(&dwg, "x.dwg").unwrap();
|
||||
assert_eq!(save_version(&back), v, "{v}");
|
||||
assert_eq!(back.model.len(), d.model.len(), "{v}");
|
||||
@@ -187,6 +187,6 @@ fn dwg_at_every_version_reads_back() {
|
||||
#[test]
|
||||
fn dwg_default_is_at_least_2004() {
|
||||
let from_r2000 = read(write_dxf(&sample()).as_bytes(), "x.dxf").unwrap();
|
||||
assert_eq!(cadcraft_dwg::version(&write(&from_r2000, "x.dwg").unwrap()).as_deref(), Some("AC1018"));
|
||||
assert_eq!(cadcraft_dwg::version(&write(&sample(), "x.dwg").unwrap()).as_deref(), Some("AC1032"));
|
||||
assert_eq!(craft_dwg::version(&write(&from_r2000, "x.dwg").unwrap()).as_deref(), Some("AC1018"));
|
||||
assert_eq!(craft_dwg::version(&write(&sample(), "x.dwg").unwrap()).as_deref(), Some("AC1032"));
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ fn with_scale_action() -> String {
|
||||
|
||||
/// A scale action goes through CADCraft's own DWG save (the bridge DXF, then acadrust) whole:
|
||||
/// the DWG holds it with its connections, and it reads back as the same action. AutoCAD refused
|
||||
/// such DWGs while acadrust left out the byte after its connections (cadcraft-dwg's
|
||||
/// such DWGs while acadrust left out the byte after its connections (craft-dwg's
|
||||
/// `scale_actions_keep_the_byte_autocad_reads_after_their_connections` covers the byte).
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
|
||||
@@ -37,6 +37,7 @@ mod layout_plot_window;
|
||||
mod leader_vertex_elevation;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod low_handseed;
|
||||
mod minsert_marker;
|
||||
mod mleader_content;
|
||||
mod mleader_roundtrip;
|
||||
mod mline_roundtrip;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! A block reference repeated in rows or columns (MINSERT) is written with the subclass marker
|
||||
//! `AcDbMInsertBlock`, as AutoCAD writes it; other readers (BricsCAD) draw only the first copy of
|
||||
//! an array marked `AcDbBlockReference`. A plain INSERT keeps `AcDbBlockReference` (#3127).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Insert, Line, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
fn drawing(refs: &[Insert]) -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut block = Block::new("MP");
|
||||
block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.5, 5.0, 0.0) })));
|
||||
d.blocks.insert(block.name.clone(), Arc::new(block));
|
||||
for r in refs {
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(r.clone())).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The subclass markers (group 100) of each INSERT record in the DXF text, in file order.
|
||||
fn insert_markers(text: &str) -> Vec<Vec<String>> {
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let pairs: Vec<(&str, &str)> = lines.chunks(2).filter_map(|c| Some((*c.first()?, *c.get(1)?))).collect();
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Vec<String>> = None;
|
||||
for (code, value) in pairs {
|
||||
if code == "0" {
|
||||
out.extend(cur.take());
|
||||
if value == "INSERT" {
|
||||
cur = Some(Vec::new());
|
||||
}
|
||||
} else if code == "100"
|
||||
&& let Some(m) = cur.as_mut()
|
||||
{
|
||||
m.push(value.to_string());
|
||||
}
|
||||
}
|
||||
out.extend(cur);
|
||||
out
|
||||
}
|
||||
|
||||
fn inserts(d: &Drawing) -> Vec<Insert> {
|
||||
d.model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minsert_is_written_with_the_minsert_marker() {
|
||||
let columns = Insert { block: "MP".into(), cols: 6, col_spacing: 2.5, ..Insert::default() };
|
||||
let rows = Insert { block: "MP".into(), insert: Vec3::new(0.0, 20.0, 0.0), rows: 3, row_spacing: 5.0, ..Insert::default() };
|
||||
let text = cadcraft_io::write_dxf(&drawing(&[columns, rows]));
|
||||
let markers = insert_markers(&text);
|
||||
assert_eq!(markers.len(), 2, "{markers:?}");
|
||||
for m in &markers {
|
||||
assert_eq!(m, &["AcDbEntity", "AcDbMInsertBlock"], "an arrayed reference is an AcDbMInsertBlock");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_insert_keeps_the_block_reference_marker() {
|
||||
let plain = Insert { block: "MP".into(), insert: Vec3::new(30.0, 0.0, 0.0), ..Insert::default() };
|
||||
let text = cadcraft_io::write_dxf(&drawing(&[plain]));
|
||||
assert_eq!(insert_markers(&text), [["AcDbEntity", "AcDbBlockReference"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minsert_array_reads_back_from_its_own_dxf() {
|
||||
let refs = [
|
||||
Insert { block: "MP".into(), cols: 6, col_spacing: 2.5, ..Insert::default() },
|
||||
Insert { block: "MP".into(), insert: Vec3::new(0.0, 20.0, 0.0), cols: 2, rows: 3, col_spacing: 4.0, row_spacing: 5.0, ..Insert::default() },
|
||||
Insert { block: "MP".into(), insert: Vec3::new(30.0, 0.0, 0.0), ..Insert::default() },
|
||||
];
|
||||
let text = cadcraft_io::write_dxf(&drawing(&refs));
|
||||
let back = inserts(&cadcraft_io::read_dxf(text.as_bytes()).unwrap());
|
||||
let got: Vec<_> = back.iter().map(|i| (i.block.as_str(), i.insert, i.cols, i.rows, i.col_spacing, i.row_spacing)).collect();
|
||||
let want: Vec<_> = refs.iter().map(|i| (i.block.as_str(), i.insert, i.cols, i.rows, i.col_spacing, i.row_spacing)).collect();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ fn model_layout_plots_extents_fitted_and_layouts_their_sheet() {
|
||||
fn model_layout_plot_type_survives_the_dwg_bridge() {
|
||||
let dwg = cadcraft_io::write(&drawing(), "x.dwg").unwrap();
|
||||
// What the DWG holds, as the bridge's own DXF.
|
||||
let dxf = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).to_string();
|
||||
let dxf = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).to_string();
|
||||
let p = plots(&dxf);
|
||||
let model = p.iter().find(|(n, _, _)| n.eq_ignore_ascii_case("Model")).unwrap();
|
||||
assert_eq!(model.1, Some(1), "{p:?}");
|
||||
|
||||
@@ -82,7 +82,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
let mut d = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let bytes = write_with(&d, "carets.dwg", &SaveOptions { version: Some(version), binary: false })?;
|
||||
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()));
|
||||
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()));
|
||||
// Inspect the stored DWG text before its DXF rendition: a compensating reader
|
||||
// must not hide corruption on save. This uses the same codec, not an external oracle.
|
||||
let native = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read()?;
|
||||
@@ -93,7 +93,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
});
|
||||
assert_eq!(text, Some(contents), "native DWG {version:?}, pass={pass}, {handle:?}");
|
||||
}
|
||||
let dxf = cadcraft_dwg::dwg_to_dxf(&bytes)?;
|
||||
let dxf = craft_dwg::dwg_to_dxf(&bytes)?;
|
||||
let converted = read(&dxf, "bridge.dxf")?;
|
||||
assert_contents(&converted, &expected, &format!("DWG bridge DXF {version:?}, pass={pass}"))?;
|
||||
d = read(&bytes, "carets.dwg")?;
|
||||
@@ -112,7 +112,7 @@ fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult {
|
||||
let native = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf.into_bytes()))?.read()?;
|
||||
assert!(native.entities().any(|e| matches!(e, acadrust::EntityType::MText(t) if t.value == "^ C")));
|
||||
let dwg = write_with(&d, "carets.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })?;
|
||||
let returned = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg)?)?;
|
||||
let returned = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg)?)?;
|
||||
assert!(returned.contains(" 1\r\n^ C\r\n"), "the return bridge must encode the separator independently of authored whitespace");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ fn dwg_keeps_ole_objects() {
|
||||
assert_eq!(ole(&d), 1);
|
||||
|
||||
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
|
||||
let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let back = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let got = frames(&back);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_kept(&got[0]);
|
||||
@@ -99,14 +99,14 @@ fn dwg_keeps_ole_objects() {
|
||||
let again = cadcraft_io::read(&dwg, "x.dwg").unwrap();
|
||||
assert_eq!(ole(&again), 1);
|
||||
let dwg = cadcraft_io::write(&again, "x.dwg").unwrap();
|
||||
let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
|
||||
let got = frames(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_kept(&got[0]);
|
||||
|
||||
// Older DWG versions keep it the same way.
|
||||
for version in [cadcraft_io::Version::R2000, cadcraft_io::Version::R2010] {
|
||||
let dwg = cadcraft_io::write_with(&d, "x.dwg", &cadcraft_io::SaveOptions { version: Some(version), binary: false }).unwrap();
|
||||
let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
|
||||
let got = frames(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
|
||||
assert_eq!(got.len(), 1, "{version:?}: {got:?}");
|
||||
assert_kept(&got[0]);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ fn check_version_using(case: &str, original: &Drawing, expected: &Drawing, versi
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · DWG {version} · pass={pass}");
|
||||
let bytes = write_with(¤t, "memory.dwg", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
let back = read(&bytes, "memory.dwg").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes);
|
||||
assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records);
|
||||
|
||||
@@ -82,7 +82,7 @@ pub(super) fn check(path: &str, version: Version, binary: bool) {
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
dxf_owner(&bytes);
|
||||
} else {
|
||||
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
}
|
||||
let back = read(&bytes, path).unwrap();
|
||||
preserved(&original, &back, &context);
|
||||
|
||||
@@ -107,8 +107,8 @@ fn the_block_draw_order_survives_dxf_and_dwg() {
|
||||
/// The table itself (not CADCraft's reordered records) goes through the DWG bridge (#2410).
|
||||
#[test]
|
||||
fn a_partial_block_table_survives_the_dwg_bridge() {
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap();
|
||||
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap();
|
||||
let dxf = craft_dwg::dwg_to_dxf(&dwg).unwrap();
|
||||
let d = cadcraft_io::read_dxf(&dxf).unwrap();
|
||||
assert_eq!(block_kinds(&d), ["wipeout", "circle", "hline", "vline"]);
|
||||
}
|
||||
@@ -120,8 +120,8 @@ fn a_zero_sort_handle_draws_last() {
|
||||
let dxf = block_dxf_with("331\n25B\n5\n0\n331\n25D\n5\n25E\n331\n25E\n5\n25B\n");
|
||||
let want = ["wipeout", "circle", "vline", "hline"];
|
||||
assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF");
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let back = cadcraft_io::read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
assert_eq!(block_kinds(&back), want, "DWG");
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ fn two_unlisted_block_entities_each_sort_by_their_own_handle() {
|
||||
let dxf = block_dxf_with("331\n25B\n5\n25B\n331\n25D\n5\n25D\n");
|
||||
let want = ["hline", "circle", "vline", "wipeout"];
|
||||
assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF");
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let back = cadcraft_io::read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
assert_eq!(block_kinds(&back), want, "DWG");
|
||||
}
|
||||
|
||||
@@ -170,3 +170,10 @@ fn the_pixels_never_reach_the_drawing_file() {
|
||||
assert!(bare.rasters.is_empty() && d.rasters.len() == 1);
|
||||
assert_eq!(bare, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_narrow_bmp_uses_cadcrafts_pixel_limit() {
|
||||
let mut file = std::io::Cursor::new(Vec::new());
|
||||
image::write_buffer_with_format(&mut file, &vec![0; 60_000 * 3], 1, 60_000, image::ExtendedColorType::Rgb8, image::ImageFormat::Bmp).unwrap();
|
||||
assert_eq!(probe(&file.into_inner()).unwrap(), (1, 60_000));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ fn check(bytes: &[u8], name: &str) -> cadcraft_doc::Drawing {
|
||||
})
|
||||
.unwrap();
|
||||
assert!(unknown.tags.iter().any(|t| t.code == 340 && t.value == line.hex()));
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(bytes).unwrap() } else { bytes.to_vec() };
|
||||
let dxf = if name.ends_with(".dwg") { craft_dwg::dwg_to_dxf(bytes).unwrap() } else { bytes.to_vec() };
|
||||
let tags = craft_dxf::parse(&dxf).unwrap();
|
||||
let sections = craft_dxf::sections(&tags);
|
||||
let classes = sections.iter().find(|s| s.name == "CLASSES").unwrap();
|
||||
@@ -124,7 +124,7 @@ fn duplicate_names_keep_distinct_classes_for_entities_and_dictionary_objects() {
|
||||
for _ in 0..3 {
|
||||
for name in ["copy.dxf", "copy.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(&bytes).unwrap() } else { bytes.clone() };
|
||||
let dxf = if name.ends_with(".dwg") { craft_dwg::dwg_to_dxf(&bytes).unwrap() } else { bytes.clone() };
|
||||
let tags = craft_dxf::parse(&dxf).unwrap();
|
||||
let sections = craft_dxf::sections(&tags);
|
||||
let classes = craft_dxf::records(§ions.iter().find(|s| s.name == "CLASSES").unwrap().tags);
|
||||
@@ -197,7 +197,7 @@ fn unused_malformed_classes_do_not_block_plain_geometry() {
|
||||
let dxf = format!(
|
||||
"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n{name}\n2\n{cpp}\n3\nSynthetic\n90\n{flags}\n91\n{count}\n281\n1\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n100\nAcDbEntity\n8\n0\n100\nAcDbLine\n10\n2\n20\n3\n11\n12\n21\n13\n0\nENDSEC\n0\nEOF\n"
|
||||
);
|
||||
let bytes = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1032").unwrap();
|
||||
let bytes = craft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1032").unwrap();
|
||||
let drawing = cadcraft_io::read(&bytes, "plain.dwg").unwrap();
|
||||
let lines: Vec<_> = drawing.model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l) } else { None }).collect();
|
||||
assert_eq!(lines.len(), 1);
|
||||
@@ -219,7 +219,7 @@ fn proxy_spelling_is_preserved_in_dxf_and_normalized_only_for_dwg() {
|
||||
let tags = craft_dxf::parse(&out).unwrap();
|
||||
assert!(tags.iter().any(|t| t.code == 0 && t.str() == entity));
|
||||
let bytes = cadcraft_io::write_with(&drawing, "copy.dwg", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let decoded = cadcraft_dwg::dwg_to_dxf(&bytes).unwrap();
|
||||
let decoded = craft_dwg::dwg_to_dxf(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&decoded).unwrap();
|
||||
let proxies: Vec<_> =
|
||||
craft_dxf::sections(&tags).iter().flat_map(|s| craft_dxf::records(&s.tags)).filter(|(kind, _)| kind.starts_with("ACAD_PROXY_")).collect();
|
||||
@@ -283,7 +283,7 @@ fn ambiguous_fallback_classes_are_reported_without_losing_plain_geometry() {
|
||||
source = source.replace("1\nCUSTOM\n2\nSecond", "1\n\\U+0043USTOM\n2\nSecond");
|
||||
source = source.replace("1\nCUSTOM\n2\nObject", "1\nOTHER\n2\nObject");
|
||||
}
|
||||
let (bytes, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(source.as_bytes(), "AC1032").unwrap();
|
||||
let (bytes, unwritten) = craft_dwg::dxf_to_dwg_noted(source.as_bytes(), "AC1032").unwrap();
|
||||
assert_eq!(unwritten.get("ACAD_PROXY_ENTITY"), Some(&2));
|
||||
assert_eq!(unwritten.get("ACAD_PROXY_OBJECT"), if escaped { None } else { Some(&1) });
|
||||
let drawing = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
craft-image-codecs = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
lyon_tessellation = { workspace = true }
|
||||
|
||||
@@ -354,7 +354,52 @@ fn premultiplied(r: &cadcraft_doc::Raster) -> Option<Pixmap> {
|
||||
Pixmap::from_vec(data, tiny_skia::IntSize::from_wh(r.width(), r.height())?)
|
||||
}
|
||||
|
||||
/// Write straight-alpha RGBA8 pixels as PNG a row at a time.
|
||||
pub fn write_rgba_png(mut output: impl std::io::Write, width: u32, height: u32, pixels: &[u8]) -> Option<()> {
|
||||
use craft_image_codecs::{ChannelLayout, EncodeOptions, Image, PngRows};
|
||||
let stride = usize::try_from(width).ok()?.checked_mul(4).filter(|n| *n > 0)?;
|
||||
let expected = stride.checked_mul(usize::try_from(height).ok()?)?;
|
||||
if height == 0 || pixels.len() != expected {
|
||||
return None;
|
||||
}
|
||||
let mut rows = pixels.chunks_exact(stride);
|
||||
let row = |data: &[u8]| Image::from_u8(width, 1, ChannelLayout::Rgba, data.to_vec()).ok();
|
||||
let first = row(rows.next()?)?;
|
||||
let mut encoder = PngRows::new(&mut output, &first, height, &EncodeOptions::default()).ok()?;
|
||||
encoder.push(&first).ok()?;
|
||||
for data in rows {
|
||||
encoder.push(&row(data)?).ok()?;
|
||||
}
|
||||
encoder.finish().ok()
|
||||
}
|
||||
|
||||
/// Encode a premultiplied pixmap as PNG without a second full-frame copy.
|
||||
pub fn encode_png(pixmap: &Pixmap) -> Option<Vec<u8>> {
|
||||
use craft_image_codecs::{ChannelLayout, EncodeOptions, Image, PngRows};
|
||||
let width = usize::try_from(pixmap.width()).ok()?.max(1);
|
||||
let mut rows = pixmap.pixels().chunks_exact(width);
|
||||
let make_row = |row: &[tiny_skia::PremultipliedColorU8]| {
|
||||
let rgba = row
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let c = p.demultiply();
|
||||
[c.red(), c.green(), c.blue(), c.alpha()]
|
||||
})
|
||||
.collect();
|
||||
Image::from_u8(pixmap.width(), 1, ChannelLayout::Rgba, rgba).ok()
|
||||
};
|
||||
let first = make_row(rows.next()?)?;
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = PngRows::new(&mut out, &first, pixmap.height(), &EncodeOptions::default()).ok()?;
|
||||
encoder.push(&first).ok()?;
|
||||
for row in rows {
|
||||
encoder.push(&make_row(row)?).ok()?;
|
||||
}
|
||||
encoder.finish().ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Render and encode as PNG.
|
||||
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
|
||||
render(list, view, o)?.encode_png().ok()
|
||||
encode_png(&render(list, view, o)?)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//! The shared PNG writer preserves tiny-skia's straight-alpha export pixels.
|
||||
#[test]
|
||||
fn shared_png_matches_tiny_skia_for_premultiplied_pixels() {
|
||||
let pixmap =
|
||||
tiny_skia::Pixmap::from_vec(vec![0, 0, 0, 0, 32, 64, 96, 128, 1, 2, 3, 4, 255, 63, 9, 255], tiny_skia::IntSize::from_wh(4, 1).unwrap())
|
||||
.unwrap();
|
||||
let expected = tiny_skia::Pixmap::decode_png(&pixmap.encode_png().unwrap()).unwrap();
|
||||
let actual = tiny_skia::Pixmap::decode_png(&cadcraft_render::raster::encode_png(&pixmap).unwrap()).unwrap();
|
||||
assert_eq!(actual.data(), expected.data());
|
||||
}
|
||||
@@ -16,6 +16,8 @@ cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
cadcraft-engine = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
craft-image-codecs = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
craft-window = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
|
||||
@@ -402,13 +402,21 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The alias store is process-wide. A test of this crate that changes it holds this guard while
|
||||
/// it does, so such tests take turns.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_alias_store() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::MutexGuard;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
@@ -419,8 +427,7 @@ mod tests {
|
||||
|
||||
/// The alias store is process-wide, so the tests that load files take turns and clear it.
|
||||
fn alone() -> MutexGuard<'static, ()> {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
super::lock_alias_store()
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
|
||||
@@ -96,8 +96,9 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
return None;
|
||||
}
|
||||
let st = app.session.state().ok()?;
|
||||
let refs = st.selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count();
|
||||
if refs == 1 && st.selection.len() == 1 {
|
||||
let selection = st.shown_selection();
|
||||
let refs = selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count();
|
||||
if refs == 1 && selection.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
// Start on the block last edited if it is still there, else the first one.
|
||||
|
||||
@@ -176,6 +176,9 @@ pub struct CanvasState {
|
||||
pub build_ms: f64,
|
||||
pub draw_ms: f64,
|
||||
hover_at: Option<Pos2>,
|
||||
/// The layers frozen in the active viewport when the object and the cycling badge under the
|
||||
/// cursor were last worked out ([`cadcraft_engine::DocState::viewport_frozen`]).
|
||||
hover_frozen: Vec<String>,
|
||||
/// Constraint glyphs for the parametric overlay (cached per drawing revision).
|
||||
pub param: crate::parametric::Cache,
|
||||
/// A selection window opened by a press-and-drag, to close where the button is let go.
|
||||
@@ -695,12 +698,22 @@ fn update_hover(app: &mut CadApp, ui: &egui::Ui, inside: bool, hover_pos: Option
|
||||
// The cursor picks objects (pick box, rollover highlight): "Select objects" and single-object
|
||||
// prompts, or no command with PICKFIRST on; SELECTIONPREVIEW says in which of those it shows.
|
||||
let selecting = app.session.previews_selection();
|
||||
// What the cursor is over also depends on the layers frozen in the active viewport, and those
|
||||
// can change while the pointer rests (VPLAYER, undo, another viewport made the active one).
|
||||
let refrozen = match app.session.state() {
|
||||
Ok(st) if st.viewport_frozen() != app.canvas.hover_frozen.as_slice() => {
|
||||
app.canvas.hover_frozen = st.viewport_frozen().to_vec();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if inside && selecting && app.session.pending_window.is_none() {
|
||||
if hover_pos != app.canvas.hover_at
|
||||
if (hover_pos != app.canvas.hover_at || refrozen)
|
||||
&& let (Some(w), Ok(st)) = (raw_world, app.session.state())
|
||||
{
|
||||
let filter = app.session.settings.previewfilter;
|
||||
app.canvas.hover = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, app.session.settings.pickbox.max(1.0) * 1.5 / scale)
|
||||
let ap = app.session.settings.pickbox.max(1.0) * 1.5 / scale;
|
||||
app.canvas.hover = cadcraft_engine::select::pick_in(&st.doc, &st.edit_space(), st.viewport_frozen(), w, ap)
|
||||
.filter(|h| st.doc.entity(*h).is_none_or(|e| !cadcraft_engine::select::preview_filtered(&st.doc, e, filter)));
|
||||
app.canvas.hover_at = hover_pos;
|
||||
}
|
||||
@@ -717,7 +730,7 @@ fn update_hover(app: &mut CadApp, ui: &egui::Ui, inside: bool, hover_pos: Option
|
||||
app.canvas.hover_at = None;
|
||||
}
|
||||
shift_space_cycle(app, ui, hover_pos.filter(|_| inside), raw_world);
|
||||
if inside && hover_pos != app.canvas.cycle_badge_at {
|
||||
if inside && (hover_pos != app.canvas.cycle_badge_at || refrozen) {
|
||||
app.canvas.cycle_badge = raw_world.is_some_and(|w| app.session.cycling_badge(w));
|
||||
app.canvas.cycle_badge_at = hover_pos;
|
||||
} else if !inside {
|
||||
@@ -1103,7 +1116,9 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
|
||||
{
|
||||
let gs = app.session.settings.gripsize as f32;
|
||||
for h in sel.iter().take(200) {
|
||||
if let Some(e) = st.doc.entity(*h) {
|
||||
if let Some(e) = st.doc.entity(*h)
|
||||
&& st.shows(e)
|
||||
{
|
||||
let grips = e.kind.grips();
|
||||
let count = grips.len();
|
||||
for (gi, g) in grips.into_iter().enumerate() {
|
||||
|
||||
@@ -3,6 +3,11 @@ use super::{CadApp, Handle, Input, Pos2, Rect, Xf, vec2};
|
||||
/// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts
|
||||
/// editing it, with the other hot grips when it is one of them.
|
||||
pub(super) fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) {
|
||||
// A hot grip whose object was hidden since (a layer turned off, HIDEOBJECTS, a layer frozen
|
||||
// in the active viewport) is no longer painted, so it must not be stretched along with the
|
||||
// visible ones.
|
||||
let Ok(st) = app.session.state() else { return };
|
||||
app.canvas.hot_grips.retain(|(h, _)| st.doc.entity(*h).is_some_and(|e| st.shows(e)));
|
||||
let hot = &mut app.canvas.hot_grips;
|
||||
if shift {
|
||||
match hot.iter().position(|x| *x == g) {
|
||||
@@ -85,7 +90,7 @@ pub(super) fn grip_at(app: &CadApp, xf: &Xf, hover: Option<Pos2>) -> Option<(Han
|
||||
}
|
||||
for h in sel.iter().take(200) {
|
||||
let e = st.doc.entity(*h)?;
|
||||
if st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
if !st.shows(e) || st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
continue;
|
||||
}
|
||||
let grips = e.kind.grips();
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(super) fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool {
|
||||
pub(super) fn hatch_at(app: &CadApp, w: Vec2, scale: f64) -> Option<Handle> {
|
||||
let st = app.session.state().ok()?;
|
||||
let aperture = app.session.settings.pickbox.max(1.0) * 1.5 / scale.max(1e-12);
|
||||
let h = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, aperture)?;
|
||||
let h = cadcraft_engine::select::pick_in(&st.doc, &st.edit_space(), st.viewport_frozen(), w, aperture)?;
|
||||
matches!(st.doc.entity(h)?.kind, cadcraft_doc::EntityKind::Hatch(_)).then_some(h)
|
||||
}
|
||||
|
||||
|
||||
@@ -230,3 +230,168 @@ fn dynamic_input_distance_follows_the_drawing_units() {
|
||||
let text = painted_text(&mut app, &ctx, at);
|
||||
assert!(text.contains(&format!(" {} <", arch(c.len()))), "no {} in:\n{text}", arch(c.len()));
|
||||
}
|
||||
|
||||
/// A hot grip whose object was hidden since (HIDEOBJECTS, a layer turned off) is no longer
|
||||
/// painted, so clicking another hot grip must not stretch the hidden object along with it.
|
||||
#[test]
|
||||
fn clicking_a_hot_grip_leaves_hidden_objects_out_of_the_stretch() {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
for y in [0, 30] {
|
||||
app.run("line", serde_json::json!({ "points": [[0, y], [100, y]] })).unwrap();
|
||||
}
|
||||
let handles = app.session.doc().unwrap().model.handles();
|
||||
let (shown, hidden) = (handles[0], handles[1]);
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
|
||||
app.run("hideobjects", serde_json::json!({ "handles": [hidden.hex()] })).unwrap();
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
click_grip(&mut app, (shown, 0), false);
|
||||
assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the hidden object's grip must leave the hot set");
|
||||
}
|
||||
|
||||
/// Makes the layer "Hidden" and, on Layout1, two viewports over the model: the first freezes
|
||||
/// "Hidden", the second freezes nothing. The app is left in paper space.
|
||||
fn viewports_freezing_hidden(app: &mut CadApp) -> (String, String) {
|
||||
app.session.execute("layer.new", &serde_json::json!({ "name": "Hidden" })).unwrap();
|
||||
app.session.execute("layout.set", &serde_json::json!({ "name": "Layout1" })).unwrap();
|
||||
let made = app.session.execute("mview", &serde_json::json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
|
||||
let (a, b) = (viewport(0), viewport(1));
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// An app with two model-space lines, the second on the layer "Hidden", and the two viewports of
|
||||
/// [`viewports_freezing_hidden`]: (app, the first line, the second line, the viewport that
|
||||
/// freezes "Hidden", the viewport that freezes nothing).
|
||||
fn lines_in_two_viewports() -> (CadApp, Handle, Handle, String, String) {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
for y in [0, 30] {
|
||||
app.run("line", serde_json::json!({ "points": [[0, y], [100, y]] })).unwrap();
|
||||
}
|
||||
let handles = app.session.doc().unwrap().model.handles();
|
||||
let (shown, hidden) = (handles[0], handles[1]);
|
||||
let (a, b) = viewports_freezing_hidden(&mut app);
|
||||
app.session.execute("properties.set", &serde_json::json!({ "handles": [hidden.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().entity(hidden).unwrap().common.layer, "Hidden");
|
||||
(app, shown, hidden, a, b)
|
||||
}
|
||||
|
||||
/// A hot grip whose object is on a layer frozen in the active viewport is not painted there, so
|
||||
/// clicking another hot grip must not stretch that object along with it. Through a viewport that
|
||||
/// draws the layer both grips stay hot.
|
||||
#[test]
|
||||
fn clicking_a_hot_grip_leaves_out_objects_the_viewport_does_not_draw() {
|
||||
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
|
||||
for (viewport, hot) in [(&a, vec![(shown, 0)]), (&b, vec![(shown, 0), (hidden, 0)])] {
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": viewport })).unwrap();
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
|
||||
click_grip(&mut app, (shown, 0), false);
|
||||
assert_eq!(app.canvas.hot_grips, hot);
|
||||
app.session.cancel();
|
||||
}
|
||||
// Frozen in the active viewport while both grips are hot.
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
click_grip(&mut app, (shown, 0), false);
|
||||
assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the grip of the line the viewport no longer draws must leave the hot set");
|
||||
}
|
||||
|
||||
/// The cursor finds no grip of a selected object the viewport does not draw; it finds the grips
|
||||
/// of the other selected object there, and both through a viewport that draws both.
|
||||
#[test]
|
||||
fn the_cursor_finds_no_grip_of_an_object_the_viewport_does_not_draw() {
|
||||
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
|
||||
let xf = Xf { rect, center: Vec2::new(50.0, 15.0), scale: 2.0, twist: 0.0 };
|
||||
// The start of each line, where its first grip is.
|
||||
let (on_shown, on_hidden) = (Some(xf.to_screen(Vec2::new(0.0, 0.0))), Some(xf.to_screen(Vec2::new(0.0, 30.0))));
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(grip_at(&app, &xf, on_shown), Some((shown, 0)));
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), None, "a grip of a line the viewport does not draw was hit");
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), Some((hidden, 0)));
|
||||
// Frozen in the active viewport while the line is selected, then thawed again.
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [shown, hidden], "both lines must still be selected");
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), None);
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), Some((hidden, 0)));
|
||||
}
|
||||
|
||||
/// The hatch under the cursor, which a double-click edits, is one the viewport draws.
|
||||
#[test]
|
||||
fn the_cursor_finds_no_hatch_the_viewport_does_not_draw() {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
let handle = |v: &serde_json::Value| Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap();
|
||||
let square = app.session.execute("pline", &serde_json::json!({ "vertices": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true })).unwrap();
|
||||
let hatch = handle(&app.session.execute("hatch", &serde_json::json!({ "handles": [handle(&square).hex()], "pattern": "SOLID" })).unwrap());
|
||||
let (a, b) = viewports_freezing_hidden(&mut app);
|
||||
app.session.execute("properties.set", &serde_json::json!({ "handles": [hatch.hex()], "layer": "Hidden" })).unwrap();
|
||||
let inside = Vec2::new(10.0, 10.0);
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
|
||||
assert_eq!(hatch_at(&app, inside, 1.0), None, "a hatch the viewport does not draw was found");
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
assert_eq!(hatch_at(&app, inside, 1.0), Some(hatch));
|
||||
}
|
||||
|
||||
/// The highlight of the selection inside the active viewport leaves out a selected object on a
|
||||
/// layer frozen in that viewport, also when the layer is frozen while the object is selected,
|
||||
/// and is built again for another viewport although the selection and the drawing are the same.
|
||||
#[test]
|
||||
fn the_viewport_highlight_leaves_out_objects_the_viewport_does_not_draw() {
|
||||
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
|
||||
/// The objects `highlight` outlines for the selection of both lines.
|
||||
fn outlined(app: &CadApp, highlight: &mut VpHighlight, both: [Handle; 2]) -> Vec<Handle> {
|
||||
highlight.update(app.session.state().unwrap(), &both, 10.0);
|
||||
highlight.list.prims.iter().map(|p| p.handle).collect::<std::collections::BTreeSet<_>>().into_iter().collect()
|
||||
}
|
||||
let both = [shown, hidden];
|
||||
let mut highlight = VpHighlight::default();
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), both, "this viewport draws both lines");
|
||||
let revision = app.session.state().unwrap().revision;
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().revision, revision, "entering a viewport must not look like a change of the drawing");
|
||||
assert_eq!(outlined(&app, &mut highlight, both), [shown], "a line the viewport does not draw was outlined");
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), both, "the list built for the other viewport was reused");
|
||||
// Frozen in the active viewport while the line is selected, then thawed again.
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), [shown]);
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), both);
|
||||
}
|
||||
|
||||
/// Selection cycling's badge beside a resting pointer follows the layers frozen in the active
|
||||
/// viewport: over two overlapping lines it shows while the viewport draws both, goes when the
|
||||
/// layer of one of them is frozen there, and comes back when the layer is thawed.
|
||||
#[test]
|
||||
fn the_cycling_badge_follows_the_frozen_layers_under_a_resting_pointer() {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
for _ in 0..2 {
|
||||
app.run("line", serde_json::json!({ "points": [[0, 0], [100, 0]] })).unwrap();
|
||||
}
|
||||
let on_top = app.session.doc().unwrap().model.handles()[1];
|
||||
let (_, b) = viewports_freezing_hidden(&mut app);
|
||||
app.session.execute("properties.set", &serde_json::json!({ "handles": [on_top.hex()], "layer": "Hidden" })).unwrap();
|
||||
app.session.execute("setvar", &serde_json::json!({ "name": "SELECTIONCYCLING", "value": 1 })).unwrap();
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
/// Whether the badge shows after a frame with the pointer resting on the two lines.
|
||||
fn badge(app: &mut CadApp, ctx: &egui::Context) -> bool {
|
||||
let (at, on_lines) = (Some(Pos2::new(200.0, 150.0)), Some(Vec2::new(50.0, 0.0)));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| update_hover(app, ui, true, at, on_lines, 2.0));
|
||||
output.textures_delta.clear();
|
||||
app.canvas.cycle_badge
|
||||
}
|
||||
assert!(badge(&mut app, &ctx), "this viewport draws both lines: a click would choose between them");
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert!(!badge(&mut app, &ctx), "the badge stayed for a line the viewport no longer draws");
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert!(badge(&mut app, &ctx), "thawed in the viewport: two lines are under the cursor again");
|
||||
}
|
||||
|
||||
@@ -271,7 +271,9 @@ pub(super) fn viewport_highlight_tolerance(scale: f64) -> f64 {
|
||||
/// The selected or the hovered objects tessellated in model units for the active MSPACE
|
||||
/// viewport, whose contents can't be looked up by handle in the layout's lists. The selection and
|
||||
/// the hover are kept apart, so moving the cursor over objects doesn't tessellate the selection
|
||||
/// again; each is rebuilt only when the drawing, the objects or the zoom band change.
|
||||
/// again; each is rebuilt only when the drawing, the objects, the zoom band or the layers frozen
|
||||
/// in the viewport change. An object on one of those layers is not drawn in the viewport, so it
|
||||
/// is not highlighted there either.
|
||||
#[derive(Default)]
|
||||
pub(super) struct VpHighlight {
|
||||
key: Option<u64>,
|
||||
@@ -286,12 +288,14 @@ impl VpHighlight {
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, super::list::font_generation())
|
||||
// Entering another viewport changes what is drawn without changing the drawing.
|
||||
let frozen = st.viewport_frozen();
|
||||
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, frozen, super::list::font_generation())
|
||||
.hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
if self.key != Some(key) {
|
||||
let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref());
|
||||
let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).filter(|e| !st.hidden_in_viewport(e)).map(|e| e.as_ref());
|
||||
self.list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() });
|
||||
self.key = Some(key);
|
||||
self.list_gen = self.list_gen.wrapping_add(1);
|
||||
|
||||
@@ -489,4 +489,25 @@ mod tests {
|
||||
assert!(app.closing.is_none());
|
||||
assert!(app.session.docs.is_empty());
|
||||
}
|
||||
|
||||
/// CLOSE typed with the Greek keyboard layout active (ΨΛΟΣΕ) is the interface's CLOSE, which
|
||||
/// asks about unsaved changes, not the engine's, which closes at once. A Greek word that is a
|
||||
/// command of its own stays that command.
|
||||
#[test]
|
||||
fn close_typed_with_the_greek_layout_asks_before_it_closes() {
|
||||
let mut app = app_with(&["A", "B"]);
|
||||
dirty(&mut app, 1);
|
||||
app.cmdline("ΨΛΟΣΕ");
|
||||
assert_eq!(app.close_prompt(), Some("B"));
|
||||
assert_eq!(titles(&app), ["A", "B"]);
|
||||
app.answer_close(Choice::Cancel);
|
||||
assert!(app.closing.is_none());
|
||||
|
||||
// The drawing's LISP defines a command of that Greek name: typing it runs that command.
|
||||
app.cmdline("(defun c:ΨΛΟΣΕ () (princ))");
|
||||
app.cmdline("ΨΛΟΣΕ");
|
||||
assert!(app.closing.is_none());
|
||||
assert_eq!(titles(&app), ["A", "B"]);
|
||||
assert!(app.session.docs[1].is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ fn tab(app: &mut CadApp) {
|
||||
{
|
||||
// Both values typed: Tab locks the second one as well.
|
||||
app.cmd.locked_second = Some(app.cmd.buffer.clone());
|
||||
} else if let Some(s) = suggestions(&app.cmd.buffer).first() {
|
||||
} else if let Some(s) = suggestions_in(&app.session, &app.cmd.buffer).first() {
|
||||
app.cmd.buffer = s.id.clone();
|
||||
}
|
||||
}
|
||||
@@ -118,12 +118,26 @@ pub struct Suggestion {
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
/// Matching command names/aliases for AutoComplete.
|
||||
fn suggestions(prefix: &str) -> Vec<Suggestion> {
|
||||
/// [`suggestions`] for what `session` can run: a Greek word that is a command of its own there (an
|
||||
/// alias, a command the drawing's LISP defines) stays as typed.
|
||||
fn suggestions_in(session: &cadcraft_engine::Session, prefix: &str) -> Vec<Suggestion> {
|
||||
suggestions(prefix, &|word| session.greek_word_is_a_command(word))
|
||||
}
|
||||
|
||||
/// Matching command names/aliases for AutoComplete. `is_own_command` tells whether a Greek-layout
|
||||
/// word is a command of its own rather than the Latin name its keys type.
|
||||
fn suggestions(prefix: &str, is_own_command: &dyn Fn(&str) -> bool) -> Vec<Suggestion> {
|
||||
if prefix.is_empty() || prefix.contains(' ') {
|
||||
return Vec::new();
|
||||
}
|
||||
let p = prefix.to_ascii_lowercase();
|
||||
// Typed with the Greek keyboard layout (ΛΙΝ), it completes as the Latin name (LINE), unless the word
|
||||
// as typed already names a command, alias or LISP command: that is what Enter runs.
|
||||
let typed = prefix.to_ascii_lowercase();
|
||||
let p = if cadcraft_engine::cmd::find_command(&cadcraft_engine::cmd::resolve_alias(&typed)).is_some() {
|
||||
typed
|
||||
} else {
|
||||
cadcraft_engine::keyboard::latin_keys(prefix).filter(|_| !is_own_command(prefix)).unwrap_or(typed)
|
||||
};
|
||||
|
||||
let mut out: Vec<Suggestion> = Vec::new();
|
||||
let mut seen_ids: HashSet<&'static str> = HashSet::new();
|
||||
@@ -444,7 +458,7 @@ pub fn submit(app: &mut CadApp) {
|
||||
return;
|
||||
}
|
||||
// A partly typed command name runs the highlighted (first) AutoComplete suggestion.
|
||||
let completion = if app.session.prompting() { None } else { suggestions(text.trim()).into_iter().next() };
|
||||
let completion = if app.session.prompting() { None } else { suggestions_in(&app.session, text.trim()).into_iter().next() };
|
||||
app.cmdline_or(&text, completion.map(|s| s.id.to_ascii_lowercase()).as_deref());
|
||||
}
|
||||
|
||||
@@ -585,7 +599,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
);
|
||||
// AutoComplete list.
|
||||
if !app.session.prompting() {
|
||||
let sug = suggestions(&app.cmd.buffer);
|
||||
let sug = suggestions_in(&app.session, &app.cmd.buffer);
|
||||
if !sug.is_empty() {
|
||||
let rh = 20.0;
|
||||
let lr = Rect::from_min_size(
|
||||
@@ -647,7 +661,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones {
|
||||
let grip = Rect::from_min_max(pos2(bar.left(), top - 3.0), pos2(bar.right(), top + 3.0));
|
||||
let mut all = bar.union(grip);
|
||||
if !app.session.prompting() {
|
||||
let k = suggestions(&app.cmd.buffer).len();
|
||||
let k = suggestions_in(&app.session, &app.cmd.buffer).len();
|
||||
if k > 0 {
|
||||
let above = if n == 0 { 0.0 } else { LINE_H * n as f32 + 6.0 };
|
||||
all = all.union(Rect::from_min_size(pos2(bar.left(), bar.top() - 6.0 - 20.0 * k as f32 - above), vec2(bar.width(), 1.0)));
|
||||
@@ -757,6 +771,52 @@ mod tests {
|
||||
frame(app, ctx, vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }]);
|
||||
}
|
||||
|
||||
/// A Greek-layout prefix completes like its Latin keys; a prefix in another script is matched
|
||||
/// as it is.
|
||||
#[test]
|
||||
fn autocomplete_reads_a_greek_layout_prefix_as_its_latin_keys() {
|
||||
// ΛΙΝ is the keys L-I-N on the Greek layout: it completes like the Latin prefix does.
|
||||
let latin: Vec<String> = suggestions("lin", &|_| false).into_iter().map(|s| s.id).collect();
|
||||
assert!(latin.iter().any(|id| id == "LINE"), "{latin:?}");
|
||||
let greek: Vec<String> = suggestions("ΛΙΝ", &|_| false).into_iter().map(|s| s.id).collect();
|
||||
assert_eq!(greek, latin);
|
||||
// A prefix that is not Greek-layout typing is matched as it is.
|
||||
assert!(suggestions("ЖЖЖ", &|_| false).is_empty());
|
||||
}
|
||||
|
||||
/// A Greek word the user made an alias is offered as that alias's command, which is what Enter
|
||||
/// runs.
|
||||
#[test]
|
||||
fn autocomplete_offers_the_greek_alias_the_user_defined_that_enter_would_run() {
|
||||
// Enter runs a word that is an alias as typed, so AutoComplete must not read it as Latin keys first:
|
||||
// Ξ is the J key (JOIN's alias), but the user made Ξ an alias of QSAVE. The alias store is
|
||||
// process-wide and the alias dialog's tests clear it, so this test takes its turn with them.
|
||||
let _alone = crate::aliasdlg::lock_alias_store();
|
||||
let mut session = Session::empty();
|
||||
session.execute("aliasedit", &serde_json::json!({ "set": { "Ξ": "qsave" } })).unwrap();
|
||||
let first = suggestions("Ξ", &|_| false).into_iter().next().map(|s| s.id);
|
||||
session.execute("aliasedit", &serde_json::json!({ "remove": ["Ξ"] })).unwrap();
|
||||
assert_eq!(first.as_deref(), Some("QSAVE"));
|
||||
}
|
||||
|
||||
/// A Greek word the drawing's LISP defines as a command is not completed to the Latin name of
|
||||
/// its keys.
|
||||
#[test]
|
||||
fn autocomplete_leaves_a_greek_word_the_drawings_lisp_defines_as_typed() {
|
||||
// ΛΙΝΕ is the keys of LINE, but the drawing defines a command of that Greek name: Enter runs that
|
||||
// command, so Tab must not replace the word with LINE.
|
||||
let mut session = Session::new();
|
||||
session.cmdline("(defun c:ΛΙΝΕ () (princ))").unwrap();
|
||||
for word in ["ΛΙΝΕ", "λινε", "λίνε"] {
|
||||
assert!(suggestions_in(&session, word).is_empty(), "{word}: {:?}", suggestions_in(&session, word));
|
||||
}
|
||||
// Another word of the same keys is still read as Latin: Enter would start LINE for it too.
|
||||
assert!(suggestions_in(&session, "ΛΙΝ").iter().any(|s| s.id == "LINE"));
|
||||
// Without the definition ΛΙΝΕ completes as LINE.
|
||||
let plain = Session::new();
|
||||
assert!(suggestions_in(&plain, "ΛΙΝΕ").iter().any(|s| s.id == "LINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_preedit_is_shown_and_its_commit_is_inserted_once() {
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
@@ -890,7 +950,7 @@ mod autocomplete_tests {
|
||||
|
||||
#[test]
|
||||
fn exact_alias_is_first_suggestion_with_alias_display() {
|
||||
let rec = suggestions("rec");
|
||||
let rec = suggestions("rec", &|_| false);
|
||||
assert!(!rec.is_empty());
|
||||
assert_eq!(rec[0].id, "RECTANG");
|
||||
assert_eq!(rec[0].display, "REC (RECTANG)");
|
||||
@@ -900,26 +960,26 @@ mod autocomplete_tests {
|
||||
assert_eq!(rec[1].id, "RECOVER");
|
||||
assert_eq!(rec[1].display, "RECOVER");
|
||||
|
||||
let l = suggestions("l");
|
||||
let l = suggestions("l", &|_| false);
|
||||
assert_eq!(l[0].id, "LINE");
|
||||
assert_eq!(l[0].display, "L (LINE)");
|
||||
|
||||
let c = suggestions("c");
|
||||
let c = suggestions("c", &|_| false);
|
||||
assert_eq!(c[0].id, "CIRCLE");
|
||||
assert_eq!(c[0].display, "C (CIRCLE)");
|
||||
|
||||
let co = suggestions("co");
|
||||
let co = suggestions("co", &|_| false);
|
||||
assert_eq!(co[0].id, "COPY");
|
||||
assert_eq!(co[0].display, "CO (COPY)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_without_alias_shows_command_id() {
|
||||
let rect = suggestions("rect");
|
||||
let rect = suggestions("rect", &|_| false);
|
||||
assert_eq!(rect[0].id, "RECTANG");
|
||||
assert_eq!(rect[0].display, "RECTANG");
|
||||
|
||||
let line = suggestions("line");
|
||||
let line = suggestions("line", &|_| false);
|
||||
assert_eq!(line[0].id, "LINE");
|
||||
assert_eq!(line[0].display, "LINE");
|
||||
}
|
||||
|
||||
@@ -691,6 +691,29 @@ impl Drop for PathlessShots {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_screenshot_png(img: &image::RgbaImage, output: impl std::io::Write) -> std::io::Result<()> {
|
||||
cadcraft_render::raster::write_rgba_png(output, img.width(), img.height(), img.as_raw())
|
||||
.ok_or_else(|| std::io::Error::other("PNG encoding failed"))
|
||||
}
|
||||
|
||||
fn save_explicit_screenshot(img: &image::RgbaImage, path: &str) -> std::io::Result<()> {
|
||||
use craft_image_codecs::{ChannelLayout, EncodeOptions, Format, Image};
|
||||
let format = match image::ImageFormat::from_path(path).map_err(std::io::Error::other)? {
|
||||
image::ImageFormat::Png => Format::Png,
|
||||
image::ImageFormat::Jpeg => Format::Jpeg,
|
||||
image::ImageFormat::Bmp => Format::Bmp,
|
||||
image::ImageFormat::Tiff => Format::Tiff,
|
||||
_ => return Err(std::io::Error::other("unsupported screenshot format")),
|
||||
};
|
||||
if format == Format::Png {
|
||||
return write_screenshot_png(img, std::fs::File::create(path)?);
|
||||
}
|
||||
let pixels = Image::from_u8(img.width(), img.height(), ChannelLayout::Rgba, img.as_raw().clone()).map_err(std::io::Error::other)?;
|
||||
let opts = EncodeOptions { jpeg_quality: 75, jpeg_chroma_subsampling: false, ..EncodeOptions::default() };
|
||||
let bytes = craft_image_codecs::encode(&pixels, format, &opts).map_err(std::io::Error::other)?;
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
|
||||
/// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG.
|
||||
fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result<std::path::PathBuf> {
|
||||
use std::io::Write as _;
|
||||
@@ -713,7 +736,7 @@ fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Resu
|
||||
let path = dir.join(format!("cadcraft-shot-{token}-{}.png", next_pathless_nonce()));
|
||||
match create_private_file(&path) {
|
||||
Ok(mut file) => {
|
||||
let written = img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other).and_then(|()| file.flush());
|
||||
let written = write_screenshot_png(img, &mut file).and_then(|()| file.flush());
|
||||
if let Err(e) = written {
|
||||
// The reply carries no path on failure, so nobody else could remove the partial file.
|
||||
drop(file);
|
||||
@@ -752,7 +775,7 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64)
|
||||
return inline_screenshot(&img, token);
|
||||
}
|
||||
let saved = match path {
|
||||
Some(path) => img.save(path).map(|()| path.to_string()).map_err(std::io::Error::other),
|
||||
Some(path) => save_explicit_screenshot(&img, path).map(|()| path.to_string()),
|
||||
None => save_pathless_screenshot(&img, token).map(|path| path.to_string_lossy().into_owned()),
|
||||
};
|
||||
match saved {
|
||||
@@ -766,7 +789,7 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64)
|
||||
fn inline_screenshot(img: &image::RgbaImage, token: u64) -> Value {
|
||||
let (w, h) = img.dimensions();
|
||||
let mut png = Vec::new();
|
||||
if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) {
|
||||
if let Err(e) = write_screenshot_png(img, &mut png) {
|
||||
return json!({"ok": false, "error": e.to_string(), "request": token});
|
||||
}
|
||||
json!({"ok": true, "request": token, "result": {"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "bytes": png.len()}})
|
||||
@@ -776,6 +799,25 @@ fn inline_screenshot(img: &image::RgbaImage, token: u64) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_screenshot_retains_supported_file_formats() {
|
||||
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 128]));
|
||||
for ext in ["png", "bmp", "tiff", "jpg"] {
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-format-{}-{}.{}", std::process::id(), next_pathless_nonce(), ext));
|
||||
let name = path.to_string_lossy().to_string();
|
||||
save_explicit_screenshot(&img, &name).unwrap();
|
||||
let decoded = image::open(&path).unwrap().to_rgba8();
|
||||
assert_eq!(decoded.dimensions(), img.dimensions(), "{ext}");
|
||||
if ext != "jpg" {
|
||||
let mut original = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut original, image::ImageFormat::from_path(&path).unwrap()).unwrap();
|
||||
let original = image::load_from_memory(&original.into_inner()).unwrap().to_rgba8();
|
||||
assert_eq!(decoded.as_raw(), original.as_raw(), "{ext}");
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_screenshot_carries_the_png_and_its_size() {
|
||||
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255]));
|
||||
|
||||
@@ -18,10 +18,12 @@ use cadcraft_engine::doc::dynblock::{DynGrip, GripShape};
|
||||
/// Most selected references whose grips are shown.
|
||||
const MAX_REFERENCES: usize = 200;
|
||||
|
||||
/// The dynamic grips of the selection, worked out once per drawing revision and selection.
|
||||
/// The dynamic grips of the selection, worked out once per drawing revision, selection and set
|
||||
/// of layers frozen in the active viewport: entering another viewport changes which references
|
||||
/// are shown without changing the drawing.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Cache {
|
||||
key: (u64, u64, Vec<Handle>),
|
||||
key: (u64, u64, Vec<Handle>, Vec<String>),
|
||||
grips: Arc<Vec<(Handle, DynGrip)>>,
|
||||
}
|
||||
|
||||
@@ -29,12 +31,12 @@ pub struct Cache {
|
||||
pub(crate) fn of_selection(app: &mut CadApp) -> Arc<Vec<(Handle, DynGrip)>> {
|
||||
let sel = app.session.selection();
|
||||
let Ok(st) = app.session.state() else { return Arc::default() };
|
||||
let key = (st.uid, st.revision, sel.clone());
|
||||
let key = (st.uid, st.revision, sel.clone(), st.viewport_frozen().to_vec());
|
||||
if app.canvas.dyn_grips.key != key {
|
||||
let grips: Vec<(Handle, DynGrip)> = sel
|
||||
.iter()
|
||||
.filter(|h| matches!(st.doc.entity(**h).map(|e| &e.kind), Some(cadcraft_engine::doc::EntityKind::Insert(_))))
|
||||
.filter(|h| st.doc.entity(**h).is_some_and(|e| !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
|
||||
.filter(|h| st.doc.entity(**h).is_some_and(|e| st.shows(e) && !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
|
||||
.take(MAX_REFERENCES)
|
||||
.flat_map(|h| cadcraft_engine::doc::dynblock::grips(&st.doc, *h).into_iter().map(move |g| (*h, g)))
|
||||
.collect();
|
||||
@@ -152,3 +154,130 @@ pub(crate) fn menu(app: &mut CadApp, ctx: &egui::Context, xf: &Xf) {
|
||||
app.canvas.dyn_menu = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Handle, Space};
|
||||
use serde_json::json;
|
||||
|
||||
use super::of_selection;
|
||||
use crate::{CadApp, Services};
|
||||
|
||||
/// DXF text from lines of `code value`.
|
||||
fn dxf(src: &str) -> String {
|
||||
src.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| {
|
||||
let (code, value) = l.split_once(' ').unwrap_or((l, ""));
|
||||
format!("{code}\n{value}\n")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A session with the synthetic DOOR block of the io tests (Width's end grip shown in the
|
||||
/// Open state, plus the visibility grip) and a reference to it at (100, 0).
|
||||
fn door() -> (Session, Handle) {
|
||||
let grip =
|
||||
"0 BLOCKLINEARGRIP\n5 68\n330 61\n100 AcDbEvalExpr\n90 9\n98 33\n99 232\n100 AcDbBlockElement\n300 End Grip\n98 33\n99 232\n1071 0\n";
|
||||
let src = include_str!("../../io/tests/data/dynblock_door.txt")
|
||||
.replace("\r\n", "\n")
|
||||
.replace("170 4\n91 0\n91 0\n", "170 4\n91 8\n91 9\n")
|
||||
.replace("1030 0\n93 0\n170 0\n171 0\n100 AcDbBlockVisibilityParameter", "1030 0\n93 10\n170 0\n171 0\n100 AcDbBlockVisibilityParameter")
|
||||
.replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 9\n360 68")
|
||||
.replace("95 1\n333 63\n", "95 2\n333 63\n333 68\n")
|
||||
.replace("0 BLOCKFLIPGRIP", &format!("{grip}0 BLOCKFLIPGRIP"));
|
||||
let d = cadcraft_io::read_dxf(dxf(&src).as_bytes()).unwrap();
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "door", None);
|
||||
let r = s.execute("insert", &json!({ "name": "Door", "at": [100, 0] })).unwrap();
|
||||
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
assert!(s.doc().unwrap().space_of(h) == Some(Space::Model));
|
||||
(s, h)
|
||||
}
|
||||
|
||||
/// An app with the reference of [`door`] on the layer "Hidden", and two viewports on Layout1
|
||||
/// over the model: the first freezes "Hidden", the second freezes nothing. Returns the app,
|
||||
/// the reference and the two viewports, in paper space.
|
||||
fn door_in_two_viewports() -> (CadApp, Handle, String, String) {
|
||||
let (session, h) = door();
|
||||
let mut app = CadApp::new(session, Services::default());
|
||||
app.session.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
app.session.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
|
||||
app.session.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
|
||||
let (a, b) = (viewport(0), viewport(1));
|
||||
app.session.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
(app, h, a, b)
|
||||
}
|
||||
|
||||
/// Makes `viewport` the active MSPACE viewport and selects `h` there (entering a viewport
|
||||
/// clears the selection; Undo or another selection source can put the reference back).
|
||||
fn enter_and_select(app: &mut CadApp, viewport: &str, h: Handle) {
|
||||
app.session.execute("mspace", &json!({ "handle": viewport })).unwrap();
|
||||
app.session.set_selection(vec![h]);
|
||||
}
|
||||
|
||||
/// A selected reference shows its dynamic grips through a viewport that draws its layer. Once
|
||||
/// the layer is frozen in that viewport the grips are gone although the reference is still
|
||||
/// selected, and thawing the layer there brings them back.
|
||||
#[test]
|
||||
fn a_reference_the_viewport_does_not_draw_has_no_dynamic_grips() {
|
||||
let (mut app, h, _, b) = door_in_two_viewports();
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert_eq!(of_selection(&mut app).len(), 2, "this viewport draws the reference: its two grips");
|
||||
app.session.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [h], "the reference must still be selected");
|
||||
assert!(of_selection(&mut app).is_empty(), "a reference the viewport no longer draws kept its dynamic grips");
|
||||
app.session.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [h]);
|
||||
assert_eq!(of_selection(&mut app).len(), 2, "thawed in the viewport: the grips are back");
|
||||
}
|
||||
|
||||
/// Entering another viewport changes which references are drawn without changing the drawing,
|
||||
/// so the cached grips of the same selection must not be reused from one viewport to the next.
|
||||
#[test]
|
||||
fn the_dynamic_grip_cache_follows_the_active_viewport() {
|
||||
let (mut app, h, a, b) = door_in_two_viewports();
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert_eq!(of_selection(&mut app).len(), 2);
|
||||
let revision = app.session.state().unwrap().revision;
|
||||
enter_and_select(&mut app, &a, h);
|
||||
assert_eq!(app.session.state().unwrap().revision, revision, "entering a viewport must not look like a change of the drawing");
|
||||
assert!(of_selection(&mut app).is_empty(), "the grips cached in the other viewport were reused where the reference is not drawn");
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert_eq!(app.session.state().unwrap().revision, revision);
|
||||
assert_eq!(of_selection(&mut app).len(), 2, "the empty result cached in the other viewport was reused where the reference is drawn");
|
||||
}
|
||||
|
||||
/// A hidden reference restored to the selection (Undo, or another selection source) shows
|
||||
/// and takes no dynamic grips, as ordinary grips are not shown for it either.
|
||||
#[test]
|
||||
fn a_hidden_reference_has_no_dynamic_grips() {
|
||||
let (session, h) = door();
|
||||
let mut app = CadApp::new(session, Services::default());
|
||||
app.session.set_selection(vec![h]);
|
||||
assert_eq!(of_selection(&mut app).len(), 2, "a visible reference shows its two grips");
|
||||
app.run("hideobjects", json!({ "handles": [h.hex()] })).unwrap();
|
||||
app.session.set_selection(vec![h]);
|
||||
let st = app.session.state().unwrap();
|
||||
assert!(!st.doc.is_visible(st.doc.entity(h).unwrap()), "the reference must be hidden");
|
||||
assert!(of_selection(&mut app).is_empty(), "a hidden reference must not offer dynamic grips");
|
||||
}
|
||||
|
||||
/// BEDIT without a block name opens the editor on the one selected reference, and its dialog
|
||||
/// when there is none. A selected reference the viewport does not draw is none to start from.
|
||||
#[test]
|
||||
fn bedit_opens_its_dialog_when_the_viewport_does_not_draw_the_selected_reference() {
|
||||
let (mut app, h, a, b) = door_in_two_viewports();
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert!(crate::bedit::route(&mut app, "bedit", &serde_json::Value::Null).is_none(), "one reference is selected: no dialog");
|
||||
assert_eq!(app.ui.dialog, None);
|
||||
enter_and_select(&mut app, &a, h);
|
||||
assert!(crate::bedit::route(&mut app, "bedit", &serde_json::Value::Null).is_some(), "the dialog must open");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("bedit"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,16 +70,12 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, Strin
|
||||
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))) {
|
||||
let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?;
|
||||
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
|
||||
let size = [img.width() as usize, img.height() as usize];
|
||||
let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR);
|
||||
let tex = preview_texture(ctx, &png)?;
|
||||
return Ok((tex, f.info));
|
||||
}
|
||||
let r = app.session.execute("preview", &json!({ "width": 1600 })).map_err(|e| e.to_string())?;
|
||||
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
|
||||
let size = [img.width() as usize, img.height() as usize];
|
||||
let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR);
|
||||
let tex = preview_texture(ctx, &png)?;
|
||||
let mut info = format!("{} · {}", r["layout"].as_str().unwrap_or(""), r["paper"].as_str().unwrap_or(""));
|
||||
if r["landscape"].as_bool() == Some(true) {
|
||||
info.push_str(" · landscape");
|
||||
@@ -87,6 +83,15 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, Strin
|
||||
Ok((tex, info))
|
||||
}
|
||||
|
||||
fn preview_texture(ctx: &egui::Context, png: &[u8]) -> Result<TextureHandle, String> {
|
||||
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Err("the preview is not a PNG".into());
|
||||
}
|
||||
let img = cadcraft_io::raster::decode(png).map_err(|e| e.to_string())?;
|
||||
let size = [img.width() as usize, img.height() as usize];
|
||||
Ok(ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.rgba()), egui::TextureOptions::LINEAR))
|
||||
}
|
||||
|
||||
/// The Plot Preview window: the sheet as it plots, with Print and Close.
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let id = egui::Id::unique("cc_plot_preview");
|
||||
@@ -169,6 +174,16 @@ mod tests {
|
||||
(CadApp::new(Session::new(), services), asked)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_preview_reads_shared_png_and_rejects_other_bytes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut png = Vec::new();
|
||||
cadcraft_render::raster::write_rgba_png(&mut png, 1, 1, &[10, 20, 30, 255]).unwrap();
|
||||
let texture = super::preview_texture(&ctx, &png).unwrap();
|
||||
assert_eq!(texture.size(), [1, 1]);
|
||||
assert!(super::preview_texture(&ctx, b"not a PNG").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_and_menu_commands_ask_json_calls_never_do() {
|
||||
let (mut app, asked) = app();
|
||||
|
||||
@@ -297,7 +297,7 @@ pub fn open_create(app: &mut CadApp, gradient: bool) {
|
||||
/// pick a hatch first.
|
||||
pub fn open_edit(app: &mut CadApp) {
|
||||
let Ok(st) = app.session.state() else { return };
|
||||
let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.selection.clone()), create_state(&st.doc, st.uid, false));
|
||||
let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.shown_selection()), create_state(&st.doc, st.uid, false));
|
||||
create.space = st.edit_space();
|
||||
if let Some(e) = edit.as_mut() {
|
||||
e.space = st.edit_space();
|
||||
|
||||
@@ -416,3 +416,35 @@ fn a_gradient_hatch_offers_the_current_pattern_on_the_pattern_tab() {
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit mode opens on the selected hatches. A selected hatch on a layer frozen in the active
|
||||
/// viewport is not drawn there, so it is not one of them and the command asks for a hatch.
|
||||
#[test]
|
||||
fn edit_mode_leaves_out_a_selected_hatch_the_viewport_does_not_draw() {
|
||||
let (mut app, _) = app_with_rect();
|
||||
let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string();
|
||||
let hatch = Handle::parse_hex(&hx).unwrap();
|
||||
app.session.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
app.session.execute("properties.set", &json!({ "handles": [hx], "layer": "Hidden" })).unwrap();
|
||||
app.session.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
|
||||
let (a, b) = (viewport(0), viewport(1));
|
||||
app.session.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
|
||||
// Through the viewport that draws the hatch, edit mode opens on it.
|
||||
app.session.execute("mspace", &json!({ "handle": b })).unwrap();
|
||||
app.session.set_selection(vec![hatch]);
|
||||
open_edit(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("hatch"));
|
||||
assert_eq!(app.hatch_dlg.as_ref().map(|d| d.edit.clone()), Some(vec![hatch]));
|
||||
|
||||
// Through the viewport that freezes its layer there is no hatch to edit yet.
|
||||
(app.ui.dialog, app.hatch_dlg) = (None, None);
|
||||
app.session.execute("mspace", &json!({ "handle": a })).unwrap();
|
||||
app.session.set_selection(vec![hatch]);
|
||||
open_edit(&mut app);
|
||||
assert_eq!(app.ui.dialog, None, "no dialog: the command asks for a hatch first");
|
||||
assert_eq!(app.hatch_dlg.as_ref().map(|d| d.edit.clone()), Some(Vec::new()), "a hatch the viewport does not draw must not be edited");
|
||||
assert!(app.session.running.is_some(), "HATCHPICK must be asking for a hatch");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ pub enum Preference {
|
||||
Greek,
|
||||
#[serde(rename = "uk")]
|
||||
Ukrainian,
|
||||
#[serde(rename = "pl")]
|
||||
Polish,
|
||||
#[serde(rename = "pt-BR")]
|
||||
BrazilianPortuguese,
|
||||
#[serde(rename = "de")]
|
||||
@@ -52,13 +54,13 @@ pub enum Preference {
|
||||
#[serde(rename = "vi")]
|
||||
Vietnamese,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
pub const ALL: [Self; 20] = [
|
||||
pub const ALL: [Self; 21] = [
|
||||
Self::Auto,
|
||||
Self::English,
|
||||
Self::Greek,
|
||||
Self::Ukrainian,
|
||||
Self::Polish,
|
||||
Self::BrazilianPortuguese,
|
||||
Self::German,
|
||||
Self::Spanish,
|
||||
@@ -109,6 +111,7 @@ impl Preference {
|
||||
Self::English => "en",
|
||||
Self::Greek => "el",
|
||||
Self::Ukrainian => "uk",
|
||||
Self::Polish => "pl",
|
||||
Self::BrazilianPortuguese => "pt-BR",
|
||||
Self::German => "de",
|
||||
Self::Spanish => "es",
|
||||
@@ -134,6 +137,7 @@ impl Preference {
|
||||
Self::English => "English",
|
||||
Self::Greek => "Ελληνικά",
|
||||
Self::Ukrainian => "Українська",
|
||||
Self::Polish => "Polski",
|
||||
Self::BrazilianPortuguese => "Português (Brasil)",
|
||||
Self::German => "Deutsch",
|
||||
Self::Spanish => "Español",
|
||||
@@ -188,6 +192,7 @@ impl Preference {
|
||||
Self::English => "en",
|
||||
Self::Greek => "el",
|
||||
Self::Ukrainian => "uk",
|
||||
Self::Polish => "pl",
|
||||
Self::BrazilianPortuguese => "pt-BR",
|
||||
Self::German => "de",
|
||||
Self::Spanish => "es",
|
||||
@@ -220,8 +225,9 @@ pub struct Language {
|
||||
catalog: OnceLock<Catalog<'static>>,
|
||||
}
|
||||
|
||||
pub static LANGUAGES: [Language; 19] = [
|
||||
pub static LANGUAGES: [Language; 20] = [
|
||||
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
Language { code: "pl", name: "Polski", source: include_str!("i18n/pl.tsv"), plural: plural_pl, plural_forms: 3, catalog: OnceLock::new() },
|
||||
// Greek: one for exactly 1; other for zero and all other integer counts.
|
||||
Language {
|
||||
code: "el", name: "Ελληνικά", source: include_str!("i18n/el.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new()
|
||||
@@ -375,6 +381,17 @@ pub fn format(source: &str, values: &[(&str, String)]) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Polish plurals: one (1), few (2–4, 22–24), many (0, 5–21, 25–31).
|
||||
pub fn plural_pl(n: u64) -> usize {
|
||||
if n == 1 {
|
||||
0
|
||||
} else if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukrainian integer plurals: one (1, 21), few (2–4, 22–24), many (0, 5–20, 25…).
|
||||
pub fn plural_uk(n: u64) -> usize {
|
||||
match (n % 10, n % 100) {
|
||||
|
||||
@@ -1383,3 +1383,6 @@ menu View عرض
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. تغيّر الرسم. أغلق النافذة وافتحها مرة أخرى.
|
||||
|
||||
# ArtCraft feature naming
|
||||
Additional Tools أدوات إضافية
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1335 and column 24.
|
@@ -1459,3 +1459,6 @@ menu Modify Izmijeni
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. Crtež se promijenio. Zatvorite prozor i otvorite ga ponovo.
|
||||
|
||||
# ArtCraft feature naming
|
||||
Additional Tools Dodatni alati
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1444 and column 24.
|
@@ -1239,3 +1239,6 @@ menu Modify Modifikace
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. Výkres se změnil. Zavřete okno a otevřete jej znovu.
|
||||
|
||||
# ArtCraft feature naming
|
||||
Additional Tools Další nástroje
|
||||
|
||||
|
@@ -1056,3 +1056,6 @@
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. Die Zeichnung hat sich geändert. Schließen Sie das Fenster und öffnen Sie es erneut.
|
||||
|
||||
# ArtCraft feature naming
|
||||
Additional Tools Zusätzliche Werkzeuge
|
||||
|
||||
|
@@ -47,6 +47,9 @@
|
||||
Add: Pick points Προσθήκη: επιλέξτε σημεία
|
||||
Add: Select objects Προσθήκη: επιλέξτε αντικείμενα
|
||||
Additional angles Πρόσθετες γωνίες
|
||||
Add… Προσθήκη…
|
||||
Alias Ψευδώνυμο
|
||||
Alias file: {file} Αρχείο ψευδωνύμων: {file}
|
||||
Align Στοίχιση
|
||||
Align Multileaders Στοίχιση πολλαπλών γραμμών υπόδειξης (Multileaders)
|
||||
Align Text Στοίχιση κειμένου
|
||||
@@ -57,6 +60,8 @@
|
||||
All Scales Όλες οι κλίμακες
|
||||
All Used Layers Όλα τα χρησιμοποιούμενα επίπεδα (Layers)
|
||||
An array that would create more objects than this is refused. Απορρίπτεται η διάταξη επειδή θα δημιουργούσε περισσότερα αντικείμενα από το επιτρεπτό όριο.
|
||||
Analyze Shapes Ανάλυση σχημάτων
|
||||
Analyze Usage Ανάλυση χρήσης
|
||||
Angle Γωνία
|
||||
Angular Γωνιακό
|
||||
Animation Effects Εφέ κίνησης
|
||||
@@ -89,6 +94,7 @@
|
||||
Automatic save file location Θέση αρχείων αυτόματης αποθήκευσης
|
||||
Axis, End Άξονας, τέλος
|
||||
B B
|
||||
Backup copy Αντίγραφο ασφαλείας
|
||||
Base Βάση
|
||||
Baseline Γραμμή βάσης (Baseline)
|
||||
Batch Publish Μαζική δημοσίευση
|
||||
@@ -118,6 +124,7 @@
|
||||
Box Κουτί (Box)
|
||||
Break Διακοπή (Break)
|
||||
Break At Point Διακοπή σε σημείο (Break at Point)
|
||||
Break-line Symbol Σύμβολο γραμμής διακοπής (Break-line Symbol)
|
||||
Bring Above Objects Μεταφορά πάνω από τα αντικείμενα
|
||||
Bring Annotations to Front Μεταφορά σχολιασμών στο προσκήνιο
|
||||
Bring to Front Μεταφορά στο προσκήνιο
|
||||
@@ -145,10 +152,13 @@
|
||||
Center, Start, Length Κέντρο, αρχή, μήκος
|
||||
Centered Κεντραρισμένο
|
||||
Chamfer Λοξότμηση (Chamfer)
|
||||
Change Αλλαγή
|
||||
Change Properties Αλλαγή ιδιοτήτων (Change Properties)
|
||||
Change Space Αλλαγή χώρου σχεδίασης
|
||||
Change to Current Layer Μεταφορά στο τρέχον επίπεδο (Layer)
|
||||
Changed: {n} Αλλαγές: {n}
|
||||
Changes apply at once and are kept for next time. Οι αλλαγές εφαρμόζονται αμέσως και διατηρούνται για την επόμενη φορά.
|
||||
Choose the command the alias starts. Επιλέξτε την εντολή που εκκινεί το ψευδώνυμο.
|
||||
Circle Κύκλος
|
||||
Circumference Περιφέρεια
|
||||
Clear Εκκαθάριση
|
||||
@@ -166,6 +176,16 @@
|
||||
Close Array Editing Κλείσιμο επεξεργασίας διάταξης
|
||||
Close Block Editor Κλείσιμο επεξεργαστή μπλοκ
|
||||
Close Comparison Κλείσιμο σύγκρισης
|
||||
Command Εντολή
|
||||
Convert Text to Mtext Μετατροπή κειμένου σε πολυγραμμικό κείμενο (MText)
|
||||
Create backup copy with each save Δημιουργία αντιγράφου ασφαλείας σε κάθε αποθήκευση
|
||||
Customize Προσαρμογή
|
||||
Define each corner of the selection window with a separate click. Ορίστε κάθε γωνία του παραθύρου επιλογής με ξεχωριστό κλικ.
|
||||
Discard Απόρριψη
|
||||
Don't Save Να μην αποθηκευτεί
|
||||
Edit Command Aliases (PGP)... Επεξεργασία ψευδωνύμων εντολών (PGP)...
|
||||
Edit… Επεξεργασία…
|
||||
Explode Attributes to Text Διάσπαση ιδιοτήτων (Attributes) σε κείμενο
|
||||
Export Comparison Εξαγωγή σύγκρισης
|
||||
Closed Κλειστό
|
||||
Coincident Σύμπτωση (Coincident)
|
||||
@@ -197,6 +217,29 @@
|
||||
Constraint Bars Γραμμές περιορισμών
|
||||
Constraint Settings Ρυθμίσεις περιορισμών
|
||||
Construction Line Βοηθητική ευθεία (Construction Line)
|
||||
Express Tools Εργαλεία Express (Express Tools)
|
||||
Font Γραμματοσειρά
|
||||
Imperial (inches) Αγγλοσαξονικό (ίντσες)
|
||||
Implied Loops Έμμεσοι βρόχοι
|
||||
Interface scale Κλίμακα περιβάλλοντος
|
||||
Island Νησίδα
|
||||
Metric (millimetres) Μετρικό (χιλιοστά)
|
||||
More than {n} aliases. Περισσότερα από {n} ψευδώνυμα.
|
||||
Move/Copy/Rotate Μετακίνηση/Αντιγραφή/Περιστροφή
|
||||
Multiple Insert Πολλαπλή εισαγωγή (Multiple Insert)
|
||||
New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Τα νέα σχέδια και το πρώτο σχέδιο κατά την εκκίνηση χρησιμοποιούν αυτές τις μονάδες, εκτός αν έχει οριστεί προεπιλεγμένο πρότυπο (QNEWTEMPLATE).
|
||||
No alias file loaded. REINIT replaces these aliases with a file's. Δεν έχει φορτωθεί αρχείο ψευδωνύμων. Η εντολή REINIT αντικαθιστά αυτά τα ψευδώνυμα με αυτά ενός αρχείου.
|
||||
Save Layout as Template Αποθήκευση διάταξης ως πρότυπο (Save Layout as Template)
|
||||
Save alias Αποθήκευση ψευδωνύμου
|
||||
Save changes to block "{name}"? Να αποθηκευτούν οι αλλαγές στο μπλοκ «{name}»;
|
||||
Save changes to {name}? Να αποθηκευτούν οι αλλαγές στο {name};
|
||||
Select boundaries to remove Επιλέξτε περιγράμματα για αφαίρεση
|
||||
Select using two clicks only Επιλογή μόνο με δύο κλικ
|
||||
Type an alias name. Πληκτρολογήστε ένα όνομα ψευδωνύμου.
|
||||
Units Μονάδες
|
||||
Units of new drawings Μονάδες νέων σχεδίων
|
||||
Your changes will be lost if you don't save them. Οι αλλαγές σας θα χαθούν αν δεν τις αποθηκεύσετε.
|
||||
built-in ενσωματωμένο
|
||||
contains authoring elements. Open in Block Editor? περιέχει στοιχεία δημιουργίας. Άνοιγμα στον επεξεργαστή μπλοκ;
|
||||
Contents Περιεχόμενα
|
||||
Continue Συνέχεια
|
||||
@@ -1317,6 +1360,9 @@
|
||||
Zoom Realtime Μεγέθυνση σε πραγματικό χρόνο
|
||||
Zoom sensitivity Ευαισθησία μεγέθυνσης
|
||||
Zoom Window Μεγέθυνση περιοχής παραθύρου
|
||||
overrides built-in υπερισχύει του ενσωματωμένου
|
||||
{alias} is already defined; select it and use Edit. Το {alias} είναι ήδη ορισμένο· επιλέξτε το και χρησιμοποιήστε την Επεξεργασία.
|
||||
{command} is an unknown command. Το {command} δεν είναι γνωστή εντολή.
|
||||
{contributors} · {commits} {contributors} · {commits}
|
||||
{kind} (constraint {id}) {kind} (περιορισμός {id})
|
||||
{kind} constraint {name} = {expression} (double-click to edit) {kind} περιορισμός {name} = {expression} (διπλό κλικ για επεξεργασία)
|
||||
@@ -1350,3 +1396,6 @@ menu View Προβολή
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. Το σχέδιο άλλαξε. Κλείστε το παράθυρο και ανοίξτε το ξανά.
|
||||
|
||||
# ArtCraft feature naming
|
||||
Additional Tools Πρόσθετα εργαλεία
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 234 and column 24.
|
@@ -1270,3 +1270,6 @@
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. El dibujo cambió. Cierre la ventana y ábrala de nuevo.
|
||||
|
||||
# ArtCraft feature naming
|
||||
Additional Tools Herramientas adicionales
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user