mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-08 11:29:55 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b4ec8043e | ||
|
|
7400119120 | ||
|
|
258c89fbb0 | ||
|
|
d7ef7da872 | ||
|
|
5caaf73e3b | ||
|
|
b6331c8603 | ||
|
|
931c0c711a | ||
|
|
fa060b0998 | ||
|
|
87f8ce22f6 | ||
|
|
ac886483d1 | ||
|
|
a626b71284 | ||
|
|
81e93f2caf | ||
|
|
f18cdcbcac | ||
|
|
fc9d056576 | ||
|
|
0575a76291 | ||
|
|
e28ed2018a | ||
|
|
add1fd3528 | ||
|
|
c254c51474 | ||
|
|
b0b2aba23e | ||
|
|
7cee28580d | ||
|
|
a83af92eb2 | ||
|
|
4552b35688 | ||
|
|
e60d546ce4 | ||
|
|
42be8540a9 | ||
|
|
a1335895fc | ||
|
|
b6c7a73fa6 | ||
|
|
b35f00847a | ||
|
|
c6e49b177c | ||
|
|
11ce7eb52c | ||
|
|
9b32882065 | ||
|
|
ee715f4636 |
@@ -62,6 +62,7 @@ jobs:
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
|
||||
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
|
||||
@@ -12,7 +10,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
@@ -47,25 +45,22 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
|
||||
+9
-8
@@ -48,14 +48,14 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `docs/images/ui-apartment.png` | CADCraft contributors | screenshot of CADCraft itself (examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/images/ui-layout.png` | CADCraft contributors | screenshot of CADCraft itself (a layout of examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/images/ui-bracket.png` | CADCraft contributors | screenshot of CADCraft itself (sample drawing generated in code) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
|
||||
## Interface translations
|
||||
|
||||
@@ -77,3 +77,4 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `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/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 |
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
|
||||
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
|
||||
@@ -12,7 +10,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
@@ -46,7 +44,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
|
||||
Generated
+25
-25
@@ -268,7 +268,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -643,7 +643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -659,14 +659,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -676,7 +676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -687,14 +687,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -703,7 +703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -724,7 +724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.14.0",
|
||||
@@ -734,14 +734,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -762,7 +762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -775,7 +775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -789,7 +789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -815,7 +815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -1191,7 +1191,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1643,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3435,7 +3435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4030,7 +4030,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4087,7 +4087,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4562,10 +4562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5471,7 +5471,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5894,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -66,7 +66,7 @@ egui-wgpu = "0.37"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
|
||||
@@ -377,7 +377,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/cadcraft-64.png" alt="" width="32" height="32"> | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+3
-3
@@ -125,8 +125,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
|
||||
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
|
||||
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
|
||||
| 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. |
|
||||
| 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. |
|
||||
| 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. |
|
||||
@@ -144,7 +144,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate |
|
||||
|
||||
## Shared UI workspace integration
|
||||
|
||||
+35
-17
@@ -194,6 +194,31 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
|
||||
filters
|
||||
}
|
||||
|
||||
/// The main window: its size, title bar, placement and rendering needs.
|
||||
fn window_options() -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("CADCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// The window opens centred on its screen, not cascaded from the top-left corner (#1129).
|
||||
// Wayland compositors place windows themselves.
|
||||
centered: true,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
@@ -256,22 +281,7 @@ fn main() -> eframe::Result {
|
||||
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
|
||||
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
|
||||
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("CADCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
..window_options()
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
@@ -383,7 +393,7 @@ fn main() -> eframe::Result {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{filters_for_save, follow_desktop};
|
||||
use super::{filters_for_save, follow_desktop, window_options};
|
||||
|
||||
#[test]
|
||||
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
|
||||
@@ -401,6 +411,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1129: the window opens centred, not cascaded from the top-left corner.
|
||||
#[test]
|
||||
fn the_window_opens_centred() {
|
||||
let o = window_options();
|
||||
assert!(o.centered, "centred on launch");
|
||||
assert!(!o.persist_window, "no saved position to restore instead");
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
|
||||
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
|
||||
#[test]
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -99,3 +96,6 @@ sync_github_name = false
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GULERTOLGA]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -465,6 +465,91 @@ impl EntityKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale every height of the object by `f` about the height `base_z`: the Z of its points,
|
||||
/// its elevation. With [`EntityKind::transform`] scaling about the base point's XY this is a
|
||||
/// uniform 3D scale about the base point, as AutoCAD 2024's SCALE makes it. Sizes `transform`
|
||||
/// already scales (text and helix heights, an insert's Z scale) are left alone; splines,
|
||||
/// regions and wipeouts lie in the XY plane.
|
||||
pub fn scale_heights(&mut self, base_z: f64, f: f64) {
|
||||
let scale = |h: &mut f64| *h = base_z + (*h - base_z) * f;
|
||||
let z = |p: &mut Vec3| scale(&mut p.z);
|
||||
let text_z = |t: &mut Text| {
|
||||
z(&mut t.insert);
|
||||
if let Some(a) = &mut t.align_pt {
|
||||
z(a);
|
||||
}
|
||||
};
|
||||
let attribs_z = |attribs: &mut Vec<Attrib>| {
|
||||
for a in attribs {
|
||||
text_z(&mut a.text);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
};
|
||||
match self {
|
||||
EntityKind::Line(l) => {
|
||||
z(&mut l.a);
|
||||
z(&mut l.b);
|
||||
}
|
||||
EntityKind::Point(p) => z(&mut p.p),
|
||||
EntityKind::Circle(c) => z(&mut c.center),
|
||||
EntityKind::Arc(a) => z(&mut a.center),
|
||||
EntityKind::Ellipse(e) => z(&mut e.center),
|
||||
EntityKind::LwPolyline(p) => scale(&mut p.elevation),
|
||||
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(z),
|
||||
EntityKind::Helix(h) => z(&mut h.center),
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => z(&mut r.base),
|
||||
EntityKind::Text(t) => text_z(t),
|
||||
EntityKind::AttDef(a) => {
|
||||
text_z(&mut a.text);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => z(&mut t.insert),
|
||||
EntityKind::Insert(i) => {
|
||||
z(&mut i.insert);
|
||||
attribs_z(&mut i.attribs);
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] {
|
||||
z(p);
|
||||
}
|
||||
if let Some(j) = &mut d.marks.jog_line {
|
||||
z(j);
|
||||
}
|
||||
if let Some(j) = &mut d.marks.jogged {
|
||||
z(&mut j.center);
|
||||
z(&mut j.jog);
|
||||
}
|
||||
d.marks.breaks.iter_mut().for_each(|b| z(&mut b.at));
|
||||
d.block = None;
|
||||
}
|
||||
EntityKind::MLine(ml) => ml.vertices.iter_mut().for_each(|v| z(&mut v.p)),
|
||||
EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z),
|
||||
EntityKind::MLeader(ml) => {
|
||||
ml.leaders.iter_mut().flatten().for_each(z);
|
||||
z(&mut ml.landing);
|
||||
if let Some(t) = &mut ml.text {
|
||||
z(&mut t.insert);
|
||||
}
|
||||
if let Some(b) = &mut ml.block {
|
||||
z(&mut b.insert);
|
||||
attribs_z(&mut b.attribs);
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => scale(&mut h.elevation),
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
|
||||
EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z),
|
||||
EntityKind::Viewport(v) => z(&mut v.center),
|
||||
EntityKind::Image(i) => z(&mut i.insert),
|
||||
EntityKind::Table(t) => z(&mut t.insert),
|
||||
EntityKind::Tolerance(t) => z(&mut t.insert),
|
||||
EntityKind::Spline(_) | EntityKind::Region(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grip points (editable defining points), in display order.
|
||||
pub fn grips(&self) -> Vec<Vec2> {
|
||||
// A broken table has a grip for each part.
|
||||
|
||||
@@ -416,6 +416,13 @@ mod native {
|
||||
writes
|
||||
}
|
||||
|
||||
/// Whether a background write hasn't finished yet. Its file can exist before it finishes
|
||||
/// (the folder is synced after the rename), and until [`Autosaver::reap`] takes it in, the
|
||||
/// drawing is left out of planning.
|
||||
pub fn writing(&self) -> bool {
|
||||
self.running.iter().any(|(_, h)| !h.is_finished())
|
||||
}
|
||||
|
||||
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
|
||||
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
|
||||
/// so it is tried again SAVETIME later even if it doesn't change.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`).
|
||||
//! Array commands: ARRAY, ARRAYRECT, ARRAYPOLAR and the classic -ARRAY (ARRAYPATH lives in
|
||||
//! `modify2`).
|
||||
//!
|
||||
//! Arrays are associative by default (ARRAYASSOCIATIVITY): the source objects move into an
|
||||
//! anonymous source block and the array becomes one block reference to an anonymous array block
|
||||
@@ -21,6 +22,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod classic;
|
||||
mod edit;
|
||||
|
||||
/// Most preview entities drawn while the array is edited.
|
||||
@@ -48,6 +50,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{handles?, center, count, angle? (degrees, default 360), rotate?: bool, rows? (1), rowSpacing?, rowElevation?, levels? (1), levelSpacing?, base? ([x, y], default the first object's centre), associative?: bool (default ARRAYASSOCIATIVITY, on)} → {handle (the array, when associative), created (copies)}",
|
||||
)
|
||||
.interactive(|_| Ok(Box::new(PolarM::default()))),
|
||||
CommandSpec::new("-array", "Array", classic::run_classic)
|
||||
.params("{handles?, type?: \"rectangular\" (default) | \"polar\", rows? (1), cols? (1), rowSpacing?, colSpacing?, center, count, angle? (degrees, default 360), rotate?: bool (default true), base? ([x, y], default the DXF group 10 point of the last object)} → {created}: separate copies, as the classic -ARRAY makes them")
|
||||
.interactive(|_| Ok(Box::new(classic::ClassicM::default()))),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(edit::specs())
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! -ARRAY: the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command`
|
||||
//! (accoreconsole, every copy read back with `entget`). It makes separate copies, never an
|
||||
//! associative array:
|
||||
//! - "Enter the type of array [Rectangular/Polar] <R>": the default is the type used last;
|
||||
//! - Rectangular asks "Enter the number of rows (---) <1>" and "Enter the number of columns (|||)
|
||||
//! <1>", then "Enter the distance between rows or specify unit cell (---)" only for more than one
|
||||
//! row (two corners give both distances) and "Specify the distance between columns (|||)" only for
|
||||
//! more than one column. The copy in row r and column c moves by (c·columns, r·rows): 2 rows and
|
||||
//! 2 columns at −12.57 and −18.01 put the copies below and to the left;
|
||||
//! - Polar asks "Specify center point of array or [Base]", "Enter the number of items in the
|
||||
//! array", "Specify the angle to fill (+=ccw, -=cw) <360>" and "Rotate arrayed objects?
|
||||
//! [Yes/No] <Y>". Items are 360/n apart around a full circle, else fill/(n − 1) apart (6 items in
|
||||
//! 270°: 54°; −90° turns clockwise). Not rotated, each copy moves as the DXF group 10 point of the
|
||||
//! object selected last turns about the centre (a polyline's first vertex, an arc's centre), or
|
||||
//! as the Base point does.
|
||||
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::{bad, bool_or, f64_or, point_param, point_req, str_param, targets};
|
||||
use super::{KEYWORDS, angle, check_size, copy_all, count, count_or, max_count, other, yes_no};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Select,
|
||||
Kind,
|
||||
Rows,
|
||||
Columns,
|
||||
RowDistance,
|
||||
/// The unit cell's other corner, after the first one.
|
||||
Corner(Vec2),
|
||||
ColumnDistance,
|
||||
Center,
|
||||
Base,
|
||||
Items,
|
||||
Fill,
|
||||
Rotate,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ClassicM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
stage: Stage,
|
||||
rows: u64,
|
||||
columns: u64,
|
||||
row_distance: f64,
|
||||
center: Vec2,
|
||||
base: Option<Vec2>,
|
||||
items: u64,
|
||||
fill: f64,
|
||||
}
|
||||
|
||||
/// The moves of a rectangular array's copies (the objects themselves stay in row 0, column 0).
|
||||
fn rect_mats(rows: u64, columns: u64, row_distance: f64, column_distance: f64) -> Vec<Mat3> {
|
||||
(0..rows)
|
||||
.flat_map(|r| (0..columns).map(move |c| (r, c)))
|
||||
.filter(|rc| *rc != (0, 0))
|
||||
.map(|(r, c)| Mat3::translate(Vec2::new(c as f64 * column_distance, r as f64 * row_distance)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The transforms of a polar array's copies: `items` around `center` filling `fill` degrees,
|
||||
/// turned with it or moved as `base` turns.
|
||||
fn polar_mats(center: Vec2, items: u64, fill: f64, rotate: bool, base: Vec2) -> Vec<Mat3> {
|
||||
let full = (fill.abs() - 360.0).abs() < 1e-9;
|
||||
let gaps = if full { items } else { items.saturating_sub(1) }.max(1);
|
||||
let step = fill.to_radians() / gaps as f64;
|
||||
(1..items)
|
||||
.map(|k| {
|
||||
let a = step * k as f64;
|
||||
if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(base.rotate_about(center, a) - base) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The DXF group 10 point of the last of `hs`, which a polar array that doesn't rotate its copies
|
||||
/// moves around the centre.
|
||||
fn last_point(s: &Session, hs: &[Handle]) -> Option<Vec2> {
|
||||
let d = s.doc().ok()?;
|
||||
hs.last().and_then(|h| d.entity(*h)).and_then(|e| crate::session::group_10(d, e))
|
||||
}
|
||||
|
||||
/// -ARRAY's JSON form.
|
||||
pub(super) fn run_classic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let polar = match str_param(p, "type").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("rectangular") => false,
|
||||
Some("polar") => true,
|
||||
Some(_) => return Err(bad("-array", "type must be \"rectangular\" or \"polar\"")),
|
||||
};
|
||||
// The size is checked before the copies' transforms are made.
|
||||
let mats = if polar {
|
||||
let center = point_req("-array", p, "center")?;
|
||||
let items = count_or("-array", p, "count", 1)?;
|
||||
check_size(s, items, hs.len())?;
|
||||
let base = point_param(p, "base").or_else(|| last_point(s, &hs)).unwrap_or(center);
|
||||
polar_mats(center, items, f64_or(p, "angle", 360.0), bool_or(p, "rotate", true), base)
|
||||
} else {
|
||||
let rows = count_or("-array", p, "rows", 1)?;
|
||||
let columns = count_or("-array", p, "cols", 1)?;
|
||||
check_size(s, rows.saturating_mul(columns), hs.len())?;
|
||||
rect_mats(rows, columns, f64_or(p, "rowSpacing", 0.0), f64_or(p, "colSpacing", 0.0))
|
||||
};
|
||||
let created = copy_all(s, &hs, mats.into_iter())?;
|
||||
Ok(json!({ "created": created }))
|
||||
}
|
||||
|
||||
impl ClassicM {
|
||||
/// Make the copies.
|
||||
fn finish(&mut self, s: &mut Session, mats: Vec<Mat3>) -> Result<Step> {
|
||||
check_size(s, mats.len() as u64 + 1, self.objs.len())?;
|
||||
copy_all(s, &self.objs, mats.into_iter())?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
/// After the rows and columns: the distances still needed, or the copies.
|
||||
fn after_counts(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if self.rows > 1 {
|
||||
self.stage = Stage::RowDistance;
|
||||
} else if self.columns > 1 {
|
||||
self.stage = Stage::ColumnDistance;
|
||||
} else {
|
||||
return self.finish(s, Vec::new());
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn rect(&mut self, s: &mut Session, column_distance: f64) -> Result<Step> {
|
||||
let mats = rect_mats(self.rows, self.columns, self.row_distance, column_distance);
|
||||
self.finish(s, mats)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for ClassicM {
|
||||
fn name(&self) -> &'static str {
|
||||
"-ARRAY"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
self.stage = Stage::Kind;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Select => self.sel.prompt(),
|
||||
Stage::Kind => Prompt::new("Enter the type of array", KEYWORDS)
|
||||
.kw(&["Rectangular", "Polar"])
|
||||
.default(if s.last_used.classic_array_polar { "P" } else { "R" }),
|
||||
Stage::Rows => Prompt::new("Enter the number of rows (---)", Accept::NUMBER).default("1"),
|
||||
Stage::Columns => Prompt::new("Enter the number of columns (|||)", Accept::NUMBER).default("1"),
|
||||
Stage::RowDistance => Prompt::new("Enter the distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
|
||||
Stage::Corner(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
Stage::ColumnDistance => Prompt::new("Specify the distance between columns (|||)", Accept::NUMBER),
|
||||
Stage::Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base"]),
|
||||
Stage::Base => Prompt::new("Specify base point of objects", Accept::POINT),
|
||||
Stage::Items => Prompt::new("Enter the number of items in the array", Accept::NUMBER),
|
||||
Stage::Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default("360"),
|
||||
Stage::Rotate => Prompt::new("Rotate arrayed objects?", KEYWORDS).kw(&["Yes", "No"]).default("Y"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let max = max_count(s, 1, self.objs.len());
|
||||
match (self.stage, i) {
|
||||
(Stage::Select, i) => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
self.stage = Stage::Kind;
|
||||
}
|
||||
SelOutcome::Empty => return Ok(Step::Done),
|
||||
},
|
||||
(Stage::Kind, i) => {
|
||||
let polar = match &i {
|
||||
Input::Enter => s.last_used.classic_array_polar,
|
||||
Input::Keyword(k) | Input::Text(k) => match k.trim().trim_start_matches('_').to_ascii_lowercase().chars().next() {
|
||||
Some('p') => true,
|
||||
Some('r') => false,
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
s.last_used.classic_array_polar = polar;
|
||||
self.stage = if polar { Stage::Center } else { Stage::Rows };
|
||||
}
|
||||
(Stage::Rows, i) => {
|
||||
if let Some(n) = count(&i, 1, max)? {
|
||||
self.rows = n;
|
||||
self.stage = Stage::Columns;
|
||||
}
|
||||
}
|
||||
// No more columns than MAXARRAY allows with the rows.
|
||||
(Stage::Columns, i) => {
|
||||
if let Some(n) = count(&i, 1, max_count(s, self.rows, self.objs.len()))? {
|
||||
self.columns = n;
|
||||
return self.after_counts(s);
|
||||
}
|
||||
}
|
||||
(Stage::RowDistance, Input::Point(a)) => self.stage = Stage::Corner(a),
|
||||
(Stage::RowDistance, Input::Text(t)) => {
|
||||
self.row_distance = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
|
||||
if self.columns <= 1 {
|
||||
return self.rect(s, 0.0);
|
||||
}
|
||||
self.stage = Stage::ColumnDistance;
|
||||
}
|
||||
// A unit cell gives both distances.
|
||||
(Stage::Corner(a), Input::Point(b)) => {
|
||||
self.row_distance = b.y - a.y;
|
||||
return self.rect(s, b.x - a.x);
|
||||
}
|
||||
(Stage::ColumnDistance, Input::Text(t)) => {
|
||||
let d = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
|
||||
return self.rect(s, d);
|
||||
}
|
||||
(Stage::Center, Input::Point(c)) => {
|
||||
self.center = c;
|
||||
self.stage = Stage::Items;
|
||||
}
|
||||
(Stage::Center, Input::Keyword(k)) if k == "Base" => self.stage = Stage::Base,
|
||||
(Stage::Base, Input::Point(b)) => {
|
||||
self.base = Some(b);
|
||||
self.stage = Stage::Center;
|
||||
}
|
||||
(Stage::Items, i @ Input::Text(_)) => {
|
||||
if let Some(n) = count(&i, 1, max)? {
|
||||
self.items = n;
|
||||
self.stage = Stage::Fill;
|
||||
}
|
||||
}
|
||||
(Stage::Fill, i) => {
|
||||
if let Some(a) = angle(&i, 360.0, &s.angle_settings())? {
|
||||
self.fill = a;
|
||||
self.stage = Stage::Rotate;
|
||||
}
|
||||
}
|
||||
(Stage::Rotate, i) => {
|
||||
let Some(rotate) = yes_no(&i, true) else { return Err(other("Invalid option keyword.")) };
|
||||
let base = self.base.or_else(|| last_point(s, &self.objs)).unwrap_or(self.center);
|
||||
let mats = polar_mats(self.center, self.items, self.fill, rotate, base);
|
||||
return self.finish(s, mats);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -365,10 +365,18 @@ fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
||||
let src = s.doc()?;
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
// The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its
|
||||
// units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches
|
||||
// and scaled on insert, and its defaults (text size, current styles, annotation scales).
|
||||
d.header = src.header.clone();
|
||||
d.layers = src.layers.clone();
|
||||
d.linetypes = src.linetypes.clone();
|
||||
d.text_styles = src.text_styles.clone();
|
||||
d.dim_styles = src.dim_styles.clone();
|
||||
d.mleader_styles = src.mleader_styles.clone();
|
||||
d.table_styles = src.table_styles.clone();
|
||||
d.mline_styles = src.mline_styles.clone();
|
||||
d.scales = src.scales.clone();
|
||||
d.blocks = src.blocks.clone();
|
||||
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
|
||||
// objects and the block's base point for a block (`base` is then in block coordinates).
|
||||
@@ -595,6 +603,8 @@ enum InsStage {
|
||||
ZScale,
|
||||
Rotation,
|
||||
Attribs,
|
||||
/// A Verify attribute asked again.
|
||||
Verify,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -615,8 +625,11 @@ struct InsertM {
|
||||
/// Where the block was dropped to pick a Basepoint on it.
|
||||
dropped: Option<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
attdefs: Vec<(String, String, String)>,
|
||||
/// The attributes asked for: tag, prompt, default value, and Verify (asked again).
|
||||
attdefs: Vec<(String, String, String, bool)>,
|
||||
values: serde_json::Map<String, Value>,
|
||||
/// Verify attributes asked again so far.
|
||||
verified: usize,
|
||||
/// `*name`: insert the block's objects (one uniform scale factor, no attribute values).
|
||||
explode: bool,
|
||||
/// The file waiting for the Redefine answer.
|
||||
@@ -633,6 +646,17 @@ impl InsertM {
|
||||
} else {
|
||||
InsStage::Attribs
|
||||
};
|
||||
if self.stage == InsStage::Attribs && self.values.is_empty() && !self.attdefs.is_empty() {
|
||||
s.echo("Enter attribute values");
|
||||
}
|
||||
// Then the Verify attributes are asked again, with the values typed.
|
||||
if self.stage == InsStage::Attribs && self.values.len() >= self.attdefs.len() && self.verified < self.verifying().len() {
|
||||
if self.verified == 0 {
|
||||
s.echo("Verify attribute values");
|
||||
}
|
||||
self.stage = InsStage::Verify;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.stage == InsStage::Attribs
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
@@ -646,20 +670,26 @@ impl InsertM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point.
|
||||
/// The Verify attributes asked for.
|
||||
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
|
||||
self.attdefs.iter().filter(|a| a.3).collect()
|
||||
}
|
||||
|
||||
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point. Constant
|
||||
/// and Preset attributes take their values without asking, and so do all of them with ATTREQ 0.
|
||||
fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> {
|
||||
let Some(b) = s.doc()?.block(name).cloned() else {
|
||||
s.echo(format!("Block \"{name}\" not found."));
|
||||
return Ok(());
|
||||
};
|
||||
self.attdefs = if self.explode {
|
||||
self.attdefs = if self.explode || !s.settings.attreq {
|
||||
Vec::new()
|
||||
} else {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let EntityKind::AttDef(a) = &e.kind {
|
||||
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
|
||||
(!a.constant && !a.modes.preset).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone(), a.modes.verify))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -744,9 +774,14 @@ impl Interactive for InsertM {
|
||||
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
}
|
||||
InsStage::Verify => {
|
||||
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
|
||||
let typed = self.values.get(&tag).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(typed)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
@@ -905,7 +940,7 @@ impl Interactive for InsertM {
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Attribs => {
|
||||
let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
Input::Text(t) => t,
|
||||
_ => default,
|
||||
@@ -913,6 +948,20 @@ impl Interactive for InsertM {
|
||||
self.values.insert(tag, Value::String(v));
|
||||
return self.advance(s);
|
||||
}
|
||||
// Enter keeps the value typed.
|
||||
InsStage::Verify => {
|
||||
let tag = self.verifying().get(self.verified).map(|a| a.0.clone()).unwrap_or_default();
|
||||
match i {
|
||||
Input::Text(t) => {
|
||||
self.values.insert(tag, Value::String(t));
|
||||
}
|
||||
Input::Enter => {}
|
||||
// A click or anything else is no answer: the value is asked for again.
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
self.verified += 1;
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
Accept, EntityKind, Input, Interactive, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves, f64_or, lwpoly,
|
||||
number, other, point_param, point_req, rect_with_corners, size_param,
|
||||
};
|
||||
use crate::last_used::RectangModes;
|
||||
|
||||
// =====================================================================================
|
||||
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
|
||||
@@ -115,11 +116,58 @@ pub(crate) struct RectM2 {
|
||||
asking: Option<RAsk>,
|
||||
}
|
||||
|
||||
/// A rectangle's rotation as AutoCAD 2024 shows it: degrees from 0 to 360, two decimals.
|
||||
fn rotation_text(rotation: f64) -> String {
|
||||
let t = format!("{:.2}", rotation.to_degrees().rem_euclid(360.0));
|
||||
if t == "360.00" { "0.00".into() } else { t }
|
||||
}
|
||||
|
||||
/// AutoCAD 2024's line listing the RECTANG modes that are set, `None` when none is ("Current
|
||||
/// rectangle modes: Chamfer=2.2518 x 3.7663 Rotation=50.50"): distances in the drawing's
|
||||
/// units, the rotation as [`rotation_text`] shows it.
|
||||
fn modes_line(s: &Session, m: &RectangModes) -> Option<String> {
|
||||
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
let d = |v: f64| crate::units::format_distance(v, u, p);
|
||||
let mut parts = Vec::new();
|
||||
if m.chamfer.0 > 0.0 || m.chamfer.1 > 0.0 {
|
||||
parts.push(format!("Chamfer={} x {}", d(m.chamfer.0), d(m.chamfer.1)));
|
||||
}
|
||||
if m.fillet > 0.0 {
|
||||
parts.push(format!("Fillet={}", d(m.fillet)));
|
||||
}
|
||||
if m.elevation != 0.0 {
|
||||
parts.push(format!("Elevation={}", d(m.elevation)));
|
||||
}
|
||||
if m.thickness != 0.0 {
|
||||
parts.push(format!("Thickness={}", d(m.thickness)));
|
||||
}
|
||||
if m.width > 0.0 {
|
||||
parts.push(format!("Width={}", d(m.width)));
|
||||
}
|
||||
let rot = rotation_text(m.rotation);
|
||||
if rot != "0.00" {
|
||||
parts.push(format!("Rotation={rot}"));
|
||||
}
|
||||
(!parts.is_empty()).then(|| format!("Current rectangle modes: {}", parts.join(" ")))
|
||||
}
|
||||
|
||||
impl RectM2 {
|
||||
/// The modes the next rectangle starts from.
|
||||
fn modes(&self) -> RectangModes {
|
||||
RectangModes {
|
||||
chamfer: self.chamfer,
|
||||
fillet: self.fillet,
|
||||
width: self.width,
|
||||
elevation: self.elevation,
|
||||
thickness: self.thickness,
|
||||
rotation: self.rotation,
|
||||
}
|
||||
}
|
||||
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
|
||||
/// the centre when centre mode is on, then applies the requested thickness.
|
||||
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
|
||||
let Some(a) = self.first else { return Ok(Step::Cancel) };
|
||||
s.last_used.rectang = self.modes();
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 {
|
||||
return Err(other("The rectangle has no area."));
|
||||
}
|
||||
@@ -155,6 +203,20 @@ impl Interactive for RectM2 {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECTANG"
|
||||
}
|
||||
/// Starts from the modes the last rectangle left, and lists the ones that are set.
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let m = s.last_used.rectang;
|
||||
self.chamfer = m.chamfer;
|
||||
self.fillet = m.fillet;
|
||||
self.width = m.width;
|
||||
self.elevation = m.elevation;
|
||||
self.thickness = m.thickness;
|
||||
self.rotation = m.rotation;
|
||||
if let Some(line) = modes_line(s, &m) {
|
||||
s.echo(line);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
/// Asks for the pending option's value, or for the first and second points; in centre mode
|
||||
/// the first prompt asks for the centre instead of a corner.
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
@@ -173,9 +235,9 @@ impl Interactive for RectM2 {
|
||||
RAsk::AreaWidth => num("Enter rectangle width", 10.0),
|
||||
RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)),
|
||||
RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)),
|
||||
RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER)
|
||||
RAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Pick points"])
|
||||
.default(format!("{:.0}", self.rotation.to_degrees()))
|
||||
.default(rotation_text(self.rotation))
|
||||
.base_opt(self.first),
|
||||
RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT),
|
||||
RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
|
||||
@@ -207,8 +269,14 @@ impl Interactive for RectM2 {
|
||||
self.chamfer.1 = self.chamfer.0;
|
||||
self.asking = Some(RAsk::Chamfer2);
|
||||
}
|
||||
RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0),
|
||||
RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0),
|
||||
RAsk::Chamfer2 => {
|
||||
self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0);
|
||||
self.fillet = 0.0;
|
||||
}
|
||||
RAsk::Fillet => {
|
||||
self.fillet = val(&i, self.fillet)?.max(0.0);
|
||||
self.chamfer = (0.0, 0.0);
|
||||
}
|
||||
RAsk::Width => self.width = val(&i, self.width)?.max(0.0),
|
||||
RAsk::Elevation => self.elevation = val(&i, self.elevation)?,
|
||||
RAsk::Thickness => self.thickness = val(&i, self.thickness)?,
|
||||
@@ -260,6 +328,8 @@ impl Interactive for RectM2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A mode is kept as soon as it is set, even if no rectangle follows.
|
||||
s.last_used.rectang = self.modes();
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
|
||||
@@ -996,7 +996,8 @@ fn rectang_options_command_line() {
|
||||
s.cmdline("rectang 0,0 r p 0,0 1,1 d 2 2 5,5").unwrap();
|
||||
let pts = rect_pts(&s);
|
||||
assert!(pts.iter().any(|p| near(*p, 0.0, 8f64.sqrt())), "{pts:?}");
|
||||
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 4,4").unwrap();
|
||||
// RECTANG keeps the rotation of the rectangle before (45 degrees), as AutoCAD does: back to 0.
|
||||
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 r 0 4,4").unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::LwPolyline(p) => assert!(p.const_width == 0.5 && p.vertices.len() == 8),
|
||||
k => panic!("{k:?}"),
|
||||
|
||||
@@ -446,9 +446,20 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backup of the drawing file `path`: `<name>.bak` beside it, as AutoCAD names it.
|
||||
/// The backup of the drawing file `path`, beside it. A DWG's is `<name>.bak`, as AutoCAD names it,
|
||||
/// in the extension's letter case (`plan.DWG` to `plan.BAK`); any other drawing appends `.bak`
|
||||
/// (`plan.dxf.bak`), so `plan.dwg` and `plan.dxf` in one folder never overwrite each other's backup.
|
||||
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
path.with_extension("bak")
|
||||
match path.extension().map(|e| e.to_string_lossy()) {
|
||||
Some(e) if e.eq_ignore_ascii_case("dwg") => path.with_extension(
|
||||
e.chars().zip("bak".chars()).map(|(d, b)| if d.is_ascii_uppercase() { b.to_ascii_uppercase() } else { b }).collect::<String>(),
|
||||
),
|
||||
_ => {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(".bak");
|
||||
name.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as
|
||||
|
||||
@@ -15,6 +15,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, point_param, points_param, size_p
|
||||
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
mod assoc;
|
||||
pub(crate) use assoc::detach;
|
||||
mod edit;
|
||||
mod pick;
|
||||
use super::has_doc;
|
||||
|
||||
@@ -121,7 +121,7 @@ pub(super) fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool {
|
||||
ba.min.dist(bb.min) <= eps && ba.max.dist(bb.max) <= eps && (shoelace(&pa).abs() - shoelace(&pb).abs()).abs() <= eps * (1.0 + size)
|
||||
}
|
||||
|
||||
pub(super) fn detach(h: &mut Hatch) {
|
||||
pub(crate) fn detach(h: &mut Hatch) {
|
||||
h.associative = false;
|
||||
h.loops.iter_mut().for_each(|l| l.sources.clear());
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed:
|
||||
let r = d.len();
|
||||
let a0 = d.angle();
|
||||
let step = cadcraft_geom::TAU / n as f64;
|
||||
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 - step / 2.0) };
|
||||
// Circumscribed: the picked point is an edge's midpoint and the first vertex is the end of
|
||||
// that edge counter-clockwise from it, as AutoCAD 2024 numbers them.
|
||||
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 + step / 2.0) };
|
||||
(0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect()
|
||||
}
|
||||
|
||||
@@ -62,43 +64,31 @@ pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec<PolyVertex> {
|
||||
pts.into_iter().map(PolyVertex::new).collect()
|
||||
}
|
||||
|
||||
/// Rectangle vertices with fillet radius or chamfer distances at every corner.
|
||||
/// Rectangle vertices from corner `a` to the opposite corner `b`, with a fillet radius or
|
||||
/// chamfer distances at every corner, in AutoCAD 2024's order: from `a` along X to `b`'s X, then
|
||||
/// `b`, then back along Y. A chamfer cuts the edge into each corner by its first distance and the
|
||||
/// edge out of it by its second; each fillet arc starts on the edge into its corner. A fillet or
|
||||
/// chamfer that doesn't fit leaves the corners square.
|
||||
pub fn rect_with_corners(a: Vec2, b: Vec2, fillet: f64, chamfer: (f64, f64)) -> Vec<PolyVertex> {
|
||||
let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
|
||||
let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
|
||||
let w = x1 - x0;
|
||||
let h = y1 - y0;
|
||||
if fillet > 0.0 && fillet * 2.0 <= w.min(h) + 1e-12 {
|
||||
let r = fillet;
|
||||
let bul = cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2);
|
||||
return vec![
|
||||
PolyVertex::new(Vec2::new(x0 + r, y0)),
|
||||
PolyVertex::with_bulge(Vec2::new(x1 - r, y0), bul),
|
||||
PolyVertex::new(Vec2::new(x1, y0 + r)),
|
||||
PolyVertex::with_bulge(Vec2::new(x1, y1 - r), bul),
|
||||
PolyVertex::new(Vec2::new(x1 - r, y1)),
|
||||
PolyVertex::with_bulge(Vec2::new(x0 + r, y1), bul),
|
||||
PolyVertex::new(Vec2::new(x0, y1 - r)),
|
||||
PolyVertex::with_bulge(Vec2::new(x0, y0 + r), bul),
|
||||
];
|
||||
let corners = [a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)];
|
||||
let short = (b.x - a.x).abs().min((b.y - a.y).abs());
|
||||
let (d_in, d_out, bulge) = if fillet > 0.0 && fillet * 2.0 <= short + 1e-12 {
|
||||
// Counter-clockwise round the corners (a positive bulge) when `b` is up and right of `a`, or
|
||||
// down and left.
|
||||
let sign = if (b.x - a.x) * (b.y - a.y) >= 0.0 { 1.0 } else { -1.0 };
|
||||
(fillet, fillet, sign * cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2))
|
||||
} else if chamfer.0 > 0.0 && chamfer.1 > 0.0 && chamfer.0 + chamfer.1 <= short + 1e-12 {
|
||||
(chamfer.0, chamfer.1, 0.0)
|
||||
} else {
|
||||
return corners.into_iter().map(PolyVertex::new).collect();
|
||||
};
|
||||
let mut out = Vec::with_capacity(8);
|
||||
for (i, cur) in corners.iter().enumerate() {
|
||||
let (Some(prev), Some(next)) = (corners.get((i + 3) % 4), corners.get((i + 1) % 4)) else { continue };
|
||||
out.push(PolyVertex::with_bulge(*cur + (*prev - *cur).normalized() * d_in, bulge));
|
||||
out.push(PolyVertex::new(*cur + (*next - *cur).normalized() * d_out));
|
||||
}
|
||||
let (c1, c2) = chamfer;
|
||||
if c1 > 0.0 && c2 > 0.0 && c1 + c1 <= w + 1e-12 && c2 + c2 <= h + 1e-12 {
|
||||
return [
|
||||
Vec2::new(x0 + c1, y0),
|
||||
Vec2::new(x1 - c1, y0),
|
||||
Vec2::new(x1, y0 + c2),
|
||||
Vec2::new(x1, y1 - c2),
|
||||
Vec2::new(x1 - c1, y1),
|
||||
Vec2::new(x0 + c1, y1),
|
||||
Vec2::new(x0, y1 - c2),
|
||||
Vec2::new(x0, y0 + c2),
|
||||
]
|
||||
.into_iter()
|
||||
.map(PolyVertex::new)
|
||||
.collect();
|
||||
}
|
||||
rect_vertices(Vec2::new(x0, y0), Vec2::new(x1, y1))
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- the user's support files ----------
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("scale", "Scale", run_scale)
|
||||
.menu(&["Modify", "Scale"])
|
||||
.alias(&["sc"])
|
||||
.params("{handles?, base: [x,y], factor, copy?: bool}")
|
||||
.params("{handles?, base: [x,y] or [x,y,z] (default Z: ELEVATION), factor, copy?: bool}")
|
||||
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))),
|
||||
CommandSpec::new("mirror", "Mirror", run_mirror)
|
||||
.menu(&["Modify", "Mirror"])
|
||||
|
||||
@@ -212,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handle": nh.hex() }))
|
||||
}
|
||||
|
||||
/// The copy at the source's height, as AutoCAD 2024's OFFSET makes it: a line's end Zs, an arc's
|
||||
/// or circle's centre Z, a polyline's elevation (an ellipse's offset, a polyline, at its centre Z).
|
||||
fn keep_z(src: &EntityKind, k: &mut EntityKind) {
|
||||
match (src, k) {
|
||||
(EntityKind::Line(s), EntityKind::Line(l)) => {
|
||||
l.a.z = s.a.z;
|
||||
l.b.z = s.b.z;
|
||||
}
|
||||
(EntityKind::Circle(s), EntityKind::Circle(c)) => c.center.z = s.center.z,
|
||||
(EntityKind::Arc(s), EntityKind::Arc(a)) => a.center.z = s.center.z,
|
||||
(EntityKind::LwPolyline(s), EntityKind::LwPolyline(p)) => p.elevation = s.elevation,
|
||||
(EntityKind::Ellipse(s), EntityKind::LwPolyline(p)) => p.elevation = s.center.z,
|
||||
(EntityKind::Ray(s) | EntityKind::XLine(s), EntityKind::Ray(r) | EntityKind::XLine(r)) => r.base.z = s.base.z,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the offset copy `k` of `src` (on the current layer instead of the source's when
|
||||
/// `current_layer`), then erase the source when `erase` and its layer is not locked.
|
||||
fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
|
||||
/// `current_layer`), then erase the source when `erase` and its layer is not locked. The copy is
|
||||
/// at the source's height ([`keep_z`]); a circle's or arc's copy has no thickness, a polyline's
|
||||
/// keeps it, as AutoCAD 2024 makes them.
|
||||
fn add_offset(s: &mut Session, src: &Entity, mut k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
|
||||
keep_z(&src.kind, &mut k);
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let mut common = src.common.clone();
|
||||
if matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_)) {
|
||||
common.thickness = 0.0;
|
||||
}
|
||||
if current_layer {
|
||||
common.layer = d.header.str("CLAYER", "0");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::break_join::join;
|
||||
use super::explode::explode;
|
||||
use super::transform::{erase, transform_entities};
|
||||
use super::transform::{at_elevation, erase, scale_entities, transform_entities};
|
||||
use super::{
|
||||
Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number,
|
||||
};
|
||||
@@ -242,7 +242,7 @@ impl SelectThen {
|
||||
let f = require_length(Some(f))?;
|
||||
// The next SCALE offers this factor.
|
||||
s.last_used.scale_factor = f;
|
||||
transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?;
|
||||
scale_entities(s, &self.objs, at_elevation(s, base), f, self.copy_mode)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
@@ -439,13 +439,13 @@ impl Interactive for SelectThen {
|
||||
};
|
||||
if f > 1e-12 && f.is_finite() {
|
||||
s.last_used.scale_factor = f;
|
||||
transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?;
|
||||
scale_entities(s, &self.objs, at_elevation(s, self.pts[0]), f, self.copy_mode)?;
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Op::Mirror, 1, Input::Point(p)) => {
|
||||
if p.near(self.pts[0], 1e-12) {
|
||||
if p.near(self.pts[0], cadcraft_geom::EPS) {
|
||||
return Err(EngineError::Other("Mirror line points must differ.".into()));
|
||||
}
|
||||
self.pts.push(p);
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets};
|
||||
|
||||
// ---------------- core operations ----------------
|
||||
|
||||
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
|
||||
edit_entities(s, hs, copy, |e| e.kind.transform(m))
|
||||
}
|
||||
|
||||
/// Scale `hs` by `f` about `base` in 3D, as AutoCAD 2024's SCALE does: about the base point's XY,
|
||||
/// and every height of the objects about its Z, their thickness too.
|
||||
pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result<Vec<Handle>> {
|
||||
let m = Mat3::scale_about(base.xy(), f);
|
||||
edit_entities(s, hs, copy, |e| {
|
||||
e.kind.transform(&m);
|
||||
e.kind.scale_heights(base.z, f);
|
||||
e.common.thickness *= f;
|
||||
})
|
||||
}
|
||||
|
||||
/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one.
|
||||
pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 {
|
||||
let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite());
|
||||
Vec3::new(p.x, p.y, z.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Apply `edit` to each of `hs` still in the drawing, or to a copy of each when `copy`; an object
|
||||
/// on a locked layer is left alone unless it is copied. The handles edited or made.
|
||||
fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut Entity)) -> Result<Vec<Handle>> {
|
||||
let d = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
@@ -11,7 +36,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
|
||||
let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue };
|
||||
let nh = d.new_handle();
|
||||
let mut ne = Entity { handle: nh, ..e };
|
||||
ne.kind.transform(m);
|
||||
edit(&mut ne);
|
||||
if let Some(st) = d.space_mut(&sp) {
|
||||
st.push(ne);
|
||||
out.push(nh);
|
||||
@@ -21,7 +46,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
|
||||
if locked {
|
||||
continue;
|
||||
}
|
||||
d.modify_entity(*h, |e| e.kind.transform(m))?;
|
||||
d.modify_entity(*h, &edit)?;
|
||||
out.push(*h);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +117,12 @@ pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if f <= 0.0 {
|
||||
return Err(bad("scale", "factor must be positive"));
|
||||
}
|
||||
let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?;
|
||||
// A Z given with the base point, as AutoCAD takes one; without one, the current elevation.
|
||||
let base = match p.get("base").and_then(|b| b.get(2)) {
|
||||
Some(z) => z.as_f64().map(|z| Vec3::new(base.x, base.y, z)).ok_or_else(|| bad("scale", "`base` Z must be a number"))?,
|
||||
None => at_elevation(s, base),
|
||||
};
|
||||
let r = scale_entities(s, &hs, base, f, bool_or(p, "copy", false))?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -100,7 +130,7 @@ pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let a = point_req("mirror", p, "p1")?;
|
||||
let b = point_req("mirror", p, "p2")?;
|
||||
if a.near(b, 1e-12) {
|
||||
if a.near(b, cadcraft_geom::EPS) {
|
||||
return Err(bad("mirror", "mirror line points coincide"));
|
||||
}
|
||||
let erase_src = bool_or(p, "erase", false);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units,
|
||||
//! RENAME.
|
||||
|
||||
mod change;
|
||||
mod chprop;
|
||||
mod dash_units;
|
||||
mod matchprop;
|
||||
mod rename;
|
||||
|
||||
@@ -23,6 +25,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("chprop", "Change Properties", chprop::run_chprop)
|
||||
.params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
|
||||
.interactive(|_| Ok(Box::new(chprop::ChpropM::new()))),
|
||||
CommandSpec::new("change", "Change", change::run_change)
|
||||
.alias(&["-ch"])
|
||||
.params("{handles?, point?: [x, y], elevation?} → {changed, ignored, differingZ, detachedHatches, locked}: the point moves the nearer end of each line to it (level or plumb with the other end under ORTHOMODE) and, when it is the one object, passes a circle through it or places a text, attribute definition or block reference at it (`ignored` otherwise); `elevation` sets the z of objects whose points share one (`differingZ` otherwise); objects on locked layers are left out (the command line asks Specify change point or [Properties], then about each circle, text, attribute definition and block reference in turn)")
|
||||
.interactive(|_| Ok(Box::new(change::ChangeM::new()))),
|
||||
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
|
||||
.menu(&["Modify", "Match Properties"])
|
||||
.alias(&["ma", "painter"])
|
||||
@@ -38,6 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
|
||||
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
|
||||
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
|
||||
CommandSpec::new("-units", "Units", run_units).alias(&["-un"]).params("(as UNITS)").interactive(|_| Ok(Box::new(dash_units::DashUnits::default()))),
|
||||
CommandSpec::new("thickness", "Thickness", run_thickness)
|
||||
.menu(&["Format", "Thickness"])
|
||||
.params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}")
|
||||
@@ -1181,6 +1188,11 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?;
|
||||
}
|
||||
"linetype" | "ltype" => {
|
||||
// Stored trimmed, as -RENAME types it: lookups compare names untrimmed.
|
||||
let to = to.trim().to_string();
|
||||
if !rename::valid_name(&to) {
|
||||
return Err(bad("rename", format!("`{to}` is not a valid linetype name")));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad("rename", format!("the {from} linetype cannot be renamed")));
|
||||
@@ -1246,6 +1258,19 @@ mod tests {
|
||||
assert_eq!(col(&s), Color::ByBlock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_linetype_rejects_invalid_names() {
|
||||
let mut s = Session::new();
|
||||
s.execute("linetype", &json!({ "load": "DASHED" })).unwrap();
|
||||
for to in ["", " ", "A;B", "A|B", "*X"] {
|
||||
let r = s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": to }));
|
||||
assert!(r.is_err(), "renamed DASHED to {to:?}: {r:?}");
|
||||
assert!(s.doc().unwrap().linetype("DASHED").is_some());
|
||||
}
|
||||
s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": " DASH2 " })).unwrap();
|
||||
assert!(s.doc().unwrap().linetype("DASH2").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_set_rejects_unloaded_linetype() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! CHANGE: change objects by a change point, or change their properties, as AutoCAD 2024 does.
|
||||
//!
|
||||
//! "Specify change point or [Properties]" follows "Select objects", which leaves objects on locked
|
||||
//! layers out as CHPROP does. Properties is CHPROP's loop with Elev added: the elevation is set
|
||||
//! when the command ends, and objects whose points differ in z keep theirs ("Cannot change
|
||||
//! elevation of objects with differing Z coordinates."). A change point, or Enter, goes through the
|
||||
//! objects in the order they were selected. Measured from AutoLISP's `command` (accoreconsole,
|
||||
//! every object read back with `entget`):
|
||||
//! - each line moves its end nearer the point to it: (0,0)–(50,0) with (60,10) gives
|
||||
//! (0,0)–(60,10), with (−5,5) gives (−5,5)–(50,0). Under ORTHOMODE the moved end stays level or
|
||||
//! plumb with the other one ((60,0)). Enter leaves lines alone;
|
||||
//! - a circle passes through the point (centre (100,0) and (115,0): radius 15), or asks "Specify
|
||||
//! new circle radius <no change>";
|
||||
//! - a text is placed by the point (the alignment point of justified text), or asks "Specify new
|
||||
//! text insertion point <no change>"; then "Enter new text style", "Specify new height" (not
|
||||
//! with a style of fixed height, nor for Aligned text), "Specify new rotation angle" (not for
|
||||
//! Aligned or Fit text, whose two points set it) and "Enter new text". An
|
||||
//! attribute definition asks "Enter new tag", "Enter new prompt" and "Enter new default value" in
|
||||
//! place of the text. A style that does not exist is refused: `Style "5" not defined - use STYLE
|
||||
//! command.`;
|
||||
//! - a block reference is placed by the point, its attributes moving and turning with it, or asks
|
||||
//! "Specify new block insertion point <no change>"; then "Specify new block rotation angle";
|
||||
//! - the point places a circle, text or block reference only when it is the one object selected:
|
||||
//! with others, "Change point ignored" comes before that object's questions, which are asked as
|
||||
//! for Enter;
|
||||
//! - other objects (arcs, polylines…) are left alone, and with nothing to change it says "No
|
||||
//! changeable object selected".
|
||||
//!
|
||||
//! Elev sets the z of lines, 3D polylines and solids whose points share one, and of points,
|
||||
//! circles, arcs, ellipses, polylines, hatches (no longer associative: "Hatch boundary
|
||||
//! associativity removed."), texts, multiline texts and block references.
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use cadcraft_doc::{Attrib, EntityKind, HAlign, Handle, Text};
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::{bad, f64_req, point_req, targets};
|
||||
use super::chprop::{ChpropM, begin_unlocked, feed_unlocked, locked};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// A question CHANGE asks about one object.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Ask {
|
||||
/// Not a question: "Change point ignored" as the object comes up.
|
||||
Ignored,
|
||||
Radius,
|
||||
TextPoint,
|
||||
Style,
|
||||
Height,
|
||||
Rotation,
|
||||
Value,
|
||||
Tag,
|
||||
AttPrompt,
|
||||
Default,
|
||||
BlockPoint,
|
||||
BlockRotation,
|
||||
}
|
||||
|
||||
enum Stage {
|
||||
Select,
|
||||
Point,
|
||||
Properties(Box<ChpropM>),
|
||||
/// The questions left, in order, each about one object.
|
||||
Asking(VecDeque<(Handle, Ask)>),
|
||||
}
|
||||
|
||||
pub(super) struct ChangeM {
|
||||
select: SelectPhase,
|
||||
handles: Vec<Handle>,
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
impl ChangeM {
|
||||
pub(super) fn new() -> Self {
|
||||
ChangeM { select: SelectPhase::default(), handles: Vec::new(), stage: Stage::Select }
|
||||
}
|
||||
|
||||
/// Apply the change point `p` (`None`: Enter) and line up the questions it leaves.
|
||||
fn change_point(&mut self, s: &mut Session, p: Option<Vec2>) -> Result<Step> {
|
||||
let alone = self.handles.len() == 1;
|
||||
let ortho = s.drafting_settings().orthomode;
|
||||
let mut asks = VecDeque::new();
|
||||
let mut changeable = false;
|
||||
for &h in &self.handles {
|
||||
let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
|
||||
if matches!(kind, EntityKind::Line(_)) {
|
||||
if let Some(p) = p {
|
||||
move_end(s, h, p, ortho)?;
|
||||
changeable = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(questions) = questions(&kind) else { continue };
|
||||
changeable = true;
|
||||
let placed = p.filter(|_| alone);
|
||||
if p.is_some() && !alone {
|
||||
asks.push_back((h, Ask::Ignored));
|
||||
}
|
||||
if let Some(q) = placed {
|
||||
place(s, h, q)?;
|
||||
}
|
||||
// The point answers the first question: the radius or the insertion point.
|
||||
asks.extend(questions.iter().skip(usize::from(placed.is_some())).map(|a| (h, *a)));
|
||||
}
|
||||
if !changeable {
|
||||
s.echo("No changeable object selected");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
self.stage = Stage::Asking(asks);
|
||||
self.advance(s)
|
||||
}
|
||||
|
||||
/// Pass over what needs no answer: say "Change point ignored", and skip what [`skipped`]
|
||||
/// says isn't asked. Done when nothing is left to ask.
|
||||
fn advance(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let Stage::Asking(asks) = &mut self.stage else { return Ok(Step::Continue) };
|
||||
while let Some(&(h, ask)) = asks.front() {
|
||||
match ask {
|
||||
Ask::Ignored => s.echo("Change point ignored"),
|
||||
a if skipped(s, h, a) => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
asks.pop_front();
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// The questions about an object of `kind`, the first answered by the change point when it is
|
||||
/// the one object; `None` when the change point leaves it alone.
|
||||
fn questions(kind: &EntityKind) -> Option<&'static [Ask]> {
|
||||
Some(match kind {
|
||||
EntityKind::Circle(_) => &[Ask::Radius],
|
||||
EntityKind::Text(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Value],
|
||||
EntityKind::AttDef(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Tag, Ask::AttPrompt, Ask::Default],
|
||||
EntityKind::Insert(_) => &[Ask::BlockPoint, Ask::BlockRotation],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn text_of(kind: &EntityKind) -> Option<&Text> {
|
||||
match kind {
|
||||
EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of_mut(kind: &mut EntityKind) -> Option<&mut Text> {
|
||||
match kind {
|
||||
EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a circle, text or block reference is measured from: its centre, the point it is placed by
|
||||
/// (the alignment point of justified text) or its insertion point.
|
||||
fn anchor(kind: &EntityKind) -> Option<Vec2> {
|
||||
match kind {
|
||||
EntityKind::Circle(c) => Some(c.center.xy()),
|
||||
EntityKind::Insert(i) => Some(i.insert.xy()),
|
||||
k => text_of(k).map(Text::justify_point),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether CHANGE passes over `ask` for text `h`: the height of a text whose style has a fixed
|
||||
/// one or that is Aligned, and the rotation of Aligned or Fit text (their two points set it).
|
||||
fn skipped(s: &Session, h: Handle, ask: Ask) -> bool {
|
||||
let Ok(d) = s.doc() else { return false };
|
||||
let Some(t) = d.entity(h).and_then(|e| text_of(&e.kind)) else { return false };
|
||||
match ask {
|
||||
Ask::Height => t.halign == HAlign::Aligned || d.text_style(&t.style).is_some_and(|st| st.height > 0.0),
|
||||
Ask::Rotation => matches!(t.halign, HAlign::Aligned | HAlign::Fit),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the end of line `h` nearer `p` to it; under ortho, level or plumb with the other end.
|
||||
fn move_end(s: &mut Session, h: Handle, p: Vec2, ortho: bool) -> Result<()> {
|
||||
let settings = s.drafting_settings();
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Line(l) = &mut e.kind {
|
||||
let (end, other) = if l.a.xy().dist(p) <= l.b.xy().dist(p) { (&mut l.a, l.b) } else { (&mut l.b, l.a) };
|
||||
let q = if ortho { crate::snap::iso::constrain(&settings, other.xy(), p) } else { p };
|
||||
end.x = q.x;
|
||||
end.y = q.y;
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Place circle, text or block reference `h` by `q`: a circle passes through it, a text or block
|
||||
/// reference moves there (with its attributes).
|
||||
fn place(s: &mut Session, h: Handle, q: Vec2) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Circle(c) = &mut e.kind {
|
||||
let r = c.center.xy().dist(q);
|
||||
if r > 0.0 && r.is_finite() {
|
||||
c.radius = r;
|
||||
}
|
||||
} else if let Some(at) = anchor(&e.kind) {
|
||||
e.kind.transform(&Mat3::translate(q - at));
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a circle's radius or a text's height.
|
||||
fn set_length(s: &mut Session, h: Handle, v: f64) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| match &mut e.kind {
|
||||
EntityKind::Circle(c) => c.radius = v,
|
||||
k => {
|
||||
if let Some(t) = text_of_mut(k) {
|
||||
t.height = v;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a text's rotation, or turn a block reference (and its attributes) to `angle` about its
|
||||
/// insertion point.
|
||||
fn set_rotation(s: &mut Session, h: Handle, angle: f64) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(i) = &e.kind {
|
||||
let m = Mat3::rotate_about(i.insert.xy(), angle - i.rotation);
|
||||
e.kind.transform(&m);
|
||||
} else if let Some(t) = text_of_mut(&mut e.kind) {
|
||||
t.rotation = cadcraft_geom::norm_angle(angle);
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take an answer to `ask` about `h`; `false` when it was refused (the question comes again).
|
||||
fn answer(s: &mut Session, h: Handle, ask: Ask, i: Input) -> Result<bool> {
|
||||
let at = s.doc()?.entity(h).and_then(|e| anchor(&e.kind)).unwrap_or(Vec2::ZERO);
|
||||
match (ask, i.clone()) {
|
||||
// Enter keeps what there is.
|
||||
(_, Input::Enter) => {}
|
||||
(Ask::TextPoint | Ask::BlockPoint, Input::Point(q)) => place(s, h, q)?,
|
||||
(Ask::TextPoint | Ask::BlockPoint, _) => return Err(EngineError::Other("Point or option keyword required.".into())),
|
||||
(Ask::Radius | Ask::Height, Input::Point(_) | Input::Text(_)) => {
|
||||
let v = match &i {
|
||||
Input::Point(q) => q.dist(at),
|
||||
Input::Text(t) => number(t).ok_or_else(|| EngineError::Other("Requires numeric distance or second point.".into()))?,
|
||||
_ => 0.0,
|
||||
};
|
||||
if !(v > 0.0 && v.is_finite()) {
|
||||
return Err(EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
set_length(s, h, v)?;
|
||||
}
|
||||
(Ask::Rotation | Ask::BlockRotation, Input::Point(q)) => set_rotation(s, h, at.angle_to(q))?,
|
||||
(Ask::Rotation | Ask::BlockRotation, Input::Text(t)) => {
|
||||
let a = s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?;
|
||||
set_rotation(s, h, a)?;
|
||||
}
|
||||
(Ask::Style, Input::Text(t) | Input::Keyword(t)) => {
|
||||
let name = t.trim();
|
||||
let Some(style) = s.doc()?.text_style(name).map(|st| st.name.clone()) else {
|
||||
s.echo(format!("Style \"{name}\" not defined - use STYLE command."));
|
||||
return Ok(false);
|
||||
};
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let Some(t) = text_of_mut(&mut e.kind) {
|
||||
t.style = style;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
(Ask::Value | Ask::Tag | Ask::AttPrompt | Ask::Default, Input::Text(t) | Input::Keyword(t)) => {
|
||||
s.doc_mut()?.modify_entity(h, |e| match (&mut e.kind, ask) {
|
||||
(EntityKind::AttDef(a), Ask::Tag) => a.tag = t,
|
||||
(EntityKind::AttDef(a), Ask::AttPrompt) => a.prompt = t,
|
||||
(k, _) => {
|
||||
if let Some(x) = text_of_mut(k) {
|
||||
x.value = t;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The question `ask` about `h`.
|
||||
fn ask_prompt(s: &Session, h: Handle, ask: Ask) -> Prompt {
|
||||
let d = s.doc().ok();
|
||||
let kind = d.and_then(|d| d.entity(h)).map(|e| &e.kind);
|
||||
let at = kind.and_then(anchor);
|
||||
let text = kind.and_then(text_of);
|
||||
// As the drawing shows directions: from ANGBASE in the ANGDIR sense (ANGBASE 90° shows a text
|
||||
// at 0° as 270.00).
|
||||
let deg = |a: f64| format!("{:.2}", s.angle_settings().shown(a).to_degrees());
|
||||
match ask {
|
||||
Ask::Radius => Prompt::new("Specify new circle radius", Accept::POINT_OR_NUMBER).default("no change").base_opt(at).distance(),
|
||||
Ask::TextPoint => Prompt::new("Specify new text insertion point", Accept::POINT).default("no change"),
|
||||
Ask::BlockPoint => Prompt::new("Specify new block insertion point", Accept::POINT).default("no change"),
|
||||
Ask::Style => Prompt::new("Enter new text style", Accept::TEXT).default(text.map(|t| t.style.clone()).unwrap_or_default()),
|
||||
Ask::Height => Prompt::new("Specify new height", Accept::POINT_OR_NUMBER)
|
||||
.default(format!("{:.4}", text.map_or(0.0, |t| t.height)))
|
||||
.base_opt(at)
|
||||
.distance(),
|
||||
Ask::Rotation => {
|
||||
Prompt::new("Specify new rotation angle", Accept::POINT_OR_NUMBER).default(deg(text.map_or(0.0, |t| t.rotation))).base_opt(at)
|
||||
}
|
||||
Ask::BlockRotation => {
|
||||
let r = match kind {
|
||||
Some(EntityKind::Insert(i)) => i.rotation,
|
||||
_ => 0.0,
|
||||
};
|
||||
Prompt::new("Specify new block rotation angle", Accept::POINT_OR_NUMBER).default(deg(r)).base_opt(at)
|
||||
}
|
||||
Ask::Value => Prompt::new("Enter new text", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()),
|
||||
Ask::Default => Prompt::new("Enter new default value", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()),
|
||||
Ask::Tag | Ask::AttPrompt => {
|
||||
let (msg, v) = match (kind, ask) {
|
||||
(Some(EntityKind::AttDef(a)), Ask::Tag) => ("Enter new tag", a.tag.clone()),
|
||||
(Some(EntityKind::AttDef(a)), _) => ("Enter new prompt", a.prompt.clone()),
|
||||
(_, Ask::Tag) => ("Enter new tag", String::new()),
|
||||
_ => ("Enter new prompt", String::new()),
|
||||
};
|
||||
Prompt::new(msg, Accept::TEXT).default(v)
|
||||
}
|
||||
Ask::Ignored => Prompt::new("", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one z of `zs`, to rounding; `None` when they differ.
|
||||
fn shared_z(zs: impl IntoIterator<Item = f64>) -> Option<f64> {
|
||||
let mut zs = zs.into_iter();
|
||||
let first = zs.next().unwrap_or(0.0);
|
||||
zs.all(|z| (z - first).abs() <= 1e-9 * (1.0 + first.abs())).then_some(first)
|
||||
}
|
||||
|
||||
/// The elevation of an object Elev sets: `Some(None)` when its points differ in z (a line between
|
||||
/// two heights), `None` for the kinds Elev leaves alone.
|
||||
pub(super) fn elevation_of(kind: &EntityKind) -> Option<Option<f64>> {
|
||||
match kind {
|
||||
EntityKind::Line(l) => Some(shared_z([l.a.z, l.b.z])),
|
||||
EntityKind::Polyline3d(p) => Some(shared_z(p.points.iter().map(|q| q.z))),
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => Some(shared_z(so.corners.iter().map(|c| c.z))),
|
||||
EntityKind::Point(p) => Some(Some(p.p.z)),
|
||||
EntityKind::Circle(c) => Some(Some(c.center.z)),
|
||||
EntityKind::Arc(a) => Some(Some(a.center.z)),
|
||||
EntityKind::Ellipse(e) => Some(Some(e.center.z)),
|
||||
EntityKind::LwPolyline(p) => Some(Some(p.elevation)),
|
||||
EntityKind::Hatch(h) => Some(Some(h.elevation)),
|
||||
EntityKind::MText(t) => Some(Some(t.insert.z)),
|
||||
EntityKind::Insert(i) => Some(Some(i.insert.z)),
|
||||
k => text_of(k).map(|t| Some(t.insert.z)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `text` up by `dz`.
|
||||
fn raise_text(text: &mut Text, dz: f64) {
|
||||
for p in std::iter::once(&mut text.insert).chain(text.align_pt.as_mut()) {
|
||||
p.z += dz;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_elevation(kind: &mut EntityKind, z: f64) {
|
||||
let set = |p: &mut Vec3| p.z = z;
|
||||
match kind {
|
||||
EntityKind::Line(l) => {
|
||||
set(&mut l.a);
|
||||
set(&mut l.b);
|
||||
}
|
||||
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(set),
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(set),
|
||||
EntityKind::Point(p) => set(&mut p.p),
|
||||
EntityKind::Circle(c) => set(&mut c.center),
|
||||
EntityKind::Arc(a) => set(&mut a.center),
|
||||
EntityKind::Ellipse(e) => set(&mut e.center),
|
||||
EntityKind::LwPolyline(p) => p.elevation = z,
|
||||
EntityKind::Hatch(h) => {
|
||||
h.elevation = z;
|
||||
crate::cmd::hatch::detach(h);
|
||||
}
|
||||
EntityKind::MText(t) => set(&mut t.insert),
|
||||
// A block reference's attributes go up with it.
|
||||
EntityKind::Insert(i) => {
|
||||
let dz = z - i.insert.z;
|
||||
set(&mut i.insert);
|
||||
for a in &mut i.attribs {
|
||||
raise_text(&mut a.text, dz);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
ml.mtext.insert.z += dz;
|
||||
}
|
||||
}
|
||||
}
|
||||
k => {
|
||||
if let Some(t) = text_of_mut(k) {
|
||||
let dz = z - t.insert.z;
|
||||
raise_text(t, dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`elevate`] did.
|
||||
pub(super) struct Elevated {
|
||||
/// The objects whose elevation was set.
|
||||
pub(super) set: Vec<Handle>,
|
||||
/// Objects whose points differ in z, which keep theirs.
|
||||
pub(super) differing: usize,
|
||||
/// Associative hatches, which no longer are.
|
||||
pub(super) detached: usize,
|
||||
}
|
||||
|
||||
/// Set the elevation of the objects among `hs` whose points share one.
|
||||
pub(super) fn elevate(s: &mut Session, hs: &[Handle], z: f64) -> Result<Elevated> {
|
||||
let d = s.doc_mut()?;
|
||||
let mut r = Elevated { set: Vec::new(), differing: 0, detached: 0 };
|
||||
for &h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
match elevation_of(&e.kind) {
|
||||
Some(Some(_)) => {
|
||||
r.detached += usize::from(matches!(&e.kind, EntityKind::Hatch(hatch) if hatch.associative));
|
||||
d.modify_entity(h, |e| set_elevation(&mut e.kind, z))?;
|
||||
r.set.push(h);
|
||||
}
|
||||
Some(None) => r.differing += 1,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Properties ▸ Elev as the command ends: set the elevation, and say what was left alone or
|
||||
/// changed besides.
|
||||
pub(super) fn elevate_and_say(s: &mut Session, hs: &[Handle], z: f64) -> Result<()> {
|
||||
let r = elevate(s, hs, z)?;
|
||||
if r.differing > 0 {
|
||||
s.echo("Cannot change elevation of objects with differing Z coordinates.");
|
||||
}
|
||||
if r.detached > 0 {
|
||||
s.echo("Hatch boundary associativity removed.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CHANGE's JSON form: `point` changes the objects as the change point does (`ignored`: circles,
|
||||
/// texts and block references it leaves alone because others were selected too), `elevation` as
|
||||
/// Properties ▸ Elev does (`differingZ`: objects whose points differ in z; `detachedHatches`:
|
||||
/// associative hatches that no longer are); objects on locked layers are left out (`locked`).
|
||||
pub(super) fn run_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A point or an elevation that can't be read is refused, and so is a call with neither.
|
||||
let point = p.get("point").map(|_| point_req("change", p, "point")).transpose()?;
|
||||
let elevation = p.get("elevation").map(|_| f64_req("change", p, "elevation")).transpose()?;
|
||||
if point.is_none() && elevation.is_none() {
|
||||
return Err(bad("change", "`point` or `elevation` is required"));
|
||||
}
|
||||
let hs = targets(s, p)?;
|
||||
let locked = locked(s, &hs);
|
||||
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
|
||||
let (mut changed, mut ignored, mut differing, mut detached) = (HashSet::new(), 0, 0, 0);
|
||||
if let Some(q) = point {
|
||||
let ortho = s.drafting_settings().orthomode;
|
||||
for &h in &free {
|
||||
let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
|
||||
if matches!(kind, EntityKind::Line(_)) {
|
||||
move_end(s, h, q, ortho)?;
|
||||
changed.insert(h);
|
||||
} else if questions(&kind).is_some() {
|
||||
if free.len() == 1 {
|
||||
place(s, h, q)?;
|
||||
changed.insert(h);
|
||||
} else {
|
||||
ignored += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(z) = elevation {
|
||||
let r = elevate(s, &free, z)?;
|
||||
changed.extend(r.set);
|
||||
(differing, detached) = (r.differing, r.detached);
|
||||
}
|
||||
Ok(json!({ "changed": changed.len(), "ignored": ignored, "differingZ": differing, "detachedHatches": detached, "locked": locked.len() }))
|
||||
}
|
||||
|
||||
impl Interactive for ChangeM {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHANGE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.select = begin_unlocked(s);
|
||||
if self.select.done {
|
||||
self.handles = self.select.picked.clone();
|
||||
self.stage = Stage::Point;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match &self.stage {
|
||||
Stage::Select => self.select.prompt(),
|
||||
Stage::Point => Prompt::new("Specify change point", Accept::POINT).kw(&["Properties"]),
|
||||
Stage::Properties(m) => m.prompt(s),
|
||||
Stage::Asking(asks) => asks.front().map_or_else(|| Prompt::new("", Accept::TEXT), |&(h, ask)| ask_prompt(s, h, ask)),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match &mut self.stage {
|
||||
Stage::Select => {
|
||||
return Ok(match feed_unlocked(&mut self.select, s, i)? {
|
||||
SelOutcome::More => Step::Continue,
|
||||
SelOutcome::Done(hs) => {
|
||||
self.handles = hs;
|
||||
self.stage = Stage::Point;
|
||||
Step::Continue
|
||||
}
|
||||
SelOutcome::Empty => {
|
||||
s.set_selection(Vec::new());
|
||||
Step::Done
|
||||
}
|
||||
});
|
||||
}
|
||||
Stage::Properties(m) => return m.input(s, i),
|
||||
Stage::Asking(asks) => {
|
||||
let Some(&(h, ask)) = asks.front() else { return Ok(Step::Done) };
|
||||
if !answer(s, h, ask, i)? {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
asks.pop_front();
|
||||
return self.advance(s);
|
||||
}
|
||||
Stage::Point => {}
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) if k == "Properties" => {
|
||||
self.stage = Stage::Properties(Box::new(ChpropM::change_properties(self.handles.clone())));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => self.change_point(s, Some(p)),
|
||||
Input::Enter => self.change_point(s, None),
|
||||
_ => Err(EngineError::Other("Point or option keyword required.".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
|
||||
|
||||
/// CHANGE's Properties: CHPROP's list with Elev.
|
||||
const CHANGE_PROPERTIES: [&str; 8] = ["Color", "Elev", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
|
||||
|
||||
/// What CHPROP asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Ask {
|
||||
@@ -40,6 +43,7 @@ enum Ask {
|
||||
Lineweight,
|
||||
Thickness,
|
||||
Transparency,
|
||||
Elevation,
|
||||
}
|
||||
|
||||
pub(super) struct ChpropM {
|
||||
@@ -47,11 +51,35 @@ pub(super) struct ChpropM {
|
||||
handles: Vec<Handle>,
|
||||
/// `None` while objects are selected.
|
||||
ask: Option<Ask>,
|
||||
/// CHANGE's Properties: Elev is on the list.
|
||||
change: bool,
|
||||
/// The elevation typed at Elev, set when the command ends.
|
||||
elevation: Option<f64>,
|
||||
}
|
||||
|
||||
impl ChpropM {
|
||||
pub(super) fn new() -> Self {
|
||||
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None }
|
||||
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None, change: false, elevation: None }
|
||||
}
|
||||
|
||||
/// CHANGE's Properties option on `handles`.
|
||||
pub(super) fn change_properties(handles: Vec<Handle>) -> Self {
|
||||
ChpropM { select: SelectPhase::default(), handles, ask: Some(Ask::Property), change: true, elevation: None }
|
||||
}
|
||||
|
||||
fn keywords(&self) -> &'static [&'static str] {
|
||||
if self.change { &CHANGE_PROPERTIES } else { &PROPERTIES }
|
||||
}
|
||||
|
||||
/// The selection's common elevation, or "varies" (as a line between two heights does alone).
|
||||
fn common_elevation(&self, s: &Session) -> String {
|
||||
let Ok(d) = s.doc() else { return String::new() };
|
||||
let zs: Vec<Option<f64>> = self.handles.iter().filter_map(|h| d.entity(*h)).filter_map(|e| super::change::elevation_of(&e.kind)).collect();
|
||||
match zs.first() {
|
||||
None => format!("{:.4}", 0.0),
|
||||
Some(Some(z)) if zs.iter().all(|o| *o == Some(*z)) => format!("{z:.4}"),
|
||||
Some(_) => "varies".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection's common value of a property, or "varies".
|
||||
@@ -151,6 +179,13 @@ impl ChpropM {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ask::Elevation => match number(word).filter(|v| v.is_finite()) {
|
||||
Some(z) => self.elevation = Some(z),
|
||||
None => {
|
||||
s.echo("Requires a number.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Property => {}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -158,7 +193,7 @@ impl ChpropM {
|
||||
}
|
||||
|
||||
/// The objects among `hs` on locked layers.
|
||||
fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
|
||||
pub(super) fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
|
||||
hs.iter().copied().filter(|h| is_locked(s, *h)).collect()
|
||||
}
|
||||
|
||||
@@ -170,6 +205,38 @@ fn say_locked(s: &mut Session, n: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start selecting objects, leaving those on locked layers out of the pickfirst selection (and
|
||||
/// saying how many); `done` when some of it is left.
|
||||
pub(super) fn begin_unlocked(s: &mut Session) -> SelectPhase {
|
||||
let mut select = SelectPhase::begin(s);
|
||||
if select.done {
|
||||
let locked = locked(s, &select.picked);
|
||||
if !locked.is_empty() {
|
||||
select.picked.retain(|h| !locked.contains(h));
|
||||
say_locked(s, locked.len());
|
||||
s.set_selection(select.picked.clone());
|
||||
// Nothing left of the pickfirst selection: ask for objects instead.
|
||||
select.done = !select.picked.is_empty();
|
||||
}
|
||||
}
|
||||
select
|
||||
}
|
||||
|
||||
/// Feed `i` to `select`, leaving picked objects on locked layers out and saying how many after
|
||||
/// what was found.
|
||||
pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input) -> Result<SelOutcome> {
|
||||
let (i, n) = match i {
|
||||
Input::Pick(hs) if !select.removing => {
|
||||
let locked = locked(s, &hs);
|
||||
(Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len())
|
||||
}
|
||||
i => (i, 0),
|
||||
};
|
||||
let outcome = select.feed(s, &i)?;
|
||||
say_locked(s, n);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the
|
||||
/// command leaves out too; "locked" says how many were.
|
||||
pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -242,17 +309,7 @@ impl Interactive for ChpropM {
|
||||
"CHPROP"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.select = SelectPhase::begin(s);
|
||||
if self.select.done {
|
||||
let locked = locked(s, &self.select.picked);
|
||||
if !locked.is_empty() {
|
||||
self.select.picked.retain(|h| !locked.contains(h));
|
||||
say_locked(s, locked.len());
|
||||
s.set_selection(self.select.picked.clone());
|
||||
// Nothing left of the pickfirst selection: ask for objects instead.
|
||||
self.select.done = !self.select.picked.is_empty();
|
||||
}
|
||||
}
|
||||
self.select = begin_unlocked(s);
|
||||
if self.select.done {
|
||||
self.handles = self.select.picked.clone();
|
||||
self.ask = Some(Ask::Property);
|
||||
@@ -262,7 +319,7 @@ impl Interactive for ChpropM {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let Some(ask) = self.ask else { return self.select.prompt() };
|
||||
match ask {
|
||||
Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES),
|
||||
Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(self.keywords()),
|
||||
Ask::Color => {
|
||||
Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase()))
|
||||
}
|
||||
@@ -275,21 +332,13 @@ impl Interactive for ChpropM {
|
||||
Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))),
|
||||
Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))),
|
||||
Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())),
|
||||
Ask::Elevation => Prompt::new("Specify new elevation", Accept::NUMBER).default(self.common_elevation(s)),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(ask) = self.ask else {
|
||||
// Objects on locked layers are left out, and said after what was found.
|
||||
let (i, n) = match i {
|
||||
Input::Pick(hs) if !self.select.removing => {
|
||||
let locked = locked(s, &hs);
|
||||
(Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len())
|
||||
}
|
||||
i => (i, 0),
|
||||
};
|
||||
let outcome = self.select.feed(s, &i)?;
|
||||
say_locked(s, n);
|
||||
return Ok(match outcome {
|
||||
return Ok(match feed_unlocked(&mut self.select, s, i)? {
|
||||
SelOutcome::More => Step::Continue,
|
||||
SelOutcome::Done(hs) => {
|
||||
self.handles = hs;
|
||||
@@ -304,6 +353,10 @@ impl Interactive for ChpropM {
|
||||
};
|
||||
match (ask, i) {
|
||||
(Ask::Property, Input::Enter) => {
|
||||
// CHANGE sets the elevation as it ends.
|
||||
if let Some(z) = self.elevation {
|
||||
super::change::elevate_and_say(s, &self.handles, z)?;
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
@@ -315,12 +368,13 @@ impl Interactive for ChpropM {
|
||||
"ltScale" => Ask::LtScale,
|
||||
"LWeight" => Ask::Lineweight,
|
||||
"Thickness" => Ask::Thickness,
|
||||
"Elev" => Ask::Elevation,
|
||||
_ => Ask::Transparency,
|
||||
});
|
||||
}
|
||||
(Ask::Property, Input::Text(t)) => {
|
||||
let t = t.trim().trim_start_matches('_').to_ascii_lowercase();
|
||||
let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count();
|
||||
let n = self.keywords().iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count();
|
||||
s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." });
|
||||
}
|
||||
(Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! -UNITS: the drawing's units at the command line, with AutoCAD 2024's prompts and listings.
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::units::{AngleSettings, format_angle};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const REPORT_FORMATS: [&str; 10] = [
|
||||
"Report formats: (Examples)",
|
||||
" 1. Scientific 1.55E+01",
|
||||
" 2. Decimal 15.50",
|
||||
" 3. Engineering 1'-3.50\"",
|
||||
" 4. Architectural 1'-3 1/2\"",
|
||||
" 5. Fractional 15 1/2",
|
||||
"With the exception of Engineering and Architectural formats,",
|
||||
"these formats can be used with any basic unit of measurement.",
|
||||
"For example, Decimal mode is perfect for metric units as well",
|
||||
"as decimal English units.",
|
||||
];
|
||||
|
||||
const ANGLE_SYSTEMS: [&str; 6] = [
|
||||
"Systems of angle measure: (Examples)",
|
||||
" 1. Decimal degrees 45.0000",
|
||||
" 2. Degrees/minutes/seconds 45d0'0\"",
|
||||
" 3. Grads 50.0000g",
|
||||
" 4. Radians 0.7854r",
|
||||
" 5. Surveyor's units N 45d0'0\" E",
|
||||
];
|
||||
|
||||
const DENOMINATOR: &str = "Enter denominator of smallest fraction to display";
|
||||
|
||||
/// The drawing's units, as -UNITS shows and asks them.
|
||||
struct Units {
|
||||
lunits: i64,
|
||||
luprec: i64,
|
||||
aunits: i64,
|
||||
auprec: i64,
|
||||
angbase: f64,
|
||||
clockwise: bool,
|
||||
}
|
||||
|
||||
impl Units {
|
||||
fn of(s: &Session) -> Units {
|
||||
let Ok(d) = s.doc() else { return Units { lunits: 2, luprec: 4, aunits: 0, auprec: 0, angbase: 0.0, clockwise: false } };
|
||||
let h = &d.header;
|
||||
let angbase = h.f64("ANGBASE", 0.0);
|
||||
Units {
|
||||
lunits: h.i64("LUNITS", 2).clamp(1, 5),
|
||||
luprec: h.i64("LUPREC", 4).clamp(0, 8),
|
||||
aunits: h.i64("AUNITS", 0).clamp(0, 4),
|
||||
auprec: h.i64("AUPREC", 0).clamp(0, 8),
|
||||
angbase: if angbase.is_finite() { angbase } else { 0.0 },
|
||||
clockwise: h.i64("ANGDIR", 0) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Architectural and fractional units give their precision as a fraction's denominator.
|
||||
fn fractional(&self) -> bool {
|
||||
matches!(self.lunits, 4 | 5)
|
||||
}
|
||||
|
||||
/// Direction `a` (radians counterclockwise from +X) in the angle format, measured as the
|
||||
/// drawing measures angles but from +X: -UNITS shows north as 270 when angles turn clockwise.
|
||||
fn direction_text(&self, a: f64) -> String {
|
||||
format_angle(if self.clockwise { -a } else { a }, self.aunits, self.auprec)
|
||||
}
|
||||
|
||||
/// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle
|
||||
/// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, and in surveyor's
|
||||
/// units N, E, S or W), measured from +X as the drawing measures angles.
|
||||
fn direction(&self, t: &str) -> Option<f64> {
|
||||
let cardinal = match t.trim().to_ascii_lowercase().as_str() {
|
||||
"e" if self.aunits == 4 => Some(0.0),
|
||||
"n" if self.aunits == 4 => Some(90f64.to_radians()),
|
||||
"w" if self.aunits == 4 => Some(180f64.to_radians()),
|
||||
"s" if self.aunits == 4 => Some(270f64.to_radians()),
|
||||
_ => None,
|
||||
};
|
||||
let a = cardinal.or_else(|| AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t))?;
|
||||
let a = if self.clockwise { -a } else { a };
|
||||
a.is_finite().then(|| a.rem_euclid(TAU))
|
||||
}
|
||||
}
|
||||
|
||||
/// An integer answer in `lo..=hi`, or AutoCAD's message for one that isn't.
|
||||
fn integer(t: &str, lo: i64, hi: i64) -> Result<i64> {
|
||||
let v = t.trim().parse::<i64>().map_err(|_| EngineError::Other("Requires an integer value.".into()))?;
|
||||
if (lo..=hi).contains(&v) { Ok(v) } else { Err(EngineError::Other(format!("Requires an integer between {lo} and {hi}."))) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum Ask {
|
||||
#[default]
|
||||
LengthFormat,
|
||||
LengthPrecision,
|
||||
AngleFormat,
|
||||
AnglePrecision,
|
||||
Direction,
|
||||
Clockwise,
|
||||
}
|
||||
|
||||
/// -UNITS: the length format and precision, the angle format and precision, the direction of
|
||||
/// angle 0 and whether angles turn clockwise, asked in that order. As in AutoCAD, each answer is
|
||||
/// kept as it is given (Esc keeps the ones before it), and choosing another format resets its
|
||||
/// precision to 4, the precision then offered.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DashUnits {
|
||||
ask: Ask,
|
||||
}
|
||||
|
||||
impl Interactive for DashUnits {
|
||||
fn name(&self) -> &'static str {
|
||||
"-UNITS"
|
||||
}
|
||||
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
for l in REPORT_FORMATS {
|
||||
s.echo(l);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let u = Units::of(s);
|
||||
match self.ask {
|
||||
Ask::LengthFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default(u.lunits.to_string()),
|
||||
Ask::LengthPrecision if u.fractional() => {
|
||||
Prompt::new("(1, 2, 4, 8, 16, 32, 64, 128, or 256)", Accept::NUMBER).default((1_i64 << u.luprec).to_string())
|
||||
}
|
||||
Ask::LengthPrecision => {
|
||||
Prompt::new("Enter number of digits to right of decimal point (0 to 8)", Accept::NUMBER).default(u.luprec.to_string())
|
||||
}
|
||||
Ask::AngleFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default((u.aunits + 1).to_string()),
|
||||
Ask::AnglePrecision => {
|
||||
Prompt::new("Enter number of fractional places for display of angles (0 to 8)", Accept::NUMBER).default(u.auprec.to_string())
|
||||
}
|
||||
Ask::Direction => {
|
||||
Prompt::new(format!("Enter direction for angle {}", u.direction_text(0.0)), Accept::TEXT).default(u.direction_text(u.angbase))
|
||||
}
|
||||
Ask::Clockwise => Prompt::new("Measure angles clockwise?", Accept::TEXT).kw(&["Yes", "No"]).default(if u.clockwise { "Y" } else { "N" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let u = Units::of(s);
|
||||
let text = match &i {
|
||||
Input::Text(t) => Some(t.as_str()),
|
||||
Input::Enter => None,
|
||||
Input::Keyword(k) => Some(k.as_str()),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let h = &mut s.doc_mut()?.header;
|
||||
match self.ask {
|
||||
Ask::LengthFormat => {
|
||||
let l = text.map_or(Ok(u.lunits), |t| integer(t, 1, 5))?;
|
||||
if l != u.lunits {
|
||||
h.set_i64("LUNITS", l);
|
||||
h.set_i64("LUPREC", 4);
|
||||
}
|
||||
self.ask = Ask::LengthPrecision;
|
||||
if matches!(l, 4 | 5) {
|
||||
s.echo(DENOMINATOR);
|
||||
}
|
||||
}
|
||||
Ask::LengthPrecision => {
|
||||
let prec = if u.fractional() {
|
||||
let den = text.map_or(Some(1 << u.luprec), |t| t.trim().parse::<i64>().ok());
|
||||
let Some(p) = den.and_then(|d| (0..=8).find(|p| 1_i64 << p == d)) else {
|
||||
s.echo("Invalid choice.");
|
||||
s.echo(DENOMINATOR);
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
p
|
||||
} else {
|
||||
text.map_or(Ok(u.luprec), |t| integer(t, 0, 8))?
|
||||
};
|
||||
h.set_i64("LUPREC", prec);
|
||||
self.ask = Ask::AngleFormat;
|
||||
for l in ANGLE_SYSTEMS {
|
||||
s.echo(l);
|
||||
}
|
||||
}
|
||||
Ask::AngleFormat => {
|
||||
let a = text.map_or(Ok(u.aunits + 1), |t| integer(t, 1, 5))? - 1;
|
||||
if a != u.aunits {
|
||||
h.set_i64("AUNITS", a);
|
||||
h.set_i64("AUPREC", 4);
|
||||
}
|
||||
self.ask = Ask::AnglePrecision;
|
||||
}
|
||||
Ask::AnglePrecision => {
|
||||
h.set_i64("AUPREC", text.map_or(Ok(u.auprec), |t| integer(t, 0, 8))?);
|
||||
self.ask = Ask::Direction;
|
||||
let u = Units::of(s);
|
||||
s.echo(format!("Direction for angle {}:", u.direction_text(0.0)));
|
||||
for (name, deg) in [("East 3", 0.0), ("North 12", 90.0), ("West 9", 180.0), ("South 6", 270.0)] {
|
||||
s.echo(format!(" {name} o'clock = {}", u.direction_text(f64::to_radians(deg))));
|
||||
}
|
||||
}
|
||||
Ask::Direction => {
|
||||
if let Some(t) = text {
|
||||
let a = u.direction(t).ok_or_else(|| EngineError::Other("Requires valid numeric angle or two points.".into()))?;
|
||||
h.set_f64("ANGBASE", a);
|
||||
}
|
||||
self.ask = Ask::Clockwise;
|
||||
}
|
||||
Ask::Clockwise => {
|
||||
let clockwise = match text.map(|t| t.trim().trim_start_matches('_').to_ascii_lowercase()) {
|
||||
None => u.clockwise,
|
||||
Some(t) if t == "y" || t == "yes" => true,
|
||||
Some(t) if t == "n" || t == "no" => false,
|
||||
Some(_) => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
};
|
||||
h.set_i64("ANGDIR", i64::from(clockwise));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use serde_json::json;
|
||||
use super::super::bad;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
fn valid_name(n: &str) -> bool {
|
||||
pub(super) fn valid_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
|
||||
@@ -333,6 +333,9 @@ fn call(name: &str, args: &[Val]) -> Result<Val, String> {
|
||||
"min" | "max" => {
|
||||
arity(1, MAX_ARGS)?;
|
||||
let vs = (0..args.len()).map(n).collect::<Result<Vec<_>, _>>()?;
|
||||
if vs.iter().any(|v| !v.is_finite()) {
|
||||
return Err("result is not a finite number".into());
|
||||
}
|
||||
let f = if name == "min" { f64::min } else { f64::max };
|
||||
Ok(Val::Num(vs.into_iter().reduce(f).unwrap_or(0.0)))
|
||||
}
|
||||
@@ -597,6 +600,10 @@ mod tests {
|
||||
assert!(ev("[1").is_err());
|
||||
assert!(ev(&"[".repeat(5000)).is_err());
|
||||
assert_eq!(ev("min(3,1,2)"), Ok(Val::Num(1.0)));
|
||||
assert!(ev("min(sqrt(-1), 5)").is_err());
|
||||
assert!(ev("max(5, ln(-1))").is_err());
|
||||
assert!(ev("min(exp(1000), 5)").is_err());
|
||||
assert!(ev("max(exp(1000), 5)").is_err());
|
||||
assert_eq!(ev("1e-3*2"), Ok(Val::Num(0.002)));
|
||||
assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err());
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ pub struct LastUsed {
|
||||
pub mleader_block: String,
|
||||
/// BREAKLINE's Block, Size and Extension.
|
||||
pub breakline: cmd::BreaklineOptions,
|
||||
/// -ARRAY's type: Polar instead of Rectangular.
|
||||
pub classic_array_polar: bool,
|
||||
/// RECTANG's chamfer, fillet, width, elevation, thickness and rotation.
|
||||
pub rectang: RectangModes,
|
||||
}
|
||||
|
||||
impl Default for LastUsed {
|
||||
@@ -39,10 +43,26 @@ impl Default for LastUsed {
|
||||
mleader: MLeaderOptions::default(),
|
||||
mleader_block: String::new(),
|
||||
breakline: cmd::BreaklineOptions::default(),
|
||||
classic_array_polar: false,
|
||||
rectang: RectangModes::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes RECTANG keeps from one rectangle to the next, as AutoCAD 2024 does. Chamfer and
|
||||
/// fillet exclude each other: setting one clears the other.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RectangModes {
|
||||
/// First and second chamfer distances.
|
||||
pub chamfer: (f64, f64),
|
||||
pub fillet: f64,
|
||||
pub width: f64,
|
||||
pub elevation: f64,
|
||||
pub thickness: f64,
|
||||
/// Radians.
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
/// Which part of a multileader MLEADER places first.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeaderOrder {
|
||||
|
||||
@@ -60,20 +60,43 @@ pub(super) fn atoi(a: &[Value]) -> R {
|
||||
Ok(Value::Int(t.get(..end).and_then(|n| n.parse::<i64>().ok()).map(|n| n as i32).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// C's `atof`: the longest number at the start; 0.0 when there is none.
|
||||
/// C's `atof`: the number token at the start; 0.0 when there is none.
|
||||
/// An overflowing token clamps to the largest finite value of its sign.
|
||||
pub(super) fn atof(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
// The longest prefix (of the first 64 characters) that reads as a finite number.
|
||||
let t: String = s.trim_start().chars().take(64).collect();
|
||||
let ends: Vec<usize> = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect();
|
||||
let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::<f64>().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e])));
|
||||
Ok(Value::Real(v.unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity").
|
||||
fn p_is_word(p: &str) -> bool {
|
||||
p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E'))
|
||||
let b = s.trim_start().as_bytes();
|
||||
let digits = |mut i: usize| {
|
||||
while b.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
};
|
||||
let mut i = usize::from(matches!(b.first(), Some(b'-' | b'+')));
|
||||
let int_end = digits(i);
|
||||
let mut end = int_end;
|
||||
let mut mantissa = int_end > i;
|
||||
if b.get(end) == Some(&b'.') {
|
||||
let frac_end = digits(end + 1);
|
||||
if mantissa || frac_end > end + 1 {
|
||||
mantissa = true;
|
||||
end = frac_end;
|
||||
}
|
||||
}
|
||||
if mantissa && matches!(b.get(end), Some(b'e' | b'E')) {
|
||||
i = end + 1 + usize::from(matches!(b.get(end + 1), Some(b'-' | b'+')));
|
||||
let exp_end = digits(i);
|
||||
if exp_end > i {
|
||||
end = exp_end;
|
||||
}
|
||||
}
|
||||
let v = if mantissa { b.get(..end).and_then(|t| std::str::from_utf8(t).ok()).and_then(|t| t.parse::<f64>().ok()) } else { None };
|
||||
let v = match v {
|
||||
Some(v) if v.is_finite() => v,
|
||||
Some(v) if v.is_infinite() => f64::MAX.copysign(v),
|
||||
_ => 0.0,
|
||||
};
|
||||
Ok(Value::Real(v))
|
||||
}
|
||||
|
||||
pub(super) fn chr(a: &[Value]) -> R {
|
||||
|
||||
@@ -1039,3 +1039,12 @@ fn a_lisp_value_at_a_prompt_is_not_evaluated_again() {
|
||||
});
|
||||
assert_eq!(r, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atof_out_of_range_exponent_reads_whole_token() {
|
||||
let mut s = Session::new();
|
||||
// Overflow must not fall back to a shorter prefix such as "1e40".
|
||||
assert_eq!(ok(&mut s, "(> (atof \"1e400\") 1e300)"), "T");
|
||||
assert_eq!(ok(&mut s, "(< (atof \"-1e400\") -1e300)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= (atof \"1e-400\") 0.0)"), "T");
|
||||
}
|
||||
|
||||
@@ -13,3 +13,5 @@ mod files;
|
||||
mod interactive;
|
||||
mod picking;
|
||||
mod zoom;
|
||||
|
||||
pub(crate) use picking::group_10;
|
||||
|
||||
@@ -162,7 +162,7 @@ impl Session {
|
||||
|
||||
/// An object's DXF group 10 point: the first point of its entity list, or its first grip where
|
||||
/// entity lists are not built in.
|
||||
fn group_10(d: &Drawing, e: &Entity) -> Option<Vec2> {
|
||||
pub(crate) fn group_10(d: &Drawing, e: &Entity) -> Option<Vec2> {
|
||||
let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() };
|
||||
let (_, tags) = (codec.groups)(d, e);
|
||||
let i = tags.iter().position(|t| t.code == 10)?;
|
||||
|
||||
@@ -127,6 +127,9 @@ pub struct Settings {
|
||||
pub polardist: f64,
|
||||
/// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0).
|
||||
pub auditctl: bool,
|
||||
/// ATTREQ: 0 inserts blocks with their attributes' default values instead of asking for them
|
||||
/// (default: 1).
|
||||
pub attreq: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -191,6 +194,7 @@ impl Default for Settings {
|
||||
polarsnap: false,
|
||||
polardist: 0.0,
|
||||
auditctl: false,
|
||||
attreq: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,28 @@ fn hidden_layers(d: &Drawing) -> impl Iterator<Item = &str> {
|
||||
d.layers.iter().filter(|l| !l.visible()).map(|l| l.name.as_str())
|
||||
}
|
||||
|
||||
/// A copy of `tree` with `removed` taken out and `inserted` added, or `None` when the caller must
|
||||
/// bulk-load instead. rstar 0.13 can panic ("This is a bug in rstar.") when inserting after removals
|
||||
/// in a bulk-loaded tree (#2468), and picking is outside the command guard, so that panic would end
|
||||
/// the app. The web build can't catch a panic, so it always bulk-loads.
|
||||
fn patch_tree(tree: &RTree<Item>, removed: impl Iterator<Item = Item>, inserted: impl Iterator<Item = Item>) -> Option<RTree<Item>> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut tree = tree.clone();
|
||||
for it in removed {
|
||||
tree.remove(&it);
|
||||
}
|
||||
for it in inserted {
|
||||
tree.insert(it);
|
||||
}
|
||||
tree
|
||||
}))
|
||||
.map_err(|_| log::warn!("spatial index: rstar failed to patch the tree; rebuilding it"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl SpatialIndex {
|
||||
/// Build an index over a store (no caching).
|
||||
pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex {
|
||||
@@ -127,23 +149,11 @@ impl SpatialIndex {
|
||||
offset = offset.saturating_add(n);
|
||||
}
|
||||
// Patch the previous tree when most of it survives; otherwise bulk-load.
|
||||
let tree = match prev {
|
||||
Some(p) if kept.len() * 2 >= chunks.len().max(1) => {
|
||||
let mut tree = p.tree.clone();
|
||||
for (_, c) in p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)) {
|
||||
for it in c.items() {
|
||||
tree.remove(&it);
|
||||
}
|
||||
}
|
||||
for c in &fresh {
|
||||
for it in c.items() {
|
||||
tree.insert(it);
|
||||
}
|
||||
}
|
||||
tree
|
||||
}
|
||||
_ => RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()),
|
||||
};
|
||||
let patched = prev.filter(|_| kept.len() * 2 >= chunks.len().max(1)).and_then(|p| {
|
||||
let removed = p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)).flat_map(|(_, c)| c.items());
|
||||
patch_tree(&p.tree, removed, fresh.iter().flat_map(|c| c.items()))
|
||||
});
|
||||
let tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()));
|
||||
let offsets = chunks.iter().map(|(off, c)| (c.serial, *off)).collect();
|
||||
let infinite = chunks.iter().flat_map(|(off, c)| c.infinite.iter().map(move |i| off + i)).collect();
|
||||
SpatialIndex {
|
||||
@@ -367,6 +377,40 @@ mod tests {
|
||||
Some(Arc::new(SpatialIndex::build(d, &d.model)))
|
||||
}
|
||||
|
||||
/// The edits of #2468, on rstar alone: its insert panics for seed 8. A failed patch falls back
|
||||
/// to a bulk load, and every tree holds exactly the live items.
|
||||
#[test]
|
||||
fn rstar_patch_failures_fall_back_to_a_rebuild() {
|
||||
fn item(r: &mut Rng, s: u32, i: u32) -> Item {
|
||||
let (x, y, w, h) = (r.f() * 1000.0, r.f() * 1000.0, r.f() * 50.0, r.f() * 50.0);
|
||||
GeomWithData::new(Rectangle::from_corners([x, y], [x + w, y + h]), (s, i))
|
||||
}
|
||||
for seed in 1..=10u64 {
|
||||
let mut r = Rng(seed * 7919);
|
||||
let n_chunks = 4 + (r.f() * 20.0) as u32;
|
||||
let mut chunks: Vec<Vec<Item>> = (0..n_chunks)
|
||||
.map(|s| {
|
||||
let n = 1 + (r.f() * 60.0) as u32;
|
||||
(0..n).map(|i| item(&mut r, s, i)).collect()
|
||||
})
|
||||
.collect();
|
||||
let mut tree = RTree::bulk_load(chunks.iter().flatten().cloned().collect());
|
||||
for serial in (n_chunks..).take(300) {
|
||||
let c = (r.f() * chunks.len() as f64) as usize;
|
||||
let n = (chunks[c].len() as i64 + if r.f() < 0.5 { -1 } else { 1 }).max(0) as u32;
|
||||
let fresh: Vec<Item> = (0..n).map(|i| item(&mut r, serial, i)).collect();
|
||||
let patched = patch_tree(&tree, chunks[c].iter().cloned(), fresh.iter().cloned());
|
||||
chunks[c] = fresh;
|
||||
tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flatten().cloned().collect()));
|
||||
let mut held: Vec<(u32, u32)> = tree.iter().map(|it| it.data).collect();
|
||||
let mut live: Vec<(u32, u32)> = chunks.iter().flatten().map(|it| it.data).collect();
|
||||
held.sort_unstable();
|
||||
live.sort_unstable();
|
||||
assert_eq!(held, live, "seed {seed}, serial {serial}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_parity_with_scan() {
|
||||
for seed in [3, 11, 97] {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod table;
|
||||
|
||||
const SESSION_VARS: &[&str] = &[
|
||||
"AUDITCTL",
|
||||
"ATTREQ",
|
||||
"OSMODE",
|
||||
"OSNAPHATCH",
|
||||
"ANNOAUTOSCALE",
|
||||
@@ -281,7 +282,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
let bad = || EngineError::BadParams { cmd: "setvar".into(), msg: format!("invalid value for {n}") };
|
||||
let st = &mut s.settings;
|
||||
match n.as_str() {
|
||||
"OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff,
|
||||
"OSMODE" => {
|
||||
st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x| *x <= 0x7fff).ok_or_else(bad)? as u32
|
||||
}
|
||||
// The `color` command's input: ByLayer, ByBlock, a name, an index or a true colour.
|
||||
"CECOLOR" => crate::cmd::set_current_color_value(s, v).ok_or_else(bad)?,
|
||||
"OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?,
|
||||
@@ -424,7 +427,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
// Keep the header type stable for known numeric vars.
|
||||
match d.header.get(&n) {
|
||||
Some(cadcraft_doc::HVal::Real(_)) => {
|
||||
d.header.set_f64(&n, val.as_f64().ok_or_else(bad)?);
|
||||
d.header.set_f64(&n, val.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?);
|
||||
return Ok(());
|
||||
}
|
||||
Some(cadcraft_doc::HVal::Int(_)) => {
|
||||
|
||||
@@ -81,8 +81,11 @@ const LINEWEIGHTS: [i64; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53,
|
||||
|
||||
/// On/off session settings: name, read, write.
|
||||
type Toggle = (&'static str, fn(&Settings) -> bool, fn(&mut Settings, bool));
|
||||
const SETTINGS: &[Toggle] =
|
||||
&[("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v)];
|
||||
const SETTINGS: &[Toggle] = &[
|
||||
("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v),
|
||||
("ATTREQ", |s| s.attreq, |s, v| s.attreq = v),
|
||||
("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v),
|
||||
];
|
||||
|
||||
fn header_var(name: &str) -> Option<&'static (&'static str, Kind, Def)> {
|
||||
HEADER.iter().find(|(n, _, _)| *n == name)
|
||||
|
||||
@@ -653,6 +653,18 @@ fn setvar_rejects_out_of_range_units() {
|
||||
assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_rejects_out_of_range_osmode() {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({"name": "OSMODE", "value": 4133})).unwrap();
|
||||
for v in [4294967296u64, 65536, 32768] {
|
||||
assert!(s.execute("setvar", &json!({"name": "OSMODE", "value": v})).is_err(), "{v}");
|
||||
assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(4133)), "{v}");
|
||||
}
|
||||
s.execute("setvar", &json!({"name": "OSMODE", "value": 0x7fff})).unwrap();
|
||||
assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(0x7fff)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_input_pointer_settings() {
|
||||
let mut s = Session::new();
|
||||
@@ -1465,3 +1477,16 @@ fn array_counts_reject_invalid_values() {
|
||||
}
|
||||
s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_rejects_non_finite_text_for_real_variables() {
|
||||
let mut s = Session::new();
|
||||
for name in ["LTSCALE", "TEXTSIZE", "SNAPANG"] {
|
||||
let before = s.execute("getvar", &json!({"name": name})).unwrap();
|
||||
for text in ["nan", "inf", "-inf"] {
|
||||
let r = s.execute("setvar", &json!({"name": name, "value": text}));
|
||||
assert!(r.is_err(), "SETVAR {name} {text} was accepted");
|
||||
assert_eq!(s.execute("getvar", &json!({"name": name})).unwrap(), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,16 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
} else {
|
||||
("S", "E", deg - 270.0)
|
||||
};
|
||||
format!("{ns} {} {ew}", format_dms(ang, prec))
|
||||
// A bearing shown as due north, south, east or west is that letter, as AutoCAD 2024
|
||||
// shows it ("E" for 0), except to whole degrees ("N 90d E").
|
||||
let dms = format_dms(ang, prec);
|
||||
if prec > 0 && dms == format_dms(0.0, prec) {
|
||||
ns.to_string()
|
||||
} else if prec > 0 && dms == format_dms(90.0, prec) {
|
||||
ew.to_string()
|
||||
} else {
|
||||
format!("{ns} {dms} {ew}")
|
||||
}
|
||||
}
|
||||
_ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)),
|
||||
}
|
||||
@@ -332,6 +341,9 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
((num(g)? * 0.9).to_radians(), false)
|
||||
} else if let Some(b) = bearing(&t) {
|
||||
(b, true)
|
||||
} else if let Some(b) = (aunits == 4).then(|| cardinal(&t)).flatten() {
|
||||
// A due bearing as surveyor's units show it: one letter.
|
||||
(b, true)
|
||||
} else if let Some(v) = num(&t) {
|
||||
let a = match aunits {
|
||||
2 => (v * 0.9).to_radians(),
|
||||
@@ -345,6 +357,17 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
|
||||
v.0.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A due bearing (`n`, `e`, `s`, `w`, lower case) as radians counterclockwise from +X.
|
||||
fn cardinal(t: &str) -> Option<f64> {
|
||||
match t {
|
||||
"e" => Some(0.0),
|
||||
"n" => Some(PI / 2.0),
|
||||
"w" => Some(PI),
|
||||
"s" => Some(1.5 * PI),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A surveyor's bearing (`n45d30'e`, lower case) as radians counterclockwise from +X.
|
||||
fn bearing(t: &str) -> Option<f64> {
|
||||
let north = t.starts_with('n');
|
||||
@@ -542,6 +565,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surveyors_units_read_a_due_bearing_typed_as_one_letter() {
|
||||
// What surveyor's units show for a due bearing can be typed back, e.g. `@10<E`.
|
||||
let a = AngleSettings { aunits: 4, angbase: 1.0, clockwise: true };
|
||||
for (t, want) in [("E", 0.0), ("n", PI / 2.0), ("W", PI), ("s", 1.5 * PI)] {
|
||||
let got = a.direction(t).unwrap();
|
||||
assert!((got - want).abs() < 1e-12, "{t}: {got}");
|
||||
}
|
||||
// Other angle units don't take a bare letter.
|
||||
assert_eq!(AngleSettings { aunits: 0, angbase: 0.0, clockwise: false }.direction("E"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surveyors_units_show_a_due_bearing_as_one_letter() {
|
||||
// AutoCAD 2024's ANGTOS in surveyor's units (measured): due east, north, west and south are
|
||||
// one letter, also when the bearing only rounds to one; to whole degrees east stays a
|
||||
// bearing.
|
||||
let cases = [
|
||||
(0.0, 0, "N 90d E"),
|
||||
(0.0, 1, "E"),
|
||||
(0.0, 2, "E"),
|
||||
(0.0, 4, "E"),
|
||||
(PI / 2.0, 1, "N"),
|
||||
(PI, 4, "W"),
|
||||
(3.0 * PI / 2.0, 4, "S"),
|
||||
(0.00001, 2, "E"),
|
||||
(1.0, 1, "N 32d42' E"),
|
||||
(1.0, 3, "N 32d42'15\" E"),
|
||||
(1.0, 7, "N 32d42'15.194\" E"),
|
||||
];
|
||||
for (angle, prec, expected) in cases {
|
||||
assert_eq!(format_angle(angle, 4, prec), expected, "{angle} AUPREC {prec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_to_a_full_turn_wraps_to_zero() {
|
||||
let almost_turn = 359.9999_f64.to_radians();
|
||||
|
||||
@@ -185,6 +185,14 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
|
||||
// The file exists before the write finishes (the folder sync comes after the rename); until
|
||||
// it has, the drawing is busy and the change drawn below would not be planned.
|
||||
for _ in 0..500 {
|
||||
if !a.writing() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
// While the app runs, its lock keeps its files out of recovery. SAVETIME and SAVEFILEPATH
|
||||
// are system variables (process-wide, so set in this test only).
|
||||
assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVETIME"), Some(json!(10)));
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! CHANGE as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back
|
||||
//! with `entget`).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::doc::color::Color;
|
||||
use serde_json::json;
|
||||
|
||||
/// The measured drawing, its objects named in LISP: lines `a` (0,0)–(50,0) and `b` (0,20)–(50,30)
|
||||
/// on layer L1, circle `c` (centre (100,0), radius 10), text `t` "ABC" at (0,50) 2.5 high, circle
|
||||
/// `e` ((200,0), 3), arc `r` ((150,0), 10, 0°–90°) and polyline `p` (0,100)–(50,100)–(50,120); then
|
||||
/// `code`.
|
||||
fn run(code: &str) -> Session {
|
||||
// `entmake` and `entget` go through the DXF codec, as in the app.
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().ensure_layer("L1");
|
||||
for (v, made) in [
|
||||
("a", "(0 . \"LINE\") (10 0.0 0.0 0.0) (11 50.0 0.0 0.0)"),
|
||||
("b", "(0 . \"LINE\") (8 . \"L1\") (10 0.0 20.0 0.0) (11 50.0 30.0 0.0)"),
|
||||
("c", "(0 . \"CIRCLE\") (10 100.0 0.0 0.0) (40 . 10.0)"),
|
||||
("t", "(0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\")"),
|
||||
("e", "(0 . \"CIRCLE\") (10 200.0 0.0 0.0) (40 . 3.0)"),
|
||||
("r", "(0 . \"ARC\") (10 150.0 0.0 0.0) (40 . 10.0) (50 . 0.0) (51 . 1.5707963267948966)"),
|
||||
("p", "(0 . \"LWPOLYLINE\") (90 . 3) (70 . 0) (10 0.0 100.0) (10 50.0 100.0) (10 50.0 120.0)"),
|
||||
] {
|
||||
s.cmdline(&format!("(setq {v} (progn (entmake '({made})) (entlast)))")).unwrap();
|
||||
}
|
||||
s.log.clear();
|
||||
s.cmdline(&format!("(progn {code})")).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The `i`th object drawn.
|
||||
fn kind(s: &Session, i: usize) -> EntityKind {
|
||||
s.doc().unwrap().model.iter().nth(i).unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn line(s: &Session, i: usize) -> [[f64; 3]; 2] {
|
||||
let EntityKind::Line(l) = kind(s, i) else { panic!("not a line") };
|
||||
[[l.a.x, l.a.y, l.a.z], [l.b.x, l.b.y, l.b.z]]
|
||||
}
|
||||
|
||||
fn radius(s: &Session, i: usize) -> f64 {
|
||||
let EntityKind::Circle(c) = kind(s, i) else { panic!("not a circle") };
|
||||
c.radius
|
||||
}
|
||||
|
||||
fn text(s: &Session, i: usize) -> cadcraft_engine::doc::Text {
|
||||
match kind(s, i) {
|
||||
EntityKind::Text(t) => t,
|
||||
EntityKind::AttDef(a) => a.text,
|
||||
_ => panic!("not a text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn said(s: &Session, line: &str) -> usize {
|
||||
s.log.iter().filter(|l| l.as_str() == line).count()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_change_point_moves_the_nearer_end_of_each_line() {
|
||||
let s = run("(command \"_.CHANGE\" a \"\" '(60.0 10.0))");
|
||||
assert_eq!(line(&s, 0), [[0.0, 0.0, 0.0], [60.0, 10.0, 0.0]]);
|
||||
let s = run("(command \"_.CHANGE\" a \"\" '(-5.0 5.0))");
|
||||
assert_eq!(line(&s, 0), [[-5.0, 5.0, 0.0], [50.0, 0.0, 0.0]]);
|
||||
let s = run("(command \"_.CHANGE\" a b \"\" '(60.0 10.0))");
|
||||
assert_eq!((line(&s, 0)[1], line(&s, 1)), ([60.0, 10.0, 0.0], [[0.0, 20.0, 0.0], [60.0, 10.0, 0.0]]));
|
||||
// Under ortho the moved end stays level or plumb with the other one.
|
||||
let s = run("(setvar \"ORTHOMODE\" 1) (command \"_.CHANGE\" a b \"\" '(60.0 10.0))");
|
||||
assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 0.0, 0.0], [60.0, 20.0, 0.0]));
|
||||
// Arcs and polylines are left alone: with a line the point still moves it, without one there
|
||||
// is nothing to change. Enter leaves lines alone too.
|
||||
let s = run("(command \"_.CHANGE\" a r p \"\" '(60.0 10.0))");
|
||||
assert_eq!(line(&s, 0)[1], [60.0, 10.0, 0.0]);
|
||||
assert_eq!(said(&s, "No changeable object selected"), 0, "{:?}", s.log);
|
||||
for code in ["(command \"_.CHANGE\" r \"\" '(160.0 10.0))", "(command \"_.CHANGE\" p \"\" '(60.0 130.0))", "(command \"_.CHANGE\" a \"\" \"\")"] {
|
||||
let s = run(code);
|
||||
assert_eq!(said(&s, "No changeable object selected"), 1, "{code}: {:?}", s.log);
|
||||
assert!(s.running.is_none(), "{code}");
|
||||
assert_eq!(line(&s, 0)[1], [50.0, 0.0, 0.0], "{code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_passes_through_the_point_or_takes_a_radius() {
|
||||
let s = run("(command \"_.CHANGE\" c \"\" '(115.0 0.0))");
|
||||
assert!(near(radius(&s, 2), 15.0));
|
||||
let s = run("(command \"_.CHANGE\" c \"\" \"\" 20.0)");
|
||||
assert!(near(radius(&s, 2), 20.0));
|
||||
// With other objects the point is ignored, said as each circle comes up.
|
||||
let s = run("(command \"_.CHANGE\" c e \"\" '(110.0 10.0) 8.0)");
|
||||
assert_eq!((radius(&s, 2), radius(&s, 4)), (8.0, 3.0));
|
||||
assert_eq!(said(&s, "Change point ignored"), 2, "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("Specify new circle radius <no change>"), "{}", s.prompt_text());
|
||||
let s = run("(command \"_.CHANGE\" a c \"\" '(60.0 10.0) 12.0)");
|
||||
assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 12.0));
|
||||
assert_eq!(said(&s, "Change point ignored"), 1, "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_is_placed_by_the_point_then_asked_the_rest() {
|
||||
let s = run("(command \"_.CHANGE\" t \"\" '(10.0 60.0) \"\" 4.0 \"\" \"\")");
|
||||
let t = text(&s, 3);
|
||||
assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (10.0, 60.0, 4.0, "ABC"));
|
||||
// Enter asks for the insertion point first.
|
||||
let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"\" 5.0 30 \"XYZ\")");
|
||||
let t = text(&s, 3);
|
||||
assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (0.0, 50.0, 5.0, "XYZ"));
|
||||
assert!(near(t.rotation.to_degrees(), 30.0));
|
||||
// A style that does not exist is refused, and asked for again.
|
||||
let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"5\")");
|
||||
assert_eq!(said(&s, "Style \"5\" not defined - use STYLE command."), 1, "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("Enter new text style <Standard>"), "{}", s.prompt_text());
|
||||
// Justified text is placed by its alignment point, with the point or after Enter.
|
||||
for (justify, code) in [("(72 . 1) (73 . 2)", "'(10.0 60.0) \"\" \"\" \"\" \"\""), ("(72 . 2)", "\"\" '(10.0 60.0) \"\" \"\" \"\" \"\"")] {
|
||||
let s = run(&format!(
|
||||
"(entmake '((0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\") {justify} (11 0.0 50.0 0.0))) (command \"_.CHANGE\" (entlast) \"\" {code})"
|
||||
));
|
||||
let at = text(&s, 7).align_pt.unwrap();
|
||||
assert_eq!((at.x, at.y), (10.0, 60.0), "{justify}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_of_fixed_height_is_not_asked_for_a_height() {
|
||||
let mut s = run("");
|
||||
s.execute("style", &json!({ "name": "FIXH", "height": 5.0 })).unwrap();
|
||||
s.cmdline("(entmake '((0 . \"TEXT\") (10 0.0 80.0 0.0) (40 . 5.0) (1 . \"FIX\") (7 . \"FIXH\")))").unwrap();
|
||||
s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 30 \"Q\")").unwrap();
|
||||
let t = text(&s, 7);
|
||||
assert_eq!((t.height, t.value.as_str()), (5.0, "Q"));
|
||||
assert!(near(t.rotation.to_degrees(), 30.0));
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_are_asked_about_in_the_order_selected() {
|
||||
let s = run(
|
||||
"(setq d (progn (entmake '((0 . \"TEXT\") (10 0.0 70.0 0.0) (40 . 2.5) (1 . \"DEF\"))) (entlast))) (command \"_.CHANGE\" t d \"\" \"\" \"\" \"\" \"\" \"\" \"X1\" \"\" \"\" \"\" \"\" \"X2\")",
|
||||
);
|
||||
assert_eq!((text(&s, 3).value.as_str(), text(&s, 7).value.as_str()), ("X1", "X2"));
|
||||
let s = run("(command \"_.CHANGE\" t c \"\" \"\" \"\" \"\" \"\" \"\" \"TT\" 7.0)");
|
||||
assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0));
|
||||
let s = run("(command \"_.CHANGE\" c t \"\" \"\" 7.0 \"\" \"\" \"\" \"\" \"TT\")");
|
||||
assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0));
|
||||
}
|
||||
|
||||
/// Block K: a circle and attribute TAG1 (default V) 5 above its base point, inserted at (200,0)
|
||||
/// with the default value.
|
||||
const BLOCK: &str = "(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0))) (entmake '((0 . \"ATTDEF\") (10 0.0 5.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (entmake '((0 . \"ENDBLK\"))) (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")";
|
||||
|
||||
#[test]
|
||||
fn a_block_reference_is_placed_and_turned_with_its_attributes() {
|
||||
let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" '(250.0 50.0) 90)"));
|
||||
let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") };
|
||||
assert_eq!((i.insert.x, i.insert.y), (250.0, 50.0));
|
||||
assert!(near(i.rotation.to_degrees(), 90.0));
|
||||
let a = &i.attribs.first().expect("the attribute").text;
|
||||
assert!(near(a.insert.x, 245.0) && near(a.insert.y, 50.0), "{:?}", a.insert);
|
||||
assert!(near(a.rotation.to_degrees(), 90.0));
|
||||
let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" \"\" '(220.0 30.0) 90)"));
|
||||
let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") };
|
||||
assert_eq!((i.insert.x, i.insert.y), (220.0, 30.0));
|
||||
assert!(near(i.rotation.to_degrees(), 90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attribute_definition_is_asked_its_tag_prompt_and_default() {
|
||||
let s = run(
|
||||
"(entmake '((0 . \"ATTDEF\") (10 300.0 0.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (command \"_.CHANGE\" (entlast) \"\" '(310.0 10.0) \"\" \"\" \"\" \"T2\" \"P2\" \"V2\")",
|
||||
);
|
||||
let EntityKind::AttDef(a) = kind(&s, 7) else { panic!("not an attribute definition") };
|
||||
assert_eq!((a.text.insert.x, a.text.insert.y), (310.0, 10.0));
|
||||
assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), ("T2", "P2", "V2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_on_locked_layers_are_left_out() {
|
||||
let s = run("(command \"_.-LAYER\" \"_LO\" \"L1\" \"\") (command \"_.CHANGE\" a b \"\" '(60.0 10.0))");
|
||||
assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 10.0, 0.0], [50.0, 30.0, 0.0]));
|
||||
assert_eq!(said(&s, "1 was on a locked layer."), 1, "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_add_elev_which_is_set_as_the_command_ends() {
|
||||
let s = run("(command \"_.CHANGE\" a \"\" \"_P\" \"_C\" 2 \"_E\" 10.0 \"\")");
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.model.iter().next().unwrap().common.color, Color::Index(2));
|
||||
assert_eq!(line(&s, 0), [[0.0, 0.0, 10.0], [50.0, 0.0, 10.0]]);
|
||||
let s = run("(command \"_.CHANGE\" a c \"\" \"_P\" \"_LA\" \"L1\" \"_T\" 3.0 \"\")");
|
||||
for e in s.doc().unwrap().model.iter().take(3).step_by(2) {
|
||||
assert_eq!((e.common.layer.as_str(), e.common.thickness), ("L1", 3.0));
|
||||
}
|
||||
let s = run("(command \"_.CHANGE\" c t r p \"\" \"_P\" \"_E\" 5.0 \"\")");
|
||||
let EntityKind::Circle(c) = kind(&s, 2) else { panic!() };
|
||||
let EntityKind::Arc(r) = kind(&s, 5) else { panic!() };
|
||||
let EntityKind::LwPolyline(p) = kind(&s, 6) else { panic!() };
|
||||
assert_eq!((c.center.z, text(&s, 3).insert.z, r.center.z, p.elevation), (5.0, 5.0, 5.0, 5.0));
|
||||
// A line between two heights keeps them, said as the command ends; the elevation asked shows
|
||||
// it varies.
|
||||
let mut s = run(
|
||||
"(setq l (progn (entmake '((0 . \"LINE\") (10 0.0 200.0 0.0) (11 50.0 200.0 10.0))) (entlast))) (command \"_.CHANGE\" l \"\" \"_P\" \"_E\")",
|
||||
);
|
||||
assert!(s.prompt_text().contains("Specify new elevation <varies>"), "{}", s.prompt_text());
|
||||
s.cmdline("(command 5.0 \"\")").unwrap();
|
||||
assert_eq!(line(&s, 7), [[0.0, 200.0, 0.0], [50.0, 200.0, 10.0]]);
|
||||
assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_by_json() {
|
||||
let mut s = run("");
|
||||
let h: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
let r = s.execute("change", &json!({ "handles": [h[0], h[2]], "point": [60, 10] })).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["ignored"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 10.0));
|
||||
s.execute("change", &json!({ "handles": [h[2]], "point": [115, 0] })).unwrap();
|
||||
assert!(near(radius(&s, 2), 15.0));
|
||||
s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap();
|
||||
let r = s.execute("change", &json!({ "handles": [h[0], h[1]], "elevation": 4.0 })).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert_eq!((line(&s, 0)[0][2], line(&s, 1)[0][2]), (4.0, 0.0));
|
||||
// Neither a point nor an elevation, or one that can't be read, is refused.
|
||||
for bad in [json!({ "handles": [h[0]] }), json!({ "handles": [h[0]], "point": "nowhere" }), json!({ "handles": [h[0]], "elevation": "high" })] {
|
||||
assert!(s.execute("change", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(line(&s, 0), [[0.0, 0.0, 4.0], [60.0, 10.0, 4.0]]);
|
||||
}
|
||||
|
||||
/// Elev on the other kinds AutoCAD 2024 sets it for, each alone: the z of a point, a block
|
||||
/// reference with its attributes, a multiline text, an ellipse, a solid and a flat 3D polyline;
|
||||
/// a 3D polyline between heights keeps them, and a hatch loses its associativity.
|
||||
#[test]
|
||||
fn elev_sets_the_z_of_every_kind_with_one() {
|
||||
let elev = |made: &str| {
|
||||
let mut s = run(made);
|
||||
s.log.clear();
|
||||
s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap();
|
||||
let k = s.doc().unwrap().model.iter().last().unwrap().kind.clone();
|
||||
(k, s)
|
||||
};
|
||||
let (k, _) = elev("(entmake '((0 . \"POINT\") (10 5.0 5.0 0.0)))");
|
||||
assert!(matches!(k, EntityKind::Point(p) if p.p.z == 4.0));
|
||||
let (k, _) = elev("(entmake '((0 . \"MTEXT\") (10 0.0 150.0 0.0) (40 . 2.5) (1 . \"AB\")))");
|
||||
assert!(matches!(k, EntityKind::MText(t) if t.insert.z == 4.0));
|
||||
let (k, _) = elev("(entmake '((0 . \"ELLIPSE\") (10 50.0 150.0 0.0) (11 20.0 0.0 0.0) (40 . 0.5) (41 . 0.0) (42 . 6.283185307179586)))");
|
||||
assert!(matches!(k, EntityKind::Ellipse(e) if e.center.z == 4.0));
|
||||
let (k, _) = elev("(entmake '((0 . \"SOLID\") (10 0.0 170.0 0.0) (11 10.0 170.0 0.0) (12 0.0 180.0 0.0) (13 10.0 180.0 0.0)))");
|
||||
assert!(matches!(k, EntityKind::Solid(so) if so.corners.iter().all(|c| c.z == 4.0)));
|
||||
// 3D polylines (made with their z, which a point given to a command doesn't carry).
|
||||
let poly3d = |pts: serde_json::Value| {
|
||||
let mut s = run("");
|
||||
s.execute("3dpoly", &json!({ "points": pts })).unwrap();
|
||||
s.log.clear();
|
||||
s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap();
|
||||
let EntityKind::Polyline3d(p) = s.doc().unwrap().model.iter().last().unwrap().kind.clone() else { panic!("not a 3D polyline") };
|
||||
(p.points.iter().map(|q| q.z).collect::<Vec<f64>>(), s)
|
||||
};
|
||||
let (zs, _) = poly3d(json!([[0, 220, 2], [10, 220, 2], [20, 230, 2]]));
|
||||
assert_eq!(zs, [4.0, 4.0, 4.0]);
|
||||
let (zs, s) = poly3d(json!([[0, 200, 0], [10, 200, 5], [20, 210, 0]]));
|
||||
assert_eq!(zs, [0.0, 5.0, 0.0]);
|
||||
assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log);
|
||||
let (k, _) = elev(&format!("{BLOCK} (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")"));
|
||||
let EntityKind::Insert(i) = k else { panic!("not a block reference") };
|
||||
assert_eq!((i.insert.z, i.attribs[0].text.insert.z), (4.0, 4.0));
|
||||
// A hatch: its elevation, and no longer associative.
|
||||
let mut s = run("(entmake '((0 . \"LWPOLYLINE\") (90 . 4) (70 . 1) (10 100.0 150.0) (10 120.0 150.0) (10 120.0 170.0) (10 100.0 170.0)))");
|
||||
let poly = s.doc().unwrap().model.iter().last().unwrap().handle.hex();
|
||||
s.execute("hatch", &json!({ "handles": [poly], "associative": true })).unwrap();
|
||||
// The hatch is drawn behind its boundary, so it isn't the last object in the list.
|
||||
let hatch = |s: &Session| {
|
||||
let d = s.doc().unwrap();
|
||||
d.model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).map(|e| (e.handle.hex(), e.kind.clone())).expect("a hatch")
|
||||
};
|
||||
let (hex, k) = hatch(&s);
|
||||
assert!(matches!(&k, EntityKind::Hatch(h) if h.associative));
|
||||
s.log.clear();
|
||||
s.cmdline(&format!("(command \"_.CHANGE\" (handent \"{hex}\") \"\" \"_P\" \"_E\" 4.0 \"\")")).unwrap();
|
||||
let (_, EntityKind::Hatch(h)) = hatch(&s) else { panic!("not a hatch") };
|
||||
assert_eq!((h.elevation, h.associative), (4.0, false));
|
||||
assert_eq!(said(&s, "Hatch boundary associativity removed."), 1, "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rotation_asked_shows_as_the_drawing_measures_angles() {
|
||||
// ANGBASE 90°: a text at 0° shows as 270.00.
|
||||
let s = run("(setvar \"ANGBASE\" (/ pi 2)) (command \"_.CHANGE\" t \"\" \"\" \"\" \"\" \"\")");
|
||||
assert!(s.prompt_text().contains("Specify new rotation angle <270.00>"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligned_and_fit_texts_are_set_by_their_two_points() {
|
||||
// Aligned: neither height nor rotation is asked; Fit: no rotation.
|
||||
let s = run(
|
||||
"(entmake '((0 . \"TEXT\") (10 0.0 300.0 0.0) (11 40.0 300.0 0.0) (40 . 2.5) (1 . \"AL\") (72 . 3))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" \"NEW\")",
|
||||
);
|
||||
let t = text(&s, 7);
|
||||
assert_eq!((t.height, t.rotation, t.value.as_str()), (2.5, 0.0, "NEW"));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let s = run(
|
||||
"(entmake '((0 . \"TEXT\") (10 0.0 320.0 0.0) (11 40.0 320.0 0.0) (40 . 2.5) (1 . \"FI\") (72 . 5))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 5.0 \"FN\")",
|
||||
);
|
||||
let t = text(&s, 7);
|
||||
assert_eq!((t.height, t.rotation, t.value.as_str()), (5.0, 0.0, "FN"));
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_point_follows_ortho() {
|
||||
let mut s = run("(setvar \"ORTHOMODE\" 1)");
|
||||
let a = s.doc().unwrap().model.iter().next().unwrap().handle.hex();
|
||||
s.execute("change", &json!({ "handles": [a], "point": [60, 10] })).unwrap();
|
||||
assert_eq!(line(&s, 0)[1], [60.0, 0.0, 0.0]);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! -ARRAY, the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command`
|
||||
//! (accoreconsole, every copy read back with `entget`).
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// A session with the object `made` (an entity list) as `a` in LISP, after `code`.
|
||||
fn run(made: &str, code: &str) -> Session {
|
||||
// `entmake` goes through the DXF codec, as in the app.
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
let mut s = Session::new();
|
||||
s.cmdline(&format!("(progn (setq a (progn (entmake '({made})) (entlast))) {code})")).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Where each object is: a line's start, a circle's or arc's centre, a polyline's first vertex, a
|
||||
/// text's insertion point.
|
||||
fn points(s: &Session) -> Vec<[f64; 2]> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => [l.a.x, l.a.y],
|
||||
EntityKind::Circle(c) => [c.center.x, c.center.y],
|
||||
EntityKind::Arc(a) => [a.center.x, a.center.y],
|
||||
EntityKind::LwPolyline(p) => [p.vertices[0].p.x, p.vertices[0].p.y],
|
||||
EntityKind::Text(t) => [t.insert.x, t.insert.y],
|
||||
k => panic!("unexpected {k:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `got` holds each of `want` to `tol`, and nothing else.
|
||||
fn same(got: &[[f64; 2]], want: &[[f64; 2]], tol: f64) -> bool {
|
||||
got.len() == want.len() && want.iter().all(|w| got.iter().any(|g| (g[0] - w[0]).abs() <= tol && (g[1] - w[1]).abs() <= tol))
|
||||
}
|
||||
|
||||
fn line(a: [f64; 2], b: [f64; 2]) -> String {
|
||||
format!("(0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0)", a[0], a[1], b[0], b[1])
|
||||
}
|
||||
|
||||
fn circle(c: [f64; 2], r: f64) -> String {
|
||||
format!("(0 . \"CIRCLE\") (10 {} {} 0.0) (40 . {r})", c[0], c[1])
|
||||
}
|
||||
|
||||
fn arc(c: [f64; 2], r: f64, a1: f64, a2: f64) -> String {
|
||||
format!("(0 . \"ARC\") (10 {} {} 0.0) (40 . {r}) (50 . {}) (51 . {})", c[0], c[1], a1.to_radians(), a2.to_radians())
|
||||
}
|
||||
|
||||
/// The measured polyline: four vertices with bulges 0.3, 0, −0.7, 0, from `at`, `scale` × the
|
||||
/// drawn size, `width` wide.
|
||||
fn polyline(at: [f64; 2], scale: f64, width: f64) -> String {
|
||||
let v = [(0.0, 0.0, 0.3), (20.0, 5.0, 0.0), (35.0, -10.0, -0.7), (50.0, 0.0, 0.0)];
|
||||
let vs: String = v.iter().map(|(x, y, b)| format!(" (10 {} {}) (42 . {b})", at[0] + x * scale, at[1] + y * scale)).collect();
|
||||
format!("(0 . \"LWPOLYLINE\") (90 . 4) (70 . 0) (43 . {width}){vs}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangular_copies_move_by_rows_and_columns() {
|
||||
let s = run(
|
||||
&line([-1.1929712440222175, 9.67888030686079], [44.51297824124873, 11.673136668914886]),
|
||||
"(command \"_.-ARRAY\" a \"\" \"_R\" 2 2 -12.570757111654931 -18.0084920904546)",
|
||||
);
|
||||
let want = [
|
||||
[-19.20146333447681, -2.891876804794138],
|
||||
[-19.20146333447681, 9.67888030686079],
|
||||
[-1.192971244022217, -2.891876804794138],
|
||||
[-1.192971244022217, 9.67888030686079],
|
||||
];
|
||||
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
|
||||
// One row or one column: that distance isn't asked for.
|
||||
let s = run(&circle([5.801226536663419, -5.877116775641122], 23.343762414841144), "(command \"_.-ARRAY\" a \"\" \"_R\" 1 3 -26.90504352387681)");
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let want = [[-48.0088605110902, -5.877116775641122], [-21.10381698721339, -5.877116775641122], [5.801226536663419, -5.877116775641122]];
|
||||
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
|
||||
let s = run(&polyline([0.5, 0.25], 0.001, 0.0005), "(command \"_.-ARRAY\" a \"\" \"_R\" 3 1 -0.010095474538067209)");
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(same(&points(&s), &[[0.5, 0.2298090509238659], [0.5, 0.239904525461933], [0.5, 0.25]], 1e-12), "{:?}", points(&s));
|
||||
let s = run(
|
||||
"(0 . \"TEXT\") (10 5.4185272544331635 4.443702697220587 0.0) (40 . 2.5) (1 . \"AB12\") (50 . 0.20972899)",
|
||||
"(command \"_.-ARRAY\" a \"\" \"_R\" 1 4 26.03962020976745)",
|
||||
);
|
||||
let want = [
|
||||
[83.53738788373552, 4.443702697220587],
|
||||
[57.49776767396807, 4.443702697220587],
|
||||
[31.45814746420061, 4.443702697220587],
|
||||
[5.418527254433163, 4.443702697220587],
|
||||
];
|
||||
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_copies_turn_about_the_centre() {
|
||||
// Five items around a full circle: 72° apart.
|
||||
let s = run(
|
||||
&arc([8.737608057965836, 8.018370437941456], 25.76749991913238, 246.54512163341687, 4.3030133419271355),
|
||||
"(command \"_.-ARRAY\" a \"\" \"_P\" '(10.631794214160564 -5.07952370466397) 5 360 \"_Y\")",
|
||||
);
|
||||
let want = [
|
||||
[22.50329607545565, 0.769426262850251],
|
||||
[19.86297201814329, -14.56256796884801],
|
||||
[4.465473991920418, -16.78931734426366],
|
||||
[-2.410379072682379, -2.833529910999876],
|
||||
[8.737608057965836, 8.018370437941456],
|
||||
];
|
||||
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
|
||||
let starts: Vec<f64> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Arc(a) = &e.kind { Some(a.start) } else { None }).collect();
|
||||
for want in [3.046387065797107, 1.789750004361188, 0.5331129429252733, 5.559661188668941, 4.303024127233024] {
|
||||
assert!(starts.iter().any(|a| ((a - want).rem_euclid(TAU) + 1e-9).rem_euclid(TAU) < 2e-9), "{want}: {starts:?}");
|
||||
}
|
||||
// Six items in 270°: 54° apart.
|
||||
let s = run(
|
||||
&line([14.349623930607876, -18.237839127538827], [57.5895083326729, 8.515990272720988]),
|
||||
"(command \"_.-ARRAY\" a \"\" \"_P\" '(9.968618268950948 -10.513614962917108) 6 270 \"_Y\")",
|
||||
);
|
||||
let want = [
|
||||
[2.244394104329227, -14.89462062457403],
|
||||
[1.884125186851836, -6.839676863508278],
|
||||
[8.18895082173591, -1.813636035924282],
|
||||
[15.96098679213407, -3.960114445242588],
|
||||
[18.79273740462331, -11.50949197954958],
|
||||
[14.34962393060787, -18.23783912753882],
|
||||
];
|
||||
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_copies_not_rotated_move_with_the_group_10_point() {
|
||||
// A polyline moves with its first vertex: three items in 270°.
|
||||
let s = run(
|
||||
&polyline([612345.678, 2398765.432], 1.0, 0.5),
|
||||
"(command \"_.-ARRAY\" a \"\" \"_P\" '(612362.9804954464 2398768.822523769) 3 270 \"_N\")",
|
||||
);
|
||||
let want = [[612359.5899716774, 2398786.125019215], [612377.612669657, 2398758.985274256], [612345.678, 2398765.432]];
|
||||
assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s));
|
||||
// A circle with its centre, five items in −90° (clockwise).
|
||||
let s = run(
|
||||
&circle([612340.715727557, 2398755.830080357], 26.60109393014347),
|
||||
"(command \"_.-ARRAY\" a \"\" \"_P\" '(612346.260090821 2398771.804712544) 5 -90 \"_N\")",
|
||||
);
|
||||
let want = [
|
||||
[612330.2854586337, 2398777.349075808],
|
||||
[612329.3797191395, 2398770.813809208],
|
||||
[612331.0438632132, 2398764.429398659],
|
||||
[612335.0245400043, 2398759.16781279],
|
||||
[612340.715727557, 2398755.830080357],
|
||||
];
|
||||
assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s));
|
||||
// An arc with its centre, its angles unchanged.
|
||||
let s = run(
|
||||
&arc([612339.185864084, 2398766.23253733], 13.970352776505141, 238.67958380809327, 80.40871664680844),
|
||||
"(command \"_.-ARRAY\" a \"\" \"_P\" '(612358.7800675274 2398749.633182222) 7 360 \"_N\")",
|
||||
);
|
||||
let want = [
|
||||
[612359.541179917, 2398775.302075975],
|
||||
[612379.3233625971, 2398765.042414074],
|
||||
[612383.6360250826, 2398743.179286296],
|
||||
[612369.2316445602, 2398726.176073787],
|
||||
[612346.957013359, 2398726.836542366],
|
||||
[612333.585383093, 2398744.663345723],
|
||||
[612339.185864084, 2398766.23253733],
|
||||
];
|
||||
assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s));
|
||||
assert!(s.doc().unwrap().model.iter().all(|e| matches!(&e.kind, EntityKind::Arc(a) if (a.start - 4.165744594740973).abs() < 1e-9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_type_asked_defaults_to_the_one_used_last() {
|
||||
let mut s = run(&circle([0.0, 0.0], 1.0), "(command \"_.-ARRAY\" a \"\" \"_P\" '(10.0 0.0) 3 360 \"_Y\")");
|
||||
s.cmdline("(command \"_.-ARRAY\" a \"\")").unwrap();
|
||||
assert!(s.prompt_text().contains("[Rectangular/Polar] <P>"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_array_by_json() {
|
||||
let mut s = run(&circle([0.0, 0.0], 1.0), "");
|
||||
let first = s.doc().unwrap().model.iter().next().unwrap().handle.hex();
|
||||
let r = s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [10, 0], "count": 4, "rotate": false })).unwrap();
|
||||
assert_eq!(r["created"].as_u64(), Some(3), "{r}");
|
||||
assert!(same(&points(&s), &[[0.0, 0.0], [10.0, -10.0], [20.0, 0.0], [10.0, 10.0]], 1e-9), "{:?}", points(&s));
|
||||
let r = s.execute("-array", &json!({ "handles": [first], "rows": 2, "cols": 3, "rowSpacing": 5, "colSpacing": 7 })).unwrap();
|
||||
assert_eq!(r["created"].as_u64(), Some(5), "{r}");
|
||||
// An unknown type, or an array past MAXARRAY, is refused before anything is made.
|
||||
assert!(s.execute("-array", &json!({ "handles": [first], "type": "path" })).is_err());
|
||||
assert!(s.execute("-array", &json!({ "handles": [first], "rows": 4294967296u64, "cols": 4294967296u64 })).is_err());
|
||||
assert!(s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [0, 0], "count": u64::MAX })).is_err());
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), 4 + 5);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! -UNITS asks for the drawing's units at the command line, as AutoCAD 2024 asks (measured with
|
||||
//! accoreconsole, running these scripts from these settings): length format and precision, angle
|
||||
//! format and precision, the direction of angle 0, and whether angles turn clockwise.
|
||||
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
|
||||
/// LUNITS, LUPREC, AUNITS, AUPREC, ANGBASE (radians; AutoCAD's 1.570796326794896 is pi/2), ANGDIR.
|
||||
type Units = (i64, i64, i64, i64, f64, i64);
|
||||
|
||||
const DEFAULT: Units = (2, 4, 0, 0, 0.0, 0);
|
||||
|
||||
fn session(u: Units) -> Session {
|
||||
let mut s = Session::new();
|
||||
let h = &mut s.doc_mut().unwrap().header;
|
||||
h.set_i64("LUNITS", u.0);
|
||||
h.set_i64("LUPREC", u.1);
|
||||
h.set_i64("AUNITS", u.2);
|
||||
h.set_i64("AUPREC", u.3);
|
||||
h.set_f64("ANGBASE", u.4);
|
||||
h.set_i64("ANGDIR", u.5);
|
||||
s
|
||||
}
|
||||
|
||||
fn units(s: &Session) -> Units {
|
||||
let h = &s.doc().unwrap().header;
|
||||
(h.i64("LUNITS", 2), h.i64("LUPREC", 4), h.i64("AUNITS", 0), h.i64("AUPREC", 0), h.f64("ANGBASE", 0.0), h.i64("ANGDIR", 0))
|
||||
}
|
||||
|
||||
fn same(a: Units, b: Units) -> bool {
|
||||
(a.0, a.1, a.2, a.3, a.5) == (b.0, b.1, b.2, b.3, b.5) && (a.4 - b.4).abs() <= 1e-12
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, answers: &str) {
|
||||
s.cmdline(&format!("(command \"_.-UNITS\" {answers})")).unwrap();
|
||||
assert!(s.running.is_none(), "{answers}: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_measured_scripts_set_the_units() {
|
||||
let cases: [(Units, &str, Units); 21] = [
|
||||
(DEFAULT, "4 16 1 0 0 \"_N\"", (4, 4, 0, 0, 0.0, 0)),
|
||||
(DEFAULT, "3 2 2 4 90 \"_Y\"", (3, 2, 1, 4, FRAC_PI_2, 1)),
|
||||
(DEFAULT, "2 3 3 2 100 \"_N\"", (2, 3, 2, 2, FRAC_PI_2, 0)),
|
||||
(DEFAULT, "5 8 5 2 \"N\" \"_N\"", (5, 3, 4, 2, FRAC_PI_2, 0)),
|
||||
((4, 3, 1, 2, 0.5, 1), "\"\" \"\" \"\" \"\" \"\" \"\"", (4, 3, 1, 2, 0.5, 1)),
|
||||
((2, 4, 0, 0, FRAC_PI_2, 1), "2 4 1 0 45 \"_N\"", (2, 4, 0, 0, 5.497787143782137, 0)),
|
||||
(DEFAULT, "1 2 4 3 30 \"_N\"", (1, 2, 3, 3, 4.867258771281656, 0)),
|
||||
((2, 2, 0, 2, 0.0, 0), "3 \"\" 2 \"\" \"\" \"\"", (3, 4, 1, 4, 0.0, 0)),
|
||||
((2, 2, 0, 2, 0.0, 0), "2 \"\" 1 \"\" \"\" \"\"", (2, 2, 0, 2, 0.0, 0)),
|
||||
(DEFAULT, "4 3 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)),
|
||||
((2, 4, 0, 0, 0.0, 1), "2 4 5 4 \"N45dE\" \"\"", (2, 4, 4, 4, 5.497787143782137, 1)),
|
||||
(DEFAULT, "6 \"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 0, 0, 0.0, 0)),
|
||||
((2, 2, 0, 0, 0.0, 0), "4 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)),
|
||||
((5, 6, 0, 0, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (5, 6, 0, 0, 0.0, 0)),
|
||||
((2, 4, 0, 0, 0.0, 1), "\"\" \"\" 5 \"\" \"N\" \"\"", (2, 4, 4, 4, 4.712388980384689, 1)),
|
||||
((2, 4, 3, 4, 0.0, 0), "\"\" \"\" \"\" \"\" \"45d\" \"\"", (2, 4, 3, 4, 0.7853981633974482, 0)),
|
||||
(DEFAULT, "5 1 \"\" \"\" \"\" \"\"", (5, 0, 0, 0, 0.0, 0)),
|
||||
((1, 7, 0, 7, 0.0, 0), "3 \"\" 1 \"\" \"\" \"\"", (3, 4, 0, 7, 0.0, 0)),
|
||||
(DEFAULT, "\"\" \"\" \"\" \"\" \"\" \"Yes\"", (2, 4, 0, 0, 0.0, 1)),
|
||||
((2, 4, 2, 6, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 2, 6, 0.0, 0)),
|
||||
((2, 4, 4, 2, 0.0, 1), "\"\" \"\" \"\" \"\" \"S45dE\" \"\"", (2, 4, 4, 2, 0.7853981633974482, 1)),
|
||||
];
|
||||
for (from, answers, to) in cases {
|
||||
let mut s = session(from);
|
||||
run(&mut s, answers);
|
||||
assert!(same(units(&s), to), "{answers}: {:?}, AutoCAD {to:?}", units(&s));
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt, after the command's name.
|
||||
fn asks(s: &Session) -> String {
|
||||
let p = s.prompt_text();
|
||||
p.strip_prefix("-UNITS ").unwrap_or(&p).to_string()
|
||||
}
|
||||
|
||||
fn ends_with(s: &Session, lines: &[&str]) -> bool {
|
||||
s.log.len() >= lines.len() && s.log[s.log.len() - lines.len()..].iter().zip(lines).all(|(a, b)| a == b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompts_and_listings_show_the_drawings_units() {
|
||||
// Architectural to 1/8, degrees/minutes/seconds to 2 places, angle 0 at 0.5 radians, clockwise.
|
||||
let mut s = session((4, 3, 1, 2, 0.5, 1));
|
||||
s.cmdline("-units").unwrap();
|
||||
assert!(s.log.iter().any(|l| l == " 4. Architectural 1'-3 1/2\""), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "Enter choice, 1 to 5 <4>:");
|
||||
s.cmdline("").unwrap();
|
||||
assert!(ends_with(&s, &["Enter denominator of smallest fraction to display"]), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <8>:");
|
||||
s.cmdline("").unwrap();
|
||||
assert!(ends_with(&s, &[" 4. Radians 0.7854r", " 5. Surveyor's units N 45d0'0\" E"]), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:");
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(asks(&s), "Enter number of fractional places for display of angles (0 to 8) <2>:");
|
||||
s.cmdline("").unwrap();
|
||||
let directions = [
|
||||
"Direction for angle 0d0':",
|
||||
" East 3 o'clock = 0d0'",
|
||||
" North 12 o'clock = 270d0'",
|
||||
" West 9 o'clock = 180d0'",
|
||||
" South 6 o'clock = 90d0'",
|
||||
];
|
||||
assert!(ends_with(&s, &directions), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "Enter direction for angle 0d0' <331d21'>:");
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(asks(&s), "Measure angles clockwise? [Yes/No] <Y>:");
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(same(units(&s), (4, 3, 1, 2, 0.5, 1)), "{:?}", units(&s));
|
||||
|
||||
// Surveyor's units, clockwise: north is listed as S, as AutoCAD lists it.
|
||||
let mut s = session((2, 4, 0, 0, 0.0, 1));
|
||||
s.script(
|
||||
"-units
|
||||
|
||||
|
||||
5
|
||||
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let directions = [
|
||||
"Direction for angle E:",
|
||||
" East 3 o'clock = E",
|
||||
" North 12 o'clock = S",
|
||||
" West 9 o'clock = W",
|
||||
" South 6 o'clock = N",
|
||||
];
|
||||
assert!(ends_with(&s, &directions), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "Enter direction for angle E <E>:");
|
||||
|
||||
// The listing shows directions to the precision just given.
|
||||
let mut s = session(DEFAULT);
|
||||
s.script(
|
||||
"-units
|
||||
|
||||
|
||||
2
|
||||
2
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let directions = [
|
||||
"Direction for angle 0d0':",
|
||||
" East 3 o'clock = 0d0'",
|
||||
" North 12 o'clock = 90d0'",
|
||||
" West 9 o'clock = 180d0'",
|
||||
" South 6 o'clock = 270d0'",
|
||||
];
|
||||
assert!(ends_with(&s, &directions), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "Enter direction for angle 0d0' <0d0'>:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_answer_is_asked_again() {
|
||||
let mut s = session(DEFAULT);
|
||||
s.cmdline("-units").unwrap();
|
||||
s.cmdline("6").unwrap();
|
||||
assert!(ends_with(&s, &["Requires an integer between 1 and 5."]), "{:?}", s.log);
|
||||
s.cmdline("2.5").unwrap();
|
||||
assert!(ends_with(&s, &["Requires an integer value."]), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:");
|
||||
s.cmdline("4").unwrap();
|
||||
s.cmdline("3").unwrap();
|
||||
assert!(ends_with(&s, &["Invalid choice.", "Enter denominator of smallest fraction to display"]), "{:?}", s.log);
|
||||
assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <16>:");
|
||||
s.script(
|
||||
"
|
||||
|
||||
9
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ends_with(&s, &["Requires an integer between 0 and 8."]), "{:?}", s.log);
|
||||
s.script(
|
||||
"
|
||||
abc
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ends_with(&s, &["Requires valid numeric angle or two points."]), "{:?}", s.log);
|
||||
s.script(
|
||||
"
|
||||
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(same(units(&s), (4, 4, 0, 0, 0.0, 0)), "{:?}", units(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_answer_is_kept_as_it_is_given() {
|
||||
// Esc after the angle format: the answers before it are kept, and the new angle format's
|
||||
// precision is reset to 4.
|
||||
let mut s = session(DEFAULT);
|
||||
s.script(
|
||||
"-units
|
||||
4
|
||||
16
|
||||
2
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.cancel();
|
||||
assert!(s.running.is_none());
|
||||
assert!(same(units(&s), (4, 4, 1, 4, 0.0, 0)), "{:?}", units(&s));
|
||||
let mut s = session(DEFAULT);
|
||||
s.script(
|
||||
"-units
|
||||
3
|
||||
2
|
||||
3
|
||||
5
|
||||
90
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
s.cancel();
|
||||
assert!(same(units(&s), (3, 2, 2, 5, 1.413716694115407, 0)), "{:?}", units(&s));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! INSERT's attribute questions as AutoCAD 2024 asks them from AutoLISP's `command` (accoreconsole,
|
||||
//! the attributes read back with `entget`).
|
||||
|
||||
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
|
||||
/// A session with block K: a circle and attributes (tag, DXF flags, default value), each with the
|
||||
/// prompt "P" + tag.
|
||||
fn with_block(defs: &[(&str, i32, &str)]) -> Session {
|
||||
// `entmake` goes through the DXF codec, as in the app.
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
let mut s = Session::new();
|
||||
let mut code =
|
||||
String::from("(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0)))");
|
||||
for (i, (tag, flags, default)) in defs.iter().enumerate() {
|
||||
let y = 5.0 + 3.0 * i as f64;
|
||||
code += &format!(
|
||||
" (entmake '((0 . \"ATTDEF\") (10 0.0 {y:.1} 0.0) (40 . 2.5) (1 . \"{default}\") (3 . \"P{tag}\") (2 . \"{tag}\") (70 . {flags})))"
|
||||
);
|
||||
}
|
||||
code += " (entmake '((0 . \"ENDBLK\")))";
|
||||
s.cmdline(&format!("(progn {code})")).unwrap();
|
||||
s.log.clear();
|
||||
s
|
||||
}
|
||||
|
||||
/// The attribute values of the last block reference, by tag.
|
||||
fn values(s: &Session) -> Vec<(String, String)> {
|
||||
let d = s.doc().unwrap();
|
||||
let Some(EntityKind::Insert(i)) = d.model.iter().last().map(|e| &e.kind) else { panic!("no block reference") };
|
||||
i.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect()
|
||||
}
|
||||
|
||||
fn said(s: &Session, line: &str) -> usize {
|
||||
s.log.iter().filter(|l| l.as_str() == line).count()
|
||||
}
|
||||
|
||||
fn pair(tag: &str, value: &str) -> (String, String) {
|
||||
(tag.to_string(), value.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attreq_0_inserts_the_default_values_without_asking() {
|
||||
let mut s = with_block(&[("T1", 0, "V1"), ("T2", 0, "V2")]);
|
||||
s.cmdline("(progn (setvar \"ATTREQ\" 0) (command \"_.-INSERT\" \"K\" '(100.0 0.0) \"\" \"\" 0))").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "V2")]);
|
||||
assert_eq!(said(&s, "Enter attribute values"), 0, "{:?}", s.log);
|
||||
s.cmdline("(setvar \"ATTREQ\" 1)").unwrap();
|
||||
s.cmdline("(princ (getvar \"ATTREQ\"))").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("1"), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_attribute_is_not_asked() {
|
||||
let mut s = with_block(&[("T1", 8, "V1"), ("T2", 0, "V2")]);
|
||||
s.cmdline("(command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"X\")").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "X")]);
|
||||
assert_eq!(said(&s, "Enter attribute values"), 1, "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_verify_attribute_is_asked_again_with_the_value_typed() {
|
||||
let mut s = with_block(&[("T1", 4, "V1")]);
|
||||
s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\")").unwrap();
|
||||
assert_eq!(said(&s, "Verify attribute values"), 1, "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("PT1 <X>"), "{}", s.prompt_text());
|
||||
// A click is no answer.
|
||||
s.input(Input::Point(Vec2::new(1.0, 1.0))).unwrap();
|
||||
assert!(s.prompt_text().contains("PT1 <X>"), "{}", s.prompt_text());
|
||||
// Typed at the prompt, the whole line is the value.
|
||||
s.cmdline("Y").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(values(&s), [pair("T1", "Y")]);
|
||||
// Enter keeps the value typed.
|
||||
let mut s = with_block(&[("T1", 4, "V1")]);
|
||||
s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\" \"\")").unwrap();
|
||||
assert_eq!(values(&s), [pair("T1", "X")]);
|
||||
}
|
||||
@@ -19,13 +19,16 @@ mod break_closed_polyline;
|
||||
mod break_join_locked_layers;
|
||||
mod break_keeps_z;
|
||||
mod burst;
|
||||
mod change_command;
|
||||
mod chprop_command;
|
||||
mod classic_array;
|
||||
mod compare_export;
|
||||
mod constraint_geometry;
|
||||
mod constraint_infer;
|
||||
mod copy_count;
|
||||
mod count;
|
||||
mod dash_block;
|
||||
mod dash_units;
|
||||
mod data_extraction;
|
||||
mod dim_baseline_spacing;
|
||||
mod dim_chain;
|
||||
@@ -57,6 +60,7 @@ mod hatch_options;
|
||||
mod helix;
|
||||
mod inquiry_keywords;
|
||||
mod inquiry_z;
|
||||
mod insert_attribute_prompts;
|
||||
mod insert_prompts;
|
||||
mod isometric;
|
||||
mod join_close;
|
||||
@@ -71,6 +75,7 @@ mod malformed_handles;
|
||||
mod matchprop;
|
||||
mod measure_distance_snapping;
|
||||
mod metric_linetypes;
|
||||
mod mirror_degenerate_line;
|
||||
mod mleader_content;
|
||||
mod mleader_remember;
|
||||
mod mline;
|
||||
@@ -107,10 +112,12 @@ mod query_entities;
|
||||
mod quickcalc;
|
||||
mod recent_commands;
|
||||
mod rectang_ortho;
|
||||
mod rectang_polygon_order;
|
||||
mod refedit;
|
||||
mod region;
|
||||
mod rename_cmdline;
|
||||
mod rotate_reference;
|
||||
mod scale_offset_z;
|
||||
mod select_keywords;
|
||||
mod select_similar;
|
||||
mod select_underscore_keywords;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//! MIRROR refuses a mirror line whose points are closer than the geometry tolerance.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn nearly_coincident_mirror_points_are_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({ "points": [[0, 5], [10, 5]] })).unwrap();
|
||||
let before = s.doc().unwrap().model.iter().count();
|
||||
for erase in [false, true] {
|
||||
let r = s.execute("mirror", &json!({ "handles": ["100"], "p1": [0, 0], "p2": [5e-10, 0], "erase": erase }));
|
||||
let err = r.expect_err("mirror line is degenerate").to_string();
|
||||
assert!(err.contains("mirror line points coincide"), "{err}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), before);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! RECTANG and POLYGON vertices in AutoCAD 2024's order, and the RECTANG modes it remembers from
|
||||
//! one rectangle to the next. The scripts and numbers were measured with accoreconsole; the
|
||||
//! RECTANG scripts reset Chamfer, Fillet, Width, Elevation and Thickness first, but not Rotation.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
type Vertex = (f64, f64, f64);
|
||||
|
||||
const RESET: &str = "\"_C\" 0 0 \"_F\" 0 \"_W\" 0 \"_E\" 0 \"_T\" 0";
|
||||
|
||||
/// The last polyline's vertices (x, y, bulge).
|
||||
fn last_polyline(s: &Session) -> Vec<Vertex> {
|
||||
let d = s.doc().unwrap();
|
||||
match &d.model.iter().last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect(),
|
||||
k => panic!("not a polyline: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect(got: &[Vertex], want: &[Vertex]) {
|
||||
assert_eq!(got.len(), want.len(), "{got:?}");
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
let tol = 1e-9 * (1.0 + w.0.abs().max(w.1.abs()));
|
||||
assert!((g.0 - w.0).abs() < tol && (g.1 - w.1).abs() < tol && (g.2 - w.2).abs() < 1e-9, "{got:?}\n!= {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn lisp(s: &mut Session, expr: &str) {
|
||||
s.cmdline(expr).unwrap();
|
||||
assert!(s.running.is_none(), "{expr}: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
fn rectang(s: &mut Session, rest: &str) {
|
||||
lisp(s, &format!("(command \"_.RECTANG\" {RESET} {rest})"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_starts_at_its_first_corner_and_runs_along_x_first() {
|
||||
let mut s = Session::new();
|
||||
rectang(&mut s, "'(-2.2557479240466947 -9.488221049171962) '(-31.36203661716106 -47.886714708394166)");
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[
|
||||
(-2.255747924046694, -9.488221049171962, 0.0),
|
||||
(-31.36203661716105, -9.488221049171962, 0.0),
|
||||
(-31.36203661716105, -47.88671470839417, 0.0),
|
||||
(-2.255747924046694, -47.88671470839417, 0.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfers_cut_the_edge_into_each_corner_by_the_first_distance() {
|
||||
let mut s = Session::new();
|
||||
rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(4.8089419289768 29.66648885915597) '(28.114797694689763 43.85884965263156)");
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[
|
||||
(4.8089419289768, 31.91829932479258, 0.0),
|
||||
(8.575279497862834, 29.66648885915597, 0.0),
|
||||
(25.86298722905314, 29.66648885915597, 0.0),
|
||||
(28.11479769468976, 33.432826428042, 0.0),
|
||||
(28.11479769468976, 41.60703918699494, 0.0),
|
||||
(24.34846012580373, 43.85884965263156, 0.0),
|
||||
(7.060752394613416, 43.85884965263156, 0.0),
|
||||
(4.8089419289768, 40.09251208374553, 0.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_arcs_start_on_the_edge_into_each_corner() {
|
||||
let mut s = Session::new();
|
||||
rectang(&mut s, "\"_F\" 0.6042729201106594 '(-13.457476758444333 -29.884431038180395) '(14.673991980287038 27.14695046607133)");
|
||||
let b = 0.414213562373095;
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[
|
||||
(-13.45747675844433, -29.28015811806973, b),
|
||||
(-12.85320383833367, -29.88443103818039, 0.0),
|
||||
(14.06971906017638, -29.88443103818039, b),
|
||||
(14.67399198028703, -29.28015811806973, 0.0),
|
||||
(14.67399198028703, 26.54267754596067, b),
|
||||
(14.06971906017638, 27.14695046607133, 0.0),
|
||||
(-12.85320383833367, 27.14695046607133, b),
|
||||
(-13.45747675844433, 26.54267754596067, 0.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_arcs_turn_clockwise_when_the_second_corner_is_below_the_first() {
|
||||
// The second corner down and right: the outline runs clockwise and the bulges are negative
|
||||
// (AutoCAD gave -0.414213562373095 for such a rectangle, there also rotated).
|
||||
let mut s = Session::new();
|
||||
rectang(&mut s, "\"_F\" 1.0 '(0.0 0.0) '(10.0 -5.0)");
|
||||
let b = -0.414213562373095;
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[(0.0, -1.0, b), (1.0, 0.0, 0.0), (9.0, 0.0, b), (10.0, -1.0, 0.0), (10.0, -4.0, b), (9.0, -5.0, 0.0), (1.0, -5.0, b), (0.0, -4.0, 0.0)],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_rectangle_keeps_the_rotation_and_lists_the_modes() {
|
||||
let mut s = Session::new();
|
||||
rectang(&mut s, "'(27.647422687394844 28.925709913163516) \"_R\" 50.50299224048081 '(46.86449612780332 4.979921576806525)");
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[
|
||||
(27.64742268739484, 28.92570991316351, 0.0),
|
||||
(23.66889931823719, 24.09886156025833, 0.0),
|
||||
(46.86449612780331, 4.979921576806525, 0.0),
|
||||
(50.84301949696097, 9.80676992971171, 0.0),
|
||||
],
|
||||
);
|
||||
// No Rotation option this time: the rectangle still turns by 50.50 degrees.
|
||||
rectang(&mut s, "'(14.3947813348612 23.649728466870016) \"_D\" 19.35761675393126 49.775239210761946 '(-4.9628354190700605 -26.12551074389193)");
|
||||
assert!(s.log.iter().any(|l| l == "Current rectangle modes: Rotation=50.50"), "{:?}", s.log);
|
||||
// The next Rotation prompt offers it as its default, as AutoCAD shows it.
|
||||
s.cmdline("RECTANG 0,0 R").unwrap();
|
||||
assert!(s.prompt_text().ends_with("Specify rotation angle or [Pick points] <50.50>:"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[
|
||||
(14.3947813348612, 23.64972846687001, 0.0),
|
||||
(2.082603004810238, 8.712272486646885, 0.0),
|
||||
(40.4920546468795, -22.94666721518322, 0.0),
|
||||
(52.80423297693047, -8.009211234960095, 0.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevation_thickness_and_a_picked_rotation_carry_over_together() {
|
||||
let mut s = Session::new();
|
||||
rectang(
|
||||
&mut s,
|
||||
"\"_E\" 7.5 \"_T\" 2.25 '(-18.42481827666694 -20.50343704206707) \"_R\" \"_P\" '(0.0 0.0) '(10.0 6.0) '(10.197290578707722 25.5222006119985)",
|
||||
);
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[
|
||||
(-18.42481827666694, -20.50343704206706, 0.0),
|
||||
(22.92627837613747, 4.307220949615576, 0.0),
|
||||
(10.19729057870772, 25.5222006119985, 0.0),
|
||||
(-31.15380607409669, 0.7115426203158534, 0.0),
|
||||
],
|
||||
);
|
||||
let d = s.doc().unwrap();
|
||||
let e = d.model.iter().last().unwrap();
|
||||
let EntityKind::LwPolyline(p) = &e.kind else { panic!("not a polyline") };
|
||||
assert_eq!((p.elevation, e.common.thickness), (7.5, 2.25));
|
||||
s.cmdline("RECTANG").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Elevation=7.5000 Thickness=2.2500 Rotation=30.96"));
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_and_fillet_are_remembered_one_at_a_time() {
|
||||
let mut s = Session::new();
|
||||
rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(0.0 0.0) '(40.0 30.0)");
|
||||
rectang(&mut s, "\"_F\" 0.6042729201106594 '(0.0 0.0) '(40.0 30.0)");
|
||||
let logs: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Current rectangle modes:")).collect();
|
||||
assert_eq!(logs, ["Current rectangle modes: Chamfer=2.2518 x 3.7663"]);
|
||||
s.cmdline("RECTANG").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=0.6043"));
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_a_fillet_clears_the_chamfer_and_back() {
|
||||
// A chamfer set after a fillet makes a chamfered rectangle, and the other way round.
|
||||
let mut s = Session::new();
|
||||
s.script("RECTANG F 1 C 2 3 0,0 40,30\n").unwrap();
|
||||
assert!(last_polyline(&s).iter().all(|v| v.2 == 0.0), "{:?}", last_polyline(&s));
|
||||
assert_eq!(last_polyline(&s).first().map(|v| (v.0, v.1)), Some((0.0, 2.0)));
|
||||
s.script("RECTANG C 2 3 F 1 0,0 40,30\n").unwrap();
|
||||
assert!(s.log.iter().any(|l| l == "Current rectangle modes: Chamfer=2.0000 x 3.0000"), "{:?}", s.log);
|
||||
expect(&last_polyline(&s)[..1], &[(0.0, 1.0, 0.414213562373095)]);
|
||||
s.cmdline("RECTANG").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=1.0000"));
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_ignores_the_remembered_modes() {
|
||||
let mut s = Session::new();
|
||||
rectang(&mut s, "'(0.0 0.0) \"_R\" 30 '(10.0 10.0)");
|
||||
s.execute("rectang", &json!({ "p1": [100, 0], "p2": [110, 10] })).unwrap();
|
||||
expect(&last_polyline(&s), &[(100.0, 0.0, 0.0), (110.0, 0.0, 0.0), (110.0, 10.0, 0.0), (100.0, 10.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circumscribed_polygon_starts_counter_clockwise_from_the_picked_edge() {
|
||||
let mut s = Session::new();
|
||||
lisp(&mut s, "(command \"_.POLYGON\" 4 '(-13.660465775726184 6.051830973104977) \"_C\" '(-33.469717497592136 8.913096552751803))");
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[
|
||||
(-36.33098307723896, -10.89615516911414, 0.0),
|
||||
(3.287520366492948, -16.61868632840779, 0.0),
|
||||
(9.01005152578659, 22.9998171153241, 0.0),
|
||||
(-30.60845191794531, 28.72234827461775, 0.0),
|
||||
],
|
||||
);
|
||||
lisp(&mut s, "(command \"_.POLYGON\" 3 '(-4.866064513702604 -0.5675999154110016) \"_C\" '(-33.004110114447 14.119144448101377))");
|
||||
expect(
|
||||
&last_polyline(&s),
|
||||
&[(-58.44229754982628, -34.61738015807783, 0.0), (51.41002668778618, -29.94108864243579, 0.0), (-7.565922679067647, 62.85566905428061, 0.0)],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! SCALE scales heights too and OFFSET keeps them, as AutoCAD 2024 does (measured with
|
||||
//! accoreconsole, running these scripts on these drawings): SCALE scales every Z and elevation
|
||||
//! about the base point's Z (the current elevation for a point given without one) and every
|
||||
//! thickness by the factor; OFFSET's copy is at the source's height, and a circle's or arc's copy
|
||||
//! has no thickness while a polyline's keeps it.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
use serde_json::json;
|
||||
|
||||
fn add(s: &mut Session, kind: EntityKind, thickness: f64) -> Handle {
|
||||
let space = s.space();
|
||||
s.doc_mut().unwrap().add(&space, Common { thickness, ..Common::default() }, kind).unwrap()
|
||||
}
|
||||
|
||||
fn lisp(s: &mut Session, expr: &str) {
|
||||
s.cmdline(expr).unwrap();
|
||||
assert!(s.running.is_none(), "{expr}: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
fn get(s: &Session, h: Handle) -> (EntityKind, f64) {
|
||||
let e = s.doc().unwrap().entity(h).unwrap().clone();
|
||||
(e.kind.clone(), e.common.thickness)
|
||||
}
|
||||
|
||||
fn last(s: &Session) -> (EntityKind, f64) {
|
||||
let e = s.doc().unwrap().model.iter().last().unwrap().clone();
|
||||
(e.kind.clone(), e.common.thickness)
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-9 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
fn near3(a: Vec3, b: Vec3) -> bool {
|
||||
close(a.x, b.x) && close(a.y, b.y) && close(a.z, b.z)
|
||||
}
|
||||
|
||||
fn scale(s: &mut Session, h: Handle, base: &str) {
|
||||
lisp(s, &format!("(command \"_.SCALE\" (handent \"{}\") \"\" '({base}) 1.7)", h.hex()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_a_lines_z() {
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0);
|
||||
scale(&mut s, h, "2.0 3.0");
|
||||
let EntityKind::Line(l) = get(&s, h).0 else { panic!() };
|
||||
assert!(near3(l.a, Vec3::new(-1.4, -2.1, 11.9)) && near3(l.b, Vec3::new(66.6, -2.1, 11.9)), "{l:?}");
|
||||
// A sloped line at survey coordinates.
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(612345.678, 2398765.432, 3.0), b: Vec3::new(612375.678, 2398775.432, 8.0) }), 0.0);
|
||||
scale(&mut s, h, "612347.678 2398768.432");
|
||||
let EntityKind::Line(l) = get(&s, h).0 else { panic!() };
|
||||
assert!(near3(l.a, Vec3::new(612344.278, 2398763.332, 5.1)) && near3(l.b, Vec3::new(612395.278, 2398780.332, 13.6)), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_the_centre_z_and_thickness_of_circles_and_arcs() {
|
||||
let mut s = Session::new();
|
||||
let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.5, 0.25, 0.005), radius: 0.01 }), 0.002);
|
||||
scale(&mut s, c, "0.502 0.253");
|
||||
let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() };
|
||||
assert!(near3(k.center, Vec3::new(0.4986, 0.2479, 0.0085)) && close(k.radius, 0.017) && close(t, 0.0034), "{k:?} {t}");
|
||||
let mut s = Session::new();
|
||||
let arc = Arc { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0, start: 30f64.to_radians(), end: 200f64.to_radians() };
|
||||
let a = add(&mut s, EntityKind::Arc(arc), 1.5);
|
||||
scale(&mut s, a, "2.0 3.0");
|
||||
let (EntityKind::Arc(k), t) = get(&s, a) else { panic!() };
|
||||
assert!(near3(k.center, Vec3::new(-1.4, -2.1, 7.65)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_a_polylines_elevation_and_thickness_and_a_points_z() {
|
||||
let mut s = Session::new();
|
||||
let vs = [(612345.678, 2398765.432, 0.0), (612365.678, 2398765.432, 0.4), (612375.678, 2398780.432, 0.0), (612350.678, 2398785.432, 0.0)];
|
||||
let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect();
|
||||
let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0);
|
||||
scale(&mut s, p, "612347.678 2398768.432");
|
||||
let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() };
|
||||
assert!(close(k.elevation, 21.25) && close(t, 1.7), "{} {t}", k.elevation);
|
||||
let first = k.vertices.first().unwrap().p;
|
||||
assert!(close(first.x, 612344.278) && close(first.y, 2398763.332), "{first:?}");
|
||||
let pt = add(&mut s, EntityKind::Point(Point { p: Vec3::new(5.0, 5.0, 6.0), angle: 0.0 }), 0.0);
|
||||
scale(&mut s, pt, "2.0 3.0");
|
||||
let EntityKind::Point(k) = get(&s, pt).0 else { panic!() };
|
||||
assert!(near3(k.p, Vec3::new(7.1, 6.4, 10.2)), "{k:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_scales_heights_about_the_current_elevation() {
|
||||
// ELEVATION 5: the base point, given without a Z, is at Z = 5.
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0);
|
||||
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0);
|
||||
scale(&mut s, h, "2.0 3.0");
|
||||
let EntityKind::Line(l) = get(&s, h).0 else { panic!() };
|
||||
assert!(near3(l.a, Vec3::new(-1.4, -2.1, 8.4)) && near3(l.b, Vec3::new(66.6, -2.1, 8.4)), "{l:?}");
|
||||
let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0 }), 1.5);
|
||||
scale(&mut s, c, "2.0 3.0");
|
||||
let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() };
|
||||
assert!(near3(k.center, Vec3::new(-1.4, -2.1, 4.15)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}");
|
||||
let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0)];
|
||||
let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect();
|
||||
let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0);
|
||||
scale(&mut s, p, "2.0 3.0");
|
||||
let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() };
|
||||
assert!(close(k.elevation, 17.75) && close(t, 1.7), "{} {t}", k.elevation);
|
||||
let last = k.vertices.last().unwrap().p;
|
||||
assert!(close(last.x, 49.6) && close(last.y, 23.4), "{last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_scale_takes_the_base_points_z() {
|
||||
let line = || EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) });
|
||||
let z = |s: &Session, h: Handle| {
|
||||
let EntityKind::Line(l) = get(s, h).0 else { panic!() };
|
||||
(l.a.z, l.b.z)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, line(), 0.0);
|
||||
s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, 1.0], "factor": 1.7 })).unwrap();
|
||||
let (a, b) = z(&s, h);
|
||||
assert!(close(a, 11.2) && close(b, 11.2), "{a} {b}");
|
||||
// Without one, the current elevation (not measured through JSON: the same base point as the
|
||||
// command line's).
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0);
|
||||
let h = add(&mut s, line(), 0.0);
|
||||
s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0], "factor": 1.7 })).unwrap();
|
||||
let (a, b) = z(&s, h);
|
||||
assert!(close(a, 8.4) && close(b, 8.4), "{a} {b}");
|
||||
// A Z that isn't a number is refused, the drawing left as it was.
|
||||
assert!(s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, "1"], "factor": 1.7 })).is_err());
|
||||
assert_eq!(z(&s, h), (a, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_and_rotate_leave_heights_alone() {
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 2.0);
|
||||
lisp(&mut s, &format!("(command \"_.ROTATE\" (handent \"{}\") \"\" '(0.0 0.0) 90)", h.hex()));
|
||||
lisp(&mut s, &format!("(command \"_.MOVE\" (handent \"{}\") \"\" '(0.0 0.0) '(5.0 5.0))", h.hex()));
|
||||
let (EntityKind::Line(l), t) = get(&s, h) else { panic!() };
|
||||
assert!(close(l.a.z, 7.0) && close(l.b.z, 7.0) && close(t, 2.0), "{l:?} {t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_keeps_a_lines_z() {
|
||||
let mut s = Session::new();
|
||||
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0);
|
||||
lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(20.0 0.0)) '(10.0 5.0) \"\")", h.hex()));
|
||||
let EntityKind::Line(l) = last(&s).0 else { panic!() };
|
||||
assert!(near3(l.a, Vec3::new(0.0, 3.0, 7.0)) && near3(l.b, Vec3::new(40.0, 3.0, 7.0)), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_keeps_the_centre_z_of_circles_and_arcs_but_not_their_thickness() {
|
||||
let mut s = Session::new();
|
||||
let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(612345.678, 2398765.432, 5.0), radius: 10.0 }), 2.0);
|
||||
lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(612355.678 2398765.432)) '(612345.678 2398780.432) \"\")", c.hex()));
|
||||
let (EntityKind::Circle(k), t) = last(&s) else { panic!() };
|
||||
assert!(close(k.center.z, 5.0) && close(k.radius, 13.0) && t == 0.0, "{k:?} {t}");
|
||||
let mut s = Session::new();
|
||||
let arc = Arc { center: Vec3::new(-35000.0, 81000.0, 225.0), radius: 500.0, start: 30f64.to_radians(), end: 200f64.to_radians() };
|
||||
let a = add(&mut s, EntityKind::Arc(arc), 75.0);
|
||||
lisp(&mut s, &format!("(command \"_.OFFSET\" 150.0 (list (handent \"{}\") '(-35000.0 81500.0)) '(-35000.0 81750.0) \"\")", a.hex()));
|
||||
let (EntityKind::Arc(k), t) = last(&s) else { panic!() };
|
||||
assert!(close(k.center.z, 225.0) && close(k.radius, 650.0) && t == 0.0, "{k:?} {t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_keeps_a_polylines_elevation_and_thickness() {
|
||||
let mut s = Session::new();
|
||||
let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0), (5.0, 20.0, 0.0)];
|
||||
let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect();
|
||||
let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0);
|
||||
lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(10.0 0.0)) '(12.0 6.0) \"\")", p.hex()));
|
||||
let (EntityKind::LwPolyline(k), t) = last(&s) else { panic!() };
|
||||
assert!(close(k.elevation, 12.5) && close(t, 1.0), "{} {t}", k.elevation);
|
||||
let first = k.vertices.first().unwrap().p;
|
||||
assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}");
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fn each_save_keeps_the_previous_file_as_bak() {
|
||||
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
|
||||
assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]);
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save");
|
||||
|
||||
add_circle(&mut s, 10.0);
|
||||
@@ -118,7 +118,7 @@ fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() {
|
||||
add_circle(&mut s, 15.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1));
|
||||
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
|
||||
assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -294,3 +294,13 @@ fn the_backup_a_save_promises_is_the_one_it_writes() {
|
||||
assert!(!save_keeps_current(&path), "backups off");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `plan.dwg` and `plan.dxf` in one folder keep their own backups, in the DWG's letter case.
|
||||
#[test]
|
||||
fn drawings_sharing_a_name_keep_their_own_backups() {
|
||||
let name = |p: &str| backup_path(std::path::Path::new(p)).to_string_lossy().into_owned();
|
||||
assert_eq!(name("x/plan.dwg"), "x/plan.bak");
|
||||
assert_eq!(name("x/plan.DWG"), "x/plan.BAK");
|
||||
assert_eq!(name("x/plan.dxf"), "x/plan.dxf.bak");
|
||||
assert_eq!(name("x/plan.dwt"), "x/plan.dwt.bak");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! WBLOCK writes the source drawing's units: `$INSUNITS` and `$MEASUREMENT` of the written file
|
||||
//! are the source's, for selected objects and for a block, as AutoCAD's `-WBLOCK` does. So are its
|
||||
//! other settings and styles: a metric drawing's text size, dimension style and scale list.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/temp_dir.rs"]
|
||||
mod temp_dir;
|
||||
|
||||
use temp_dir::TempDir;
|
||||
|
||||
/// Writes "INSUNITS;MEASUREMENT" and, on a second line, the settings a metric drawing changes, so
|
||||
/// the test can read them from the file that would be saved.
|
||||
fn write(d: &Drawing, _path: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(format!("{};{}\n{}", d.header.i64("INSUNITS", -1), d.header.i64("MEASUREMENT", -1), settings(d)).into_bytes())
|
||||
}
|
||||
|
||||
fn settings(d: &Drawing) -> String {
|
||||
let mleader = d.mleader_styles.first().map(|m| format!("{m:?}")).unwrap_or_default();
|
||||
let table = d.table_styles.first().map(|t| format!("{t:?}")).unwrap_or_default();
|
||||
let limmax = d.header.point("LIMMAX");
|
||||
format!("{} {} {limmax:?} {} {mleader} {table}", d.header.str("DIMSTYLE", ""), d.header.f64("TEXTSIZE", -1.0), d.scales.len())
|
||||
}
|
||||
|
||||
/// The file WBLOCK would save: line `n` of what [`write`] wrote. Each call writes into a folder
|
||||
/// of its own, so tests running in parallel never read or delete each other's file.
|
||||
fn wblock(s: &mut Session, p: serde_json::Value, n: usize) -> String {
|
||||
let dir = TempDir::new("wblock-units");
|
||||
let path = dir.path_str("out.dxf");
|
||||
let mut p = p;
|
||||
p["path"] = json!(path);
|
||||
s.execute("wblock", &p).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
text.lines().nth(n).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn written(s: &mut Session, p: serde_json::Value) -> String {
|
||||
wblock(s, p, 0)
|
||||
}
|
||||
|
||||
fn written_settings(s: &mut Session, p: serde_json::Value) -> String {
|
||||
wblock(s, p, 1)
|
||||
}
|
||||
|
||||
fn line(s: &mut Session) -> String {
|
||||
s.execute("line", &json!({ "points": [[0, 0], [2, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wblock_keeps_the_source_drawing_units() {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None });
|
||||
let mut s = Session::new();
|
||||
// A metric drawing in metres, like the issue's metres.dxf (`--metric` then `insunits 6`).
|
||||
s.new_drawing(true);
|
||||
s.execute("units", &json!({ "insunits": 6 })).unwrap();
|
||||
let b = line(&mut s);
|
||||
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [b], "keep": "delete" })).unwrap();
|
||||
let c = line(&mut s);
|
||||
|
||||
// Selected objects and a block by name both keep the source's units, not inches.
|
||||
assert_eq!(written(&mut s, json!({ "handles": [c] })), "6;1");
|
||||
assert_eq!(written(&mut s, json!({ "name": "B" })), "6;1");
|
||||
|
||||
// The metric default (millimetres) and an imperial drawing keep their units too.
|
||||
s.new_drawing(true);
|
||||
let m = line(&mut s);
|
||||
assert_eq!(written(&mut s, json!({ "handles": [m] })), "4;1");
|
||||
s.new_drawing(false);
|
||||
let i = line(&mut s);
|
||||
assert_eq!(written(&mut s, json!({ "handles": [i] })), "1;0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wblock_keeps_the_source_drawing_settings_and_styles() {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None });
|
||||
let mut s = Session::new();
|
||||
// A metric drawing: ISO-25, 2.5 mm text, A3 limits, metric multileader/table styles and scales.
|
||||
s.new_drawing(true);
|
||||
let source = settings(s.doc().unwrap());
|
||||
let l = line(&mut s);
|
||||
assert_eq!(written_settings(&mut s, json!({ "handles": [l] })), source);
|
||||
s.new_drawing(false);
|
||||
assert_ne!(settings(s.doc().unwrap()), source, "the imperial template differs, so the check means something");
|
||||
}
|
||||
@@ -9,6 +9,12 @@ pub struct Bounds2 {
|
||||
pub max: Vec2,
|
||||
}
|
||||
|
||||
/// `max - min`, clamped to `f64::MAX` when finite endpoints make it overflow.
|
||||
fn span(min: f64, max: f64) -> f64 {
|
||||
let d = max - min;
|
||||
if d.is_infinite() && min.is_finite() && max.is_finite() { f64::MAX } else { d }
|
||||
}
|
||||
|
||||
impl Default for Bounds2 {
|
||||
fn default() -> Self {
|
||||
Bounds2::EMPTY
|
||||
@@ -47,10 +53,10 @@ impl Bounds2 {
|
||||
Bounds2 { min: self.min.min(o.min), max: self.max.max(o.max) }
|
||||
}
|
||||
pub fn width(&self) -> f64 {
|
||||
if self.is_empty() { 0.0 } else { self.max.x - self.min.x }
|
||||
if self.is_empty() { 0.0 } else { span(self.min.x, self.max.x) }
|
||||
}
|
||||
pub fn height(&self) -> f64 {
|
||||
if self.is_empty() { 0.0 } else { self.max.y - self.min.y }
|
||||
if self.is_empty() { 0.0 } else { span(self.min.y, self.max.y) }
|
||||
}
|
||||
pub fn center(&self) -> Vec2 {
|
||||
if self.is_empty() {
|
||||
@@ -98,4 +104,14 @@ mod tests {
|
||||
assert_eq!(b.center().y, 5.0);
|
||||
assert_eq!(Bounds2::EMPTY.center(), Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_and_height_stay_finite_for_opposite_large_coordinates() {
|
||||
let b = Bounds2::new(Vec2::new(-1e308, -1e308), Vec2::new(1e308, 1e308));
|
||||
assert_eq!(b.width(), f64::MAX);
|
||||
assert_eq!(b.height(), f64::MAX);
|
||||
let b = Bounds2::new(Vec2::new(1e308, 0.0), Vec2::new(1.6e308, 10.0));
|
||||
assert!((b.width() / 1e308 - 0.6).abs() < 1e-12);
|
||||
assert_eq!(b.height(), 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,17 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
|
||||
})
|
||||
}
|
||||
"LEADER" => EntityKind::Leader(Leader {
|
||||
vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(),
|
||||
// Each group 10 starts a vertex. Scope Y/Z to it so an omitted Z cannot
|
||||
// shift a later vertex's elevation onto this one.
|
||||
vertices: t
|
||||
.all_f(10)
|
||||
.into_iter()
|
||||
.zip(tags.split(|tag| tag.code == 10).skip(1))
|
||||
.map(|(x, vertex)| {
|
||||
let v = T(vertex);
|
||||
Vec3::new(x, v.fd(20, 0.0), v.fd(30, 0.0))
|
||||
})
|
||||
.collect(),
|
||||
arrow: t.i(71).unwrap_or(1) != 0,
|
||||
spline: t.i(72).unwrap_or(0) != 0,
|
||||
style: t.s(3).unwrap_or_else(|| "Standard".into()),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! LEADER vertex elevations survive the shared DXF read path and DWG bridge (#2245).
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, DimStyle, Drawing, EntityKind, Handle, Leader, Lineweight, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn drawing() -> TestResult<Drawing> {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.dim_styles.push(DimStyle { name: "Elevation".into(), ..DimStyle::default() });
|
||||
let common = Common { color: Color::Index(3), lineweight: Lineweight::Mm100(25), ltscale: 2.5, visible: false, ..Common::default() };
|
||||
for vertices in [
|
||||
vec![Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)],
|
||||
vec![Vec3::new(1.0, 2.0, 0.0), Vec3::new(5.0, 6.0, 0.0), Vec3::new(9.0, 10.0, 0.0)],
|
||||
] {
|
||||
for arrow in [false, true] {
|
||||
for spline in [false, true] {
|
||||
let leader = Leader { vertices: vertices.clone(), arrow, spline, style: "Elevation".into() };
|
||||
d.add(&Space::Model, common.clone(), EntityKind::Leader(leader))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn check_round_trips(name: &str, binary: bool) -> TestResult {
|
||||
let original = drawing()?;
|
||||
for version in Version::ALL {
|
||||
let mut d = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let bytes = write_with(&d, name, &SaveOptions { version: Some(version), binary })?;
|
||||
d = read(&bytes, name)?;
|
||||
assert_eq!(d.model.len(), original.model.len(), "{name} {version:?}, pass={pass}");
|
||||
for expected in original.model.iter() {
|
||||
let actual = d.entity(expected.handle).ok_or("LEADER handle lost")?;
|
||||
let (EntityKind::Leader(want), EntityKind::Leader(got)) = (&expected.kind, &actual.kind) else {
|
||||
return Err("LEADER entity type changed".into());
|
||||
};
|
||||
assert_eq!(got.vertices, want.vertices, "XYZ/order: {name} {version:?}, pass={pass}, {:?}", expected.handle);
|
||||
assert_eq!((got.arrow, got.spline, &got.style), (want.arrow, want.spline, &want.style), "LEADER properties");
|
||||
assert_eq!(actual.common, expected.common, "Common properties: {name} {version:?}, pass={pass}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_preserves_leader_vertex_elevations() -> TestResult {
|
||||
for binary in [false, true] {
|
||||
check_round_trips("leader.dxf", binary)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn dwg_preserves_leader_vertex_elevations() -> TestResult {
|
||||
check_round_trips("leader.dwg", false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_z_defaults_only_the_corresponding_vertex() -> TestResult {
|
||||
let vertices = [Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)];
|
||||
// All eight presence patterns include wholly 2D input and omitted first/middle/last Zs.
|
||||
// Author the groups directly, independently of CADCraft's entity writer.
|
||||
for z_mask in 0..8 {
|
||||
let mut tags = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "LEADER"),
|
||||
Tag::s(5, "10"),
|
||||
Tag::s(100, "AcDbEntity"),
|
||||
Tag::s(8, "0"),
|
||||
Tag::s(100, "AcDbLeader"),
|
||||
Tag::s(3, "Standard"),
|
||||
Tag::i(71, 0),
|
||||
Tag::i(72, 1),
|
||||
Tag::i(76, 3),
|
||||
];
|
||||
let mut expected = Vec::new();
|
||||
for (i, p) in vertices.iter().enumerate() {
|
||||
tags.push(Tag::f(10, p.x));
|
||||
let has_z = z_mask & (1 << i) != 0;
|
||||
if has_z {
|
||||
// Y and Z can appear in either order within this vertex.
|
||||
tags.push(Tag::f(30, p.z));
|
||||
}
|
||||
tags.push(Tag::f(20, p.y));
|
||||
expected.push(Vec3::new(p.x, p.y, if has_z { p.z } else { 0.0 }));
|
||||
}
|
||||
tags.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
let dxf = cadcraft_dxf::write_ascii(&tags);
|
||||
let d = read(dxf.as_bytes(), "missing-z.dxf")?;
|
||||
let e = d.entity(Handle(0x10)).ok_or("LEADER missing")?;
|
||||
let EntityKind::Leader(l) = &e.kind else { return Err("expected LEADER".into()) };
|
||||
assert_eq!(l.vertices, expected, "Z presence mask {z_mask:03b}");
|
||||
assert!(!l.arrow && l.spline);
|
||||
assert_eq!(l.style, "Standard");
|
||||
assert_eq!(e.common, Common::default());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -33,6 +33,7 @@ mod hatch_pattern_def;
|
||||
mod helix_roundtrip;
|
||||
mod layout_paper_units;
|
||||
mod layout_plot_window;
|
||||
mod leader_vertex_elevation;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod low_handseed;
|
||||
mod mleader_content;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
|
||||
//! entry is verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
|
||||
@@ -26,7 +26,7 @@ mod widgets;
|
||||
use cursor::{draw_crosshair, draw_snap_marker, draw_tracking, effective_point, override_cursor};
|
||||
use cycle::{CycleList, cycle_badge, cycle_click, cycle_list, over_canvas_menu, shift_space_cycle};
|
||||
pub(crate) use draw::{Look, draw_highlight, draw_list};
|
||||
use draw::{draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill};
|
||||
use draw::{dashed_rect, draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill};
|
||||
use dyn_boxes::dynamic_input;
|
||||
pub(crate) use dyn_boxes::length_units;
|
||||
use grid::draw_grid;
|
||||
@@ -982,8 +982,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
|
||||
xf_paper.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)),
|
||||
xf_paper.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)),
|
||||
);
|
||||
let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()];
|
||||
painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0));
|
||||
painter.extend(dashed_rect(painter.clip_rect(), pa, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0));
|
||||
(Rgb(t.paper.r(), t.paper.g(), t.paper.b()), Some(paper))
|
||||
}
|
||||
None => {
|
||||
@@ -1112,8 +1111,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
|
||||
}
|
||||
let st = Stroke::new(1.0, if crossing { t.crossing_stroke } else { t.window_stroke });
|
||||
if crossing {
|
||||
let pts = vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
|
||||
painter.extend(Shape::dashed_line(&pts, st, 5.0, 3.0));
|
||||
painter.extend(dashed_rect(painter.clip_rect(), r, st, 5.0, 3.0));
|
||||
} else {
|
||||
painter.rect_stroke(r, 0.0, st, egui::StrokeKind::Middle);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,64 @@ fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]>
|
||||
Some([xf.to_screen(a), xf.to_screen(b)])
|
||||
}
|
||||
|
||||
/// Points past the view's edge that a dashed outline is still drawn to: more than one dash
|
||||
/// period, so a dash cut at the clip edge never shows.
|
||||
const CLIP_MARGIN: f32 = 16.0;
|
||||
|
||||
/// The parts of a world-space polyline within `xf`'s view, as screen points, each with how far
|
||||
/// along the whole polyline it starts (in points). Clipped in world coordinates, so an outline far
|
||||
/// larger than the screen costs only what can show, and its points stay precise when zoomed far
|
||||
/// in: dashing a whole line made billions of dashes (#2299).
|
||||
fn visible_runs(xf: &Xf, pts: &[Vec2]) -> Vec<(Vec<Pos2>, f64)> {
|
||||
let vis = xf.world_bounds().expand(f64::from(CLIP_MARGIN) / xf.scale);
|
||||
let mut runs: Vec<(Vec<Pos2>, f64)> = Vec::new();
|
||||
// World length before the current segment; whether the last run reaches its start.
|
||||
let (mut along, mut open) = (0.0, false);
|
||||
for w in pts.windows(2) {
|
||||
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
|
||||
match cadcraft_render::clip::clip_segment(a, b, &vis) {
|
||||
Some((p, q)) => {
|
||||
match runs.last_mut() {
|
||||
Some((run, _)) if open && p == a => run.push(xf.to_screen(q)),
|
||||
_ => runs.push((vec![xf.to_screen(p), xf.to_screen(q)], (along + a.dist(p)) * xf.scale)),
|
||||
}
|
||||
open = q == b;
|
||||
}
|
||||
None => open = false,
|
||||
}
|
||||
along += a.dist(b);
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Dashes along a screen polyline that starts `along` points into the outline it is part of, so
|
||||
/// they sit where they would on the whole outline.
|
||||
fn dashes(run: &[Pos2], along: f64, stroke: Stroke, dash: f32, gap: f32, out: &mut Vec<Shape>) {
|
||||
let period = f64::from(dash + gap);
|
||||
let offset = (period - along.rem_euclid(period)) % period;
|
||||
out.extend(Shape::dashed_line_with_offset(run, stroke, &[dash], &[gap], offset as f32));
|
||||
}
|
||||
|
||||
/// A dashed rectangle outline, each edge clipped to `clip` first: a rectangle zoomed far larger
|
||||
/// than the screen (a layout's printable area, a selection window) gets only the dashes that can
|
||||
/// show (#2299). Each edge keeps its direction and its dashes start where they would on the whole
|
||||
/// edge, so they don't crawl as the rectangle or the view moves.
|
||||
pub(super) fn dashed_rect(clip: Rect, r: Rect, stroke: Stroke, dash: f32, gap: f32) -> Vec<Shape> {
|
||||
let v = |p: Pos2| Vec2::new(f64::from(p.x), f64::from(p.y));
|
||||
let c = clip.expand(CLIP_MARGIN);
|
||||
let c = Bounds2::new(v(c.min), v(c.max));
|
||||
let mut out = Vec::new();
|
||||
let edges =
|
||||
[(r.left_top(), r.right_top()), (r.right_top(), r.right_bottom()), (r.right_bottom(), r.left_bottom()), (r.left_bottom(), r.left_top())];
|
||||
for (a, b) in edges {
|
||||
if let Some((p, q)) = cadcraft_render::clip::clip_segment(v(a), v(b), &c) {
|
||||
let pos = |w: Vec2| Pos2::new(w.x as f32, w.y as f32);
|
||||
dashes(&[pos(p), pos(q)], v(a).dist(p), stroke, dash, gap, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw a display list.
|
||||
pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) {
|
||||
let vis = xf.world_bounds();
|
||||
@@ -279,25 +337,31 @@ impl Look {
|
||||
/// outlined, since filling it would hide the drawing under it.
|
||||
pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, look: Look) {
|
||||
let stroke = Stroke::new(look.width, color);
|
||||
let line = |pts: Vec<Pos2>, out: &mut Vec<Shape>| {
|
||||
let screen_line = |pts: Vec<Pos2>, along: f64, out: &mut Vec<Shape>| {
|
||||
if look.dashed {
|
||||
out.extend(Shape::dashed_line(&pts, stroke, 6.0, 4.0));
|
||||
dashes(&pts, along, stroke, 6.0, 4.0, out);
|
||||
} else {
|
||||
out.push(Shape::line(pts, stroke));
|
||||
}
|
||||
};
|
||||
// Only the part of an outline in view (#2299).
|
||||
let line = |pts: &[Vec2], out: &mut Vec<Shape>| {
|
||||
for (run, along) in visible_runs(xf, pts) {
|
||||
screen_line(run, along, out);
|
||||
}
|
||||
};
|
||||
let mut shapes = Vec::new();
|
||||
let mut fill = egui::Mesh::default();
|
||||
let fill_color = color.gamma_multiply(crate::gpu::HIGHLIGHT_FILL_ALPHA);
|
||||
for prim in prims.iter().filter_map(|&i| list.prims.get(i)) {
|
||||
let pts = list.points(prim);
|
||||
match prim.kind {
|
||||
Kind::Polyline => line(pts.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes),
|
||||
Kind::Polyline => line(pts, &mut shapes),
|
||||
Kind::Infinite { ray } => {
|
||||
if let (Some(b), Some(d)) = (pts.first(), pts.get(1))
|
||||
&& let Some(seg) = clip_infinite(xf, *b, *d, ray)
|
||||
{
|
||||
line(seg.to_vec(), &mut shapes);
|
||||
screen_line(seg.to_vec(), 0.0, &mut shapes);
|
||||
}
|
||||
}
|
||||
Kind::Point => {
|
||||
@@ -314,7 +378,7 @@ pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, pri
|
||||
}
|
||||
Kind::Tris | Kind::Mask => {
|
||||
for outline in list.fill_outlines(prim) {
|
||||
line(outline.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes);
|
||||
line(&outline, &mut shapes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,3 +486,130 @@ mod highlight_stability_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod zoomed_highlight_tests {
|
||||
use super::*;
|
||||
use egui::pos2;
|
||||
|
||||
/// A one-segment polyline from `a` to `b`.
|
||||
fn line(a: Vec2, b: Vec2) -> DisplayList {
|
||||
let mut list = DisplayList { verts: vec![a, b], ..Default::default() };
|
||||
list.prims.push(cadcraft_render::DPrim {
|
||||
handle: Handle(256),
|
||||
color: Rgb(255, 255, 255),
|
||||
aci7: true,
|
||||
lw: 0.0,
|
||||
transparency: 0,
|
||||
kind: Kind::Polyline,
|
||||
start: 0,
|
||||
len: 2,
|
||||
});
|
||||
list
|
||||
}
|
||||
|
||||
const SCREEN: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 600.0, y: 400.0 } };
|
||||
|
||||
/// The dash segments `draw_highlight` makes for `list` in `xf`.
|
||||
fn dashes_of(list: &DisplayList, xf: &Xf) -> Vec<[Pos2; 2]> {
|
||||
let ctx = egui::Context::default();
|
||||
let input = egui::RawInput { screen_rect: Some(SCREEN), ..Default::default() };
|
||||
let mut output = ctx.run_ui(input, |ui| draw_highlight(ui.painter(), xf, list, &[0], Color32::LIGHT_BLUE, Look { width: 1.0, dashed: true }));
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
Shape::LineSegment { points, .. } => Some(*points),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Zoomed far in on a line, the highlight dashes only what shows. Dashing the whole line made
|
||||
/// one shape per 10 points of it: billions at the zoom limit, so the app froze and then ran out
|
||||
/// of memory (#2299).
|
||||
#[test]
|
||||
fn a_zoomed_in_highlight_dashes_only_what_shows() {
|
||||
let list = line(Vec2::new(-1000.0, 0.0), Vec2::new(1000.0, 0.0));
|
||||
// The smaller zoom first, so a regression fails here instead of filling memory at 1e9.
|
||||
for scale in [1e3, 1e9] {
|
||||
let n = dashes_of(&list, &Xf { rect: SCREEN, center: Vec2::new(0.3, 0.0), scale }).len();
|
||||
assert!((50..=70).contains(&n), "scale {scale}: {n} dashes for 600 points of line");
|
||||
}
|
||||
}
|
||||
|
||||
/// A line running off screen keeps its dashes where they are on the whole line, so they don't
|
||||
/// crawl along it when the view pans.
|
||||
#[test]
|
||||
fn clipped_dashes_stay_where_the_whole_line_puts_them() {
|
||||
let list = line(Vec2::new(0.0, 0.0), Vec2::new(1000.0, 0.0));
|
||||
for x in [500.3, 512.9, 731.0] {
|
||||
let xf = Xf { rect: SCREEN, center: Vec2::new(x, 0.0), scale: 1.0 };
|
||||
let whole = Shape::dashed_line(
|
||||
&[xf.to_screen(Vec2::ZERO), xf.to_screen(Vec2::new(1000.0, 0.0))],
|
||||
Stroke::new(1.0, Color32::LIGHT_BLUE),
|
||||
6.0,
|
||||
4.0,
|
||||
);
|
||||
let on_screen = |p: &[Pos2; 2]| p.iter().all(|q| SCREEN.contains(*q));
|
||||
let expected: Vec<[Pos2; 2]> = whole
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::LineSegment { points, .. } => Some(*points).filter(on_screen),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let got: Vec<[Pos2; 2]> = dashes_of(&list, &xf).into_iter().filter(on_screen).collect();
|
||||
assert!(!expected.is_empty());
|
||||
assert_eq!(got.len(), expected.len(), "centre {x}");
|
||||
for (g, e) in got.iter().zip(&expected) {
|
||||
assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "centre {x}: {g:?} vs {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle edge running off screen keeps its dashes where they are on the whole edge.
|
||||
#[test]
|
||||
fn clipped_rectangle_edges_keep_their_dashes() {
|
||||
let stroke = Stroke::new(1.0, Color32::GRAY);
|
||||
let segments = |shapes: Vec<Shape>| -> Vec<[Pos2; 2]> {
|
||||
shapes
|
||||
.into_iter()
|
||||
.filter_map(|s| match s {
|
||||
// The top edge's dashes that are wholly on screen.
|
||||
Shape::LineSegment { points, .. } if points.iter().all(|q| SCREEN.contains(*q) && q.y == 100.0) => Some(points),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for left in [-500.3, -517.9, -1200.0] {
|
||||
let r = Rect::from_min_max(pos2(left, 100.0), pos2(400.0, 300.0));
|
||||
let expected = segments(Shape::dashed_line(&[r.left_top(), r.right_top()], stroke, 4.0, 4.0));
|
||||
let got = segments(dashed_rect(SCREEN, r, stroke, 4.0, 4.0));
|
||||
assert!(!expected.is_empty());
|
||||
assert_eq!(got.len(), expected.len(), "left edge at {left}");
|
||||
for (g, e) in got.iter().zip(&expected) {
|
||||
assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "left edge at {left}: {g:?} vs {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dashed rectangle far larger than the screen gets dashes only along the edges that show.
|
||||
#[test]
|
||||
fn a_huge_dashed_rectangle_dashes_only_what_shows() {
|
||||
let stroke = Stroke::new(1.0, Color32::GRAY);
|
||||
// Its left edge runs through the screen; the other three are far off it. The smaller one
|
||||
// first, so a regression fails here instead of filling memory with the larger one.
|
||||
for far in [1e6, 1e9] {
|
||||
let huge = Rect::from_min_max(pos2(300.0, -far), pos2(far, far));
|
||||
let n = dashed_rect(SCREEN, huge, stroke, 4.0, 4.0).len();
|
||||
assert!((50..=60).contains(&n), "{far}: {n} dashes along 432 points of edge");
|
||||
}
|
||||
// A rectangle inside the screen keeps all four edges.
|
||||
let small = Rect::from_min_max(pos2(100.0, 100.0), pos2(180.0, 140.0));
|
||||
assert_eq!(dashed_rect(SCREEN, small, stroke, 4.0, 4.0).len(), 10 + 5 + 10 + 5);
|
||||
// Off screen, nothing.
|
||||
assert!(dashed_rect(SCREEN, small.translate(egui::vec2(5000.0, 0.0)), stroke, 4.0, 4.0).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! build.rs from contributors/contributors.json; nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
@@ -47,10 +47,12 @@ pub enum Preference {
|
||||
Turkish,
|
||||
#[serde(rename = "bs")]
|
||||
Bosnian,
|
||||
#[serde(rename = "vi")]
|
||||
Vietnamese,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
pub const ALL: [Self; 18] = [
|
||||
pub const ALL: [Self; 19] = [
|
||||
Self::Auto,
|
||||
Self::English,
|
||||
Self::Greek,
|
||||
@@ -69,6 +71,7 @@ impl Preference {
|
||||
Self::Hungarian,
|
||||
Self::Turkish,
|
||||
Self::Bosnian,
|
||||
Self::Vietnamese,
|
||||
];
|
||||
|
||||
/// Every preference in the order the Interface Language dialog lists them: Auto, then English
|
||||
@@ -117,6 +120,7 @@ impl Preference {
|
||||
Self::Hungarian => "hu",
|
||||
Self::Turkish => "tr",
|
||||
Self::Bosnian => "bs",
|
||||
Self::Vietnamese => "vi",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +150,14 @@ impl Preference {
|
||||
Self::Hungarian => "Magyar",
|
||||
Self::Turkish => "Türkçe",
|
||||
Self::Bosnian => "Bosanski",
|
||||
// Its letters need a font egui doesn't bundle ([`crate::theme::vietnamese_font`]).
|
||||
Self::Vietnamese => {
|
||||
if crate::theme::shows_vietnamese(CURRENT.with(Cell::get)) {
|
||||
"Tiếng Việt"
|
||||
} else {
|
||||
"Vietnamese"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +189,7 @@ impl Preference {
|
||||
Self::Hungarian => "hu",
|
||||
Self::Turkish => "tr",
|
||||
Self::Bosnian => "bs",
|
||||
Self::Vietnamese => "vi",
|
||||
Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"),
|
||||
}
|
||||
}
|
||||
@@ -193,7 +206,7 @@ pub struct Language {
|
||||
catalog: OnceLock<Catalog<'static>>,
|
||||
}
|
||||
|
||||
pub static LANGUAGES: [Language; 17] = [
|
||||
pub static LANGUAGES: [Language; 18] = [
|
||||
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
// Greek: one for exactly 1; other for zero and all other integer counts.
|
||||
Language {
|
||||
@@ -243,6 +256,10 @@ pub static LANGUAGES: [Language; 17] = [
|
||||
// Bosnian (Latin script, Ijekavian): one (1, 21), few (2–4, 22–24, not 12–14), other — the
|
||||
// Ukrainian rule for integer counts.
|
||||
Language { code: "bs", name: "Bosanski", source: include_str!("i18n/bs.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() },
|
||||
// Vietnamese doesn't inflect a noun for number ("3 commit"), so like Korean it has one form.
|
||||
Language {
|
||||
code: "vi", name: "Tiếng Việt", source: include_str!("i18n/vi.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new()
|
||||
},
|
||||
];
|
||||
|
||||
/// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`.
|
||||
@@ -447,6 +464,8 @@ mod picker_order_tests {
|
||||
assert!(pos(Preference::Italian) < pos(Preference::Hungarian), "Magyar sorts under M");
|
||||
assert!(pos(Preference::Turkish) < pos(Preference::Greek), "Latin names come before Greek");
|
||||
assert!(pos(Preference::Bosnian) < pos(Preference::Czech), "Bosanski sorts under B");
|
||||
assert!(pos(Preference::Swedish) < pos(Preference::Vietnamese), "Tiếng Việt sorts under T");
|
||||
assert!(pos(Preference::Vietnamese) < pos(Preference::Turkish), "Tiếng comes before Türkçe");
|
||||
assert_eq!(name_sort_key("Türkçe"), "turkce");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec<String> {
|
||||
let catalog = catalog(language).unwrap();
|
||||
let mut sources = BTreeSet::new();
|
||||
sources.extend(crate::menus::MENUS.iter().copied());
|
||||
// File ▸ Open Recent is built at run time, not from a command's menu path.
|
||||
sources.extend([crate::menus::OPEN_RECENT, crate::menus::CLEAR_RECENT]);
|
||||
for c in cadcraft_engine::command_specs() {
|
||||
sources.insert(c.label);
|
||||
sources.extend(c.menu.iter().copied());
|
||||
@@ -187,6 +189,7 @@ fn assert_additional_locale_aliases() {
|
||||
("hu", &["hu", "hu-HU", "hu_HU.UTF-8", "HU-hu", " hu-HU "]),
|
||||
("tr", &["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", " tr-TR ", "tr-CY"]),
|
||||
("bs", &["bs", "bs-BA", "bs_BA.UTF-8", "BS-ba", " bs-BA ", "bs-Latn-BA"]),
|
||||
("vi", &["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn", " vi-VN "]),
|
||||
("el", &["el", "el-GR", "el_GR.UTF-8", "EL-cy"]),
|
||||
("zh", &["zh", "zh-CN"]),
|
||||
] {
|
||||
@@ -432,12 +435,13 @@ fn dialogs_render_in_every_language_and_keep_their_source_ids() {
|
||||
}
|
||||
|
||||
/// The letters of every catalog have glyphs in both UI families. Latin, Cyrillic and Greek come
|
||||
/// from egui's bundled fonts alone, as in the web build; the scripts they lack need a font that
|
||||
/// `install_fonts` finds, and are checked only where one is there.
|
||||
/// from egui's bundled fonts alone, as in the web build; the scripts they lack (and Vietnamese's
|
||||
/// tone-marked letters) need a font that `install_fonts` finds, and are checked only where one is
|
||||
/// there.
|
||||
#[test]
|
||||
fn interface_fonts_cover_every_letter_in_each_catalog() {
|
||||
for language in translated() {
|
||||
let extra_font = ["ko", "zh", "ar"].contains(&language.code);
|
||||
let extra_font = ["ko", "zh", "ar", "vi"].contains(&language.code);
|
||||
if extra_font && !crate::theme::can_show(language.code) {
|
||||
continue;
|
||||
}
|
||||
@@ -558,6 +562,13 @@ fn production_font_chain_covers_latin_and_shortcut_symbols() {
|
||||
});
|
||||
}
|
||||
|
||||
/// #1129: in Chinese every menu, submenu, command, tool palette and object-snap label is
|
||||
/// translated, so no menu shows English entries.
|
||||
#[test]
|
||||
fn chinese_menus_and_commands_are_fully_translated() {
|
||||
assert_eq!(missing_commands_menus_tools_and_snap_labels("zh"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// Bosnian counts: 1, 21 `one`; 2–4, 22–24 (not 12–14) `few`; everything else `other`.
|
||||
#[test]
|
||||
fn bosnian_counts_take_one_few_other() {
|
||||
@@ -566,3 +577,22 @@ fn bosnian_counts_take_one_few_other() {
|
||||
assert_eq!(Preference::parse("bs"), Some(Preference::Bosnian));
|
||||
assert_eq!(Preference::Bosnian.native_name(), "Bosanski");
|
||||
}
|
||||
|
||||
/// Vietnamese has one form for every count, and shows only with an interface font that has its
|
||||
/// tone-marked letters; without one it stays English and says why.
|
||||
#[test]
|
||||
fn vietnamese_takes_one_form_and_needs_a_font_with_its_letters() {
|
||||
assert_eq!(Preference::parse("vi"), Some(Preference::Vietnamese));
|
||||
assert_eq!(Preference::Vietnamese.native_name(), "Tiếng Việt");
|
||||
let vi = LANGUAGES.iter().find(|l| l.code == "vi").unwrap();
|
||||
assert_eq!(vi.plural_forms, 1);
|
||||
assert!([0, 1, 2, 5, 21, 111].into_iter().all(|n| (vi.plural)(n) == 0));
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let r = app.run("ui.language", json!({"lang": "vi"})).unwrap();
|
||||
let shown = crate::theme::can_show("vi");
|
||||
assert_eq!(r["shown"], if shown { "vi" } else { "en" });
|
||||
if !shown {
|
||||
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("Vietnamese letters")));
|
||||
assert_eq!(Preference::Vietnamese.name(), "Vietnamese");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +130,8 @@
|
||||
Boundary Sınır
|
||||
Boundary... Sınır...
|
||||
Box Kutu
|
||||
Break Kes
|
||||
Break At Point Noktadan Kes
|
||||
Break Kır
|
||||
Break At Point Noktadan Kır
|
||||
Bring Above Objects Nesnelerin Üstüne Getir
|
||||
Bring Annotations to Front Açıklamaları Öne Getir
|
||||
Bring to Front En Öne Getir
|
||||
@@ -281,7 +281,7 @@
|
||||
Diameter Çap
|
||||
Dim style Ölçü stili
|
||||
Dimension Ölçü
|
||||
Dimension Break Ölçü Kesme
|
||||
Dimension Break Ölçü Kırma
|
||||
Dimension Edit Ölçü Düzenleme
|
||||
Dimension Objects Only Yalnızca Ölçülendirme Nesneleri
|
||||
Dimension Space Ölçü Aralığı
|
||||
@@ -295,7 +295,7 @@
|
||||
Disassociate Dimensions Ölçülerin İlişkisini Kaldır
|
||||
Display Görüntü
|
||||
Display name Görünen ad
|
||||
Display plot styles Çizim stillerini göster
|
||||
Display plot styles Çizdirme stillerini göster
|
||||
Distance Uzaklık
|
||||
Divide Böl
|
||||
Do not plot (click: plot) Çizdirme (tıklayın: çizdir)
|
||||
@@ -333,7 +333,7 @@
|
||||
Edit Reference In-Place Referansı Yerinde Düzenle
|
||||
Edit Table Cell Tablo Hücresini Düzenle
|
||||
Edit Text... Metni Düzenle...
|
||||
Elevation Yükseklik
|
||||
Elevation Kot
|
||||
Ellipse Elips
|
||||
Elliptical Arc Eliptik Yay
|
||||
Enable 3D mouse navigation 3B fare ile gezinmeyi etkinleştir
|
||||
@@ -360,7 +360,7 @@
|
||||
Enter constraint type Kısıtlama türünü girin
|
||||
Enter continuity Süreklilik girin
|
||||
Enter default attribute value Varsayılan öznitelik değerini girin
|
||||
Enter degree of spline Eğrinin derecesini girin
|
||||
Enter degree of spline Spline derecesini girin
|
||||
Enter delta angle Açı farkını girin
|
||||
Enter delta length or Uzunluk farkını girin veya
|
||||
Enter dimension text Ölçü metnini girin
|
||||
@@ -427,7 +427,7 @@
|
||||
Freeze/Thaw Dondur/Çöz
|
||||
Freeze/Thaw in all viewports Tüm görünüm pencerelerinde dondur/çöz
|
||||
Freeze/Thaw in the current viewport Geçerli görünüm penceresinde dondur/çöz
|
||||
From Kimden
|
||||
From Kaynak
|
||||
From Template... Şablondan...
|
||||
General Genel
|
||||
Geometric Center Geometrik Merkez
|
||||
@@ -487,7 +487,7 @@
|
||||
Hide Palettes Paletleri Gizle
|
||||
History Geçmiş
|
||||
Hold Shift to add to the selection Seçime eklemek için Shift'i basılı tutun
|
||||
Home Giriş
|
||||
Home Başlangıç
|
||||
Horizontal Yatay
|
||||
HorizontalDistance Yatay Uzaklık
|
||||
How to apply: Uygulama biçimi:
|
||||
@@ -677,7 +677,7 @@
|
||||
No contributor data was built into this copy. Bu kopyaya katkıda bulunan verisi eklenmemiş.
|
||||
No external references are attached. İliştirilmiş dış referans yok.
|
||||
No model credits were built into this copy. Bu kopyaya model emeği bilgisi eklenmemiş.
|
||||
No plot style tables. Çizim stili tablosu yok.
|
||||
No plot style tables. Çizdirme stili tablosu yok.
|
||||
No saved layer states Kayıtlı katman durumu yok
|
||||
No selection Seçim yok
|
||||
No supported 3D mouse found Desteklenen 3B fare bulunamadı
|
||||
@@ -763,16 +763,16 @@
|
||||
Pickbox size Seçim kutusu boyutu
|
||||
Plot Çizdir
|
||||
Plot (click: do not plot) Çizdir (tıklayın: çizdirme)
|
||||
Plot Preview Çizim Önizlemesi
|
||||
Plot Style Manager Çizim Stili Yöneticisi
|
||||
Plot Style Manager... Çizim Stili Yöneticisi...
|
||||
Plot Style... Çizim Stili...
|
||||
Plot Styles... Çizim Stilleri...
|
||||
Plot Window Çizim Penceresi
|
||||
Plot style Çizim stili
|
||||
Plot style attached to Çizim stilinin bağlı olduğu yer
|
||||
Plot style table Çizim stili tablosu
|
||||
Plot table type Çizim tablosu türü
|
||||
Plot Preview Çizdirme Önizlemesi
|
||||
Plot Style Manager Çizdirme Stili Yöneticisi
|
||||
Plot Style Manager... Çizdirme Stili Yöneticisi...
|
||||
Plot Style... Çizdirme Stili...
|
||||
Plot Styles... Çizdirme Stilleri...
|
||||
Plot Window Çizdirme Penceresi
|
||||
Plot style Çizdirme stili
|
||||
Plot style attached to Çizdirme stilinin bağlı olduğu yer
|
||||
Plot style table Çizdirme stili tablosu
|
||||
Plot table type Çizdirme tablosu türü
|
||||
Plotter Manager Çizici Yöneticisi
|
||||
Plotter Manager... Çizici Yöneticisi...
|
||||
Plus/minus Artı/eksi
|
||||
@@ -913,7 +913,7 @@
|
||||
Select a block reference. Bir blok referansı seçin.
|
||||
Select a circle or arc. Bir daire veya yay seçin.
|
||||
Select a closed polyline Kapalı bir çoklu çizgi seçin
|
||||
Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı eğri seçin.
|
||||
Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı spline seçin.
|
||||
Select a closed polyline. Kapalı bir çoklu çizgi seçin.
|
||||
Select a curve as the path. Yol olarak bir eğri seçin.
|
||||
Select a dimension constraint type Bir ölçü kısıtlaması türü seçin
|
||||
@@ -922,7 +922,7 @@
|
||||
Select a line or an arc. Bir çizgi veya yay seçin.
|
||||
Select a line or polyline segment Bir çizgi veya çoklu çizgi parçası seçin
|
||||
Select a line, arc or open polyline. Bir çizgi, yay veya açık çoklu çizgi seçin.
|
||||
Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya eğri seçin.
|
||||
Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya spline seçin.
|
||||
Select a line, circle, arc or polyline segment. Bir çizgi, daire, yay veya çoklu çizgi parçası seçin.
|
||||
Select a line. Bir çizgi seçin.
|
||||
Select a polyline. Bir çoklu çizgi seçin.
|
||||
@@ -935,7 +935,7 @@
|
||||
Select an object to measure or Ölçülecek nesneyi seçin veya
|
||||
Select an object. Bir nesne seçin.
|
||||
Select an open curve. Açık bir eğri seçin.
|
||||
Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya eğri seçin.
|
||||
Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya spline seçin.
|
||||
Select arc or circle Yay veya daire seçin
|
||||
Select arc or polyline arc segment Yay veya çoklu çizgi yay parçası seçin
|
||||
Select arc style Yay stilini seçin
|
||||
@@ -952,10 +952,10 @@
|
||||
Select object Nesne seçin
|
||||
Select object to dimension Ölçülendirilecek nesneyi seçin
|
||||
Select object to divide Bölünecek nesneyi seçin
|
||||
Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da kırpmak için Shift ile seçin
|
||||
Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da budamak için Shift ile seçin
|
||||
Select object to measure Ölçülecek nesneyi seçin
|
||||
Select object to offset Ötelenecek nesneyi seçin
|
||||
Select object to trim or shift-select to extend Kırpılacak nesneyi seçin ya da uzatmak için Shift ile seçin
|
||||
Select object to trim or shift-select to extend Budanacak nesneyi seçin ya da uzatmak için Shift ile seçin
|
||||
Select objects Nesneleri seçin
|
||||
Select objects (crossing window first corner) Nesneleri seçin (kesişen pencerenin ilk köşesi)
|
||||
Select objects first, then choose a command Önce nesneleri seçin, sonra bir komut belirleyin
|
||||
@@ -971,7 +971,7 @@
|
||||
Select second object İkinci nesneyi seçin
|
||||
Select second object or shift-select to apply corner İkinci nesneyi seçin ya da köşe uygulamak için Shift ile seçin
|
||||
Select second point İkinci noktayı seçin
|
||||
Select spline Eğri seçin
|
||||
Select spline Spline seçin
|
||||
Select symmetry line Simetri çizgisini seçin
|
||||
Select two points İki nokta seçin
|
||||
Selection Seçim
|
||||
@@ -1057,7 +1057,7 @@
|
||||
Specify center point of array Dizinin merkez noktasını belirtin
|
||||
Specify center point of base Tabanın merkez noktasını belirtin
|
||||
Specify corner of viewport Görünüm penceresinin köşesini belirtin
|
||||
Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX or nXP)
|
||||
Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX veya nXP)
|
||||
Specify diameter of circle Dairenin çapını belirtin
|
||||
Specify dimension arc line location Ölçü yayı çizgisinin konumunu belirtin
|
||||
Specify dimension line location Ölçü çizgisinin konumunu belirtin
|
||||
@@ -1125,7 +1125,7 @@
|
||||
Specify rotation angle or Döndürme açısını belirtin veya
|
||||
Specify scale factor Ölçek faktörünü belirtin
|
||||
Specify second angle endpoint Açının ikinci uç noktasını belirtin
|
||||
Specify second break point İkinci kesme noktasını belirtin
|
||||
Specify second break point İkinci kırma noktasını belirtin
|
||||
Specify second chamfer distance for rectangles Dikdörtgenler için ikinci pah uzaklığını belirtin
|
||||
Specify second constraint point İkinci kısıtlama noktasını belirtin
|
||||
Specify second destination point İkinci hedef noktayı belirtin
|
||||
@@ -1148,7 +1148,7 @@
|
||||
Specify starting width Başlangıç genişliğini belirtin
|
||||
Specify starting width for next segment Sonraki parçanın başlangıç genişliğini belirtin
|
||||
Specify tangent direction for the start point of arc Yayın başlangıç noktası için teğet yönünü belirtin
|
||||
Specify the elevation for rectangles Dikdörtgenler için yüksekliği belirtin
|
||||
Specify the elevation for rectangles Dikdörtgenler için kotu belirtin
|
||||
Specify thickness for rectangles Dikdörtgenler için kalınlığı belirtin
|
||||
Specify third point on circle Daire üzerindeki üçüncü noktayı belirtin
|
||||
Specify third point on circle: _tan to Daire üzerindeki üçüncü noktayı belirtin: _tan to
|
||||
@@ -1161,8 +1161,8 @@
|
||||
Specify width for rectangles Dikdörtgenler için genişliği belirtin
|
||||
Sphere Küre
|
||||
Spherical Küresel
|
||||
Spline Eğri
|
||||
Spline Edit Eğri Düzenle
|
||||
Spline Spline
|
||||
Spline Edit Spline Düzenle
|
||||
Stack (type 1/2 then select) Yığ (1/2 yazıp seçin)
|
||||
Standard Standart
|
||||
Start Başlangıç
|
||||
@@ -1218,7 +1218,7 @@
|
||||
This drawing Bu çizim
|
||||
Time Zaman
|
||||
Time-sensitive right-click: a quick click is Enter, holding opens the menu Süreye bağlı sağ tıklama: kısa tıklama Enter'dır, basılı tutmak menüyü açar
|
||||
To Kime
|
||||
To Hedef
|
||||
To get started, create or open a drawing. Başlamak için bir çizim oluşturun veya açın.
|
||||
Toggle PICKADD (off: each pick replaces the selection) PICKADD'i değiştir (kapalı: her seçim öncekinin yerini alır)
|
||||
Toggle PICKADD (on: picks add to the selection) PICKADD'i değiştir (açık: seçimler mevcut seçime eklenir)
|
||||
@@ -1233,7 +1233,7 @@
|
||||
Track orthogonally only Yalnızca dik açılarla izle
|
||||
Track using all polar angle settings Tüm kutupsal açı ayarlarını kullanarak izle
|
||||
Transparency Saydamlık
|
||||
Trim Kırp
|
||||
Trim Buda
|
||||
True Color Gerçek Renk
|
||||
Turn All Layers On Tüm Katmanları Aç
|
||||
Turn Off Layer Katmanı Kapat
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 13.
|
File diff suppressed because it is too large
Load Diff
@@ -1349,3 +1349,34 @@
|
||||
Check for Updates… 检查更新…
|
||||
CADCraft is up to date. CADCraft 已是最新版本。
|
||||
Could not check for updates. 无法检查更新。
|
||||
# Menu and command labels the October 2026 update missed (#1129).
|
||||
Align Multileaders 对齐多重引线
|
||||
Analyze Usage 分析使用情况
|
||||
Break-line Symbol 折断线符号
|
||||
Change 更改
|
||||
Change Properties 修改特性
|
||||
Clear Recent Files 清除最近的文件
|
||||
Close Block Editor 关闭块编辑器
|
||||
Collect 合并
|
||||
Collect Multileaders 合并多重引线
|
||||
Convert Text to Mtext 将文字转换为多行文字
|
||||
Customize 自定义
|
||||
Drag Dynamic Grip 拖动动态夹点
|
||||
Dynamic Block Data 动态块数据
|
||||
Dynamic Block Grips 动态块夹点
|
||||
Dynamic Block Reference 动态块参照
|
||||
Edit Command Aliases (PGP)... 编辑命令别名 (PGP)...
|
||||
Edit Multileader 编辑多重引线
|
||||
Evaluate LISP 求值 LISP
|
||||
Explode Attributes to Text 将属性分解为文字
|
||||
Express Tools 扩展工具
|
||||
Groups 编组
|
||||
Hatch Pick 拾取图案填充
|
||||
Implied Loops 隐含环
|
||||
Justify 对正
|
||||
Move/Copy/Rotate 移动/复制/旋转
|
||||
Save Block 保存块
|
||||
Save Block As 块另存为
|
||||
Save Layout as Template 将布局另存为样板
|
||||
Set Dynamic Property 设置动态特性
|
||||
Units 单位
|
||||
|
||||
|
@@ -10,6 +10,8 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools"
|
||||
|
||||
/// File ▸ Open Recent: one `ui.recent.<n>` item per recent file, then Clear Recent Files.
|
||||
pub const OPEN_RECENT: &str = "Open Recent";
|
||||
/// The last item of File ▸ Open Recent.
|
||||
pub const CLEAR_RECENT: &str = "Clear Recent Files";
|
||||
const RECENT_ITEM: &str = "ui.recent.";
|
||||
const RECENT_CLEAR: &str = "ui.recent.clear";
|
||||
|
||||
@@ -265,10 +267,15 @@ fn run_language_command(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
app.ui.interface_language = preference;
|
||||
let shown = app.language();
|
||||
if shown != preference.resolve(&app.system_languages) {
|
||||
app.set_status(if preference.resolve(&app.system_languages) == "ko" {
|
||||
"No font covers the Korean interface, so it stays in English. Install a Hangul font (Malgun Gothic or Nanum Gothic), or build with a Hang/Kore craft-fonts face."
|
||||
} else {
|
||||
"No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese."
|
||||
app.set_status(match preference.resolve(&app.system_languages) {
|
||||
"ko" => {
|
||||
"No font covers the Korean interface, so it stays in English. Install a Hangul font (Malgun Gothic or Nanum Gothic), or build with a Hang/Kore craft-fonts face."
|
||||
}
|
||||
"vi" => {
|
||||
"No installed interface font has the Vietnamese letters, so the interface stays in English. Install one that does (Segoe UI, Arial, DejaVu Sans or Noto Sans) to use Vietnamese."
|
||||
}
|
||||
"ar" => "No Arabic font is installed, so the interface stays in English. Install one (Noto Sans Arabic, for example) to use Arabic.",
|
||||
_ => "No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.",
|
||||
});
|
||||
}
|
||||
Some(Ok(json!({"interfaceLanguage": preference.code(), "shown": shown})))
|
||||
@@ -629,7 +636,7 @@ pub fn recent_entries(app: &CadApp) -> Vec<Entry> {
|
||||
.map(|(i, path)| Entry::Item { label: file_name(path), id: format!("{RECENT_ITEM}{i}"), shortcut: None, enabled: true })
|
||||
.collect();
|
||||
let enabled = !app.ui.recent_files.is_empty();
|
||||
entries.push(Entry::Item { label: "Clear Recent Files".into(), id: RECENT_CLEAR.into(), shortcut: None, enabled });
|
||||
entries.push(Entry::Item { label: CLEAR_RECENT.into(), id: RECENT_CLEAR.into(), shortcut: None, enabled });
|
||||
entries
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! |---|---|
|
||||
//! | `theme` | `"system"`, `"light"` or `"dark"` |
|
||||
//! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"` or `"el"` |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"`, `"vi"` or `"el"` |
|
||||
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
|
||||
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
|
||||
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
|
||||
|
||||
+96
-14
@@ -326,19 +326,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) {
|
||||
let chinese = language.starts_with("zh");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let candidates: &[&str] = if cfg!(target_os = "macos") {
|
||||
&["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"]
|
||||
} else if cfg!(windows) {
|
||||
&["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"]
|
||||
} else {
|
||||
&[
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||
]
|
||||
};
|
||||
for path in candidates {
|
||||
for path in system_ui_candidates() {
|
||||
// A Vietnamese interface takes the first one with its letters.
|
||||
if language == "vi" && vietnamese_font() != Some(std::path::Path::new(path)) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
fonts.font_data.insert("system-ui".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes)));
|
||||
if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) {
|
||||
@@ -409,6 +401,82 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) {
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// The system interface fonts [`install_fonts`] looks for, in order; it loads the first installed
|
||||
/// one ahead of egui's bundled faces.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_ui_candidates() -> &'static [&'static str] {
|
||||
if cfg!(target_os = "macos") {
|
||||
&["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"]
|
||||
} else if cfg!(windows) {
|
||||
&["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"]
|
||||
} else {
|
||||
&[
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The first of `candidates` that is `installed` and, for the Vietnamese interface, has the
|
||||
/// Vietnamese letters: the system interface font [`install_fonts`] loads ahead of egui's faces.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ui_font_choice<'a>(
|
||||
candidates: &[&'a str],
|
||||
vietnamese: bool,
|
||||
installed: impl Fn(&str) -> bool,
|
||||
has_vietnamese: impl Fn(&str) -> bool,
|
||||
) -> Option<&'a str> {
|
||||
candidates.iter().copied().find(|p| installed(p) && (!vietnamese || has_vietnamese(p)))
|
||||
}
|
||||
|
||||
/// Whether `face` has every letter Vietnamese adds to Latin: the tone-marked vowels of Latin
|
||||
/// Extended Additional (U+1EA0–U+1EF9), and ơ, ư, đ.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn covers_vietnamese(face: &cadcraft_fonts::ttf::Face) -> bool {
|
||||
('\u{1ea0}'..='\u{1ef9}').chain(['ơ', 'Ơ', 'ư', 'Ư', 'đ', 'Đ']).all(|c| face.has_glyph(c))
|
||||
}
|
||||
|
||||
/// The system interface font the Vietnamese interface takes: the first of
|
||||
/// [`system_ui_candidates`] whose first face (the one egui uses) has every Vietnamese letter.
|
||||
/// Segoe UI, Arial, SF, DejaVu Sans and Noto Sans do; egui's bundled faces stop at Latin
|
||||
/// Extended-A, so without one the interface stays English (as the web build does). Checked once.
|
||||
pub fn vietnamese_font() -> Option<&'static std::path::Path> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static FOUND: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
|
||||
FOUND
|
||||
.get_or_init(|| {
|
||||
let has_vietnamese = |p: &str| {
|
||||
read_capped(std::path::Path::new(p), MAX_UI_FONT_BYTES)
|
||||
.and_then(|bytes| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(bytes)).into_iter().next())
|
||||
.is_some_and(|face| covers_vietnamese(&face))
|
||||
};
|
||||
ui_font_choice(system_ui_candidates(), true, |p| std::path::Path::new(p).is_file(), has_vietnamese).map(std::path::PathBuf::from)
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the interface in `language` shows the Vietnamese letters (Tiếng Việt in the language
|
||||
/// picker): the Vietnamese interface with [`vietnamese_font`], any other when the system interface
|
||||
/// font it loads is that one.
|
||||
pub fn shows_vietnamese(language: &str) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let loaded = || ui_font_choice(system_ui_candidates(), false, |p| std::path::Path::new(p).is_file(), |_| true);
|
||||
vietnamese_font().is_some_and(|v| language == "vi" || loaded().is_some_and(|p| std::path::Path::new(p) == v))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = language;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces `build.rs` embedded from craft-fonts that the interface loads, in order: every CJK
|
||||
/// face, because file, layout and layer names can be Japanese or Chinese whatever the interface
|
||||
/// shows (#513), with the interface's own face leading. A sans-serif face reads best, and a
|
||||
@@ -659,10 +727,12 @@ pub fn shows_hangul(language: &str) -> bool {
|
||||
|
||||
/// Whether the interface can show `language` without missing-glyph boxes: Chinese needs a font
|
||||
/// with its glyphs (installed, or the CJK face this build embedded from craft-fonts), Korean a
|
||||
/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face.
|
||||
/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face, Tiếng Việt a
|
||||
/// system interface font with its tone-marked letters ([`vietnamese_font`]).
|
||||
pub fn can_show(language: &str) -> bool {
|
||||
match language {
|
||||
"ko" => shows_hangul("ko"),
|
||||
"vi" => vietnamese_font().is_some(),
|
||||
"ar" => arabic_font().is_some() || embedded_script("Arab"),
|
||||
l if l.starts_with("zh") => cjk_font().is_some() || embedded_script("Hans") || embedded_script("Hant"),
|
||||
_ => true,
|
||||
@@ -737,6 +807,18 @@ pub fn apply(ctx: &egui::Context, theme: Theme, follow_system: bool) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The system interface font loaded: the first installed one, and for Vietnamese the first
|
||||
/// installed one with its letters (a later one when the first lacks them).
|
||||
#[test]
|
||||
fn the_vietnamese_interface_takes_the_first_installed_font_with_its_letters() {
|
||||
let candidates = ["a.ttf", "b.ttf", "c.ttf", "d.ttf"];
|
||||
let installed = |p: &str| p != "a.ttf";
|
||||
let has_vietnamese = |p: &str| p == "c.ttf" || p == "d.ttf";
|
||||
assert_eq!(super::ui_font_choice(&candidates, false, installed, has_vietnamese), Some("b.ttf"));
|
||||
assert_eq!(super::ui_font_choice(&candidates, true, installed, has_vietnamese), Some("c.ttf"));
|
||||
assert_eq!(super::ui_font_choice(&candidates, true, installed, |_| false), None);
|
||||
}
|
||||
|
||||
use cadcraft_fonts::CraftFont;
|
||||
|
||||
/// A face shaped like `build.rs` writes them, with no font file (as in `cadcraft-fonts`).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! `craft_update::updater::Updater`.
|
||||
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
use craft_update::{CheckResultDialog, CheckResultResponse};
|
||||
|
||||
use crate::CadApp;
|
||||
|
||||
@@ -16,11 +17,15 @@ pub struct Runtime {
|
||||
enabled: bool,
|
||||
/// Help ▸ Check for Updates… was chosen; the next frame's `tick` starts it with the context.
|
||||
check_requested: bool,
|
||||
/// The check-result dialog is open (from a requested check until it is closed).
|
||||
result_open: bool,
|
||||
/// The current result was already written to the command history.
|
||||
result_echoed: bool,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false, result_echoed: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +36,11 @@ impl CadApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Help ▸ Check for Updates…: check now, and say so when there is nothing newer.
|
||||
/// Help ▸ Check for Updates…: check now, and show the result when there is nothing newer.
|
||||
pub fn check_now(app: &mut CadApp) {
|
||||
app.updates_rt.check_requested = true;
|
||||
app.updates_rt.result_open = true;
|
||||
app.updates_rt.result_echoed = false;
|
||||
}
|
||||
|
||||
/// Start the checks and collect the answers.
|
||||
@@ -50,8 +57,16 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) {
|
||||
Status::Failed(_) => Some(crate::tl!("Could not check for updates.")),
|
||||
Status::Idle | Status::Checking => None,
|
||||
};
|
||||
if let Some(message) = outcome {
|
||||
let Some(message) = outcome else {
|
||||
// A check that found an update leaves `Idle`; the prompt takes over from the dialog.
|
||||
if rt.result_open && matches!(rt.updater.status(), Status::Idle) {
|
||||
rt.result_open = false;
|
||||
}
|
||||
return;
|
||||
};
|
||||
if !rt.result_open {
|
||||
rt.updater.dismiss_status();
|
||||
} else if !std::mem::replace(&mut rt.result_echoed, true) {
|
||||
app.session.echo(message);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +75,20 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) {
|
||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
let clean = !app.session.docs.iter().any(|d| d.is_dirty());
|
||||
let rt = &mut app.updates_rt;
|
||||
if rt.result_open {
|
||||
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => {
|
||||
rt.result_open = false;
|
||||
rt.updater.dismiss_status();
|
||||
}
|
||||
CheckResultResponse::CheckAgain => {
|
||||
rt.result_echoed = false;
|
||||
rt.check_requested = true;
|
||||
}
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
}
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// The guarded close path asks about unsaved changes; the restart helper relaunches once the
|
||||
|
||||
@@ -37,3 +37,4 @@ mod toolsets_gear;
|
||||
mod toolsets_parametric;
|
||||
mod viewport_double_click_selection;
|
||||
mod workspace_focus;
|
||||
mod zoomed_far_in;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Zooming far in with the pointer on an object (#2299): its rollover highlight is dashed, and
|
||||
//! dashing the whole object made one shape per 10 points of it on screen. Zoomed to the limit
|
||||
//! that was billions of shapes, so the app froze and then ran out of memory.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use serde_json::json;
|
||||
|
||||
/// One frame of the app; the number of shapes it painted.
|
||||
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<egui::Event>) -> usize {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_far_in_on_a_hovered_line_paints_what_shows() {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.run("circle", json!({"center": [50, 50], "radius": 20})).unwrap();
|
||||
app.run("line", json!({"points": [[0, 0], [100, 100]]})).unwrap();
|
||||
app.run("line", json!({"points": [[0, 100], [100, 0]]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, Vec::new());
|
||||
let over = app.canvas.rect.unwrap().center();
|
||||
// The smaller zoom first, so a regression fails there instead of filling memory at the limit.
|
||||
for height in [1e-2, 1e-9] {
|
||||
// Centred on a point of the first line, which the pointer, at the canvas centre, hovers.
|
||||
app.run("view.set", json!({"center": [25, 25], "height": height})).unwrap();
|
||||
let shapes = (0..3).map(|_| frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)])).max().unwrap_or(0);
|
||||
// Without a hovered object there is no dashed highlight, and the count says nothing.
|
||||
assert!(app.canvas.hover.is_some(), "view height {height}: the line is hovered");
|
||||
assert!(shapes < 5_000, "view height {height}: {shapes} shapes painted");
|
||||
assert_eq!(app.session.state().unwrap().view().height, height);
|
||||
}
|
||||
}
|
||||
+4
-21
@@ -1,16 +1,14 @@
|
||||
# Contributors in the About window
|
||||
|
||||
**About ▸ Contributors** credits everyone who contributed to CADCraft, and **About ▸ Models** credits
|
||||
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
|
||||
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
|
||||
this page is the local copy of the decision.
|
||||
the AI models named in `Co-Authored-By` trailers.
|
||||
|
||||
## Decision
|
||||
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
|
||||
username.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
@@ -32,20 +30,5 @@ display_name = "Nickname"
|
||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||
```
|
||||
|
||||
or, with craftrules checked out next to `craft-apps/`:
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
|
||||
```
|
||||
|
||||
Names show after the next refresh: the script checks that each entry was committed by its own
|
||||
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
|
||||
|
||||
## Refreshing (maintainers)
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
|
||||
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
|
||||
```
|
||||
|
||||
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
|
||||
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
|
||||
Delete your entry to remove your names. Git author names and emails are never recorded.
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
| # | Gap | Est. |
|
||||
|---|---|---|
|
||||
| L1 | Catalog covers the UI (989 rows, #36) but not engine diagnostics, CLI/MCP replies or command history | 4–6 |
|
||||
| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi from the standard's list | 35–55 + native review |
|
||||
| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi | 35–55 + native review |
|
||||
| L3 | Complex-script shaping for Devanagari and other Indic scripts in drawing text; RTL (mirrored) UI. Arabic-script shaping and bidi in drawing text landed (#287) | 20–30 |
|
||||
| L4 | IME fixes on every platform (PRs #110–#119); vertical text styles | 6–10 |
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ shaping and right-to-left text through TrueType, vertical SHX text styles.
|
||||
|
||||
## Languages
|
||||
|
||||
Order and columns from craftrules' progress-docs standard. Every UI percentage is **measured**
|
||||
Every UI percentage is **measured**
|
||||
from the catalogs (0 rows for every language without a catalog file). Hours are **estimated**:
|
||||
about 3–5 h per language for an Opus 5.5 agent to translate the ~990 catalog rows and fix layout,
|
||||
plus native review by a human; extending the catalog to the remaining English-only strings
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Releasing CADCraft
|
||||
|
||||
The canonical recipe is craftrules' [`release/playbook.md`](https://github.com/storytold/craftrules/blob/main/release/playbook.md); this file covers only what's CADCraft-specific.
|
||||
This file describes how CADCraft is released.
|
||||
|
||||
- **Version:** `[workspace.package] version` in the root `Cargo.toml` is the single source. `cargo xtask version` prints it; `cargo xtask version set X.Y.Z[-pre]` changes it. The app shows it in About, `cadcraft --version` and `cadcraft-cli --version`; release builds also embed the commit (`CADCRAFT_BUILD_SHA`).
|
||||
- **Cut a release:** bump the version on `main`, then push `main` to `release` (`git push origin main:release`). Every push to `release` runs `.github/workflows/release.yml` and creates or updates the draft GitHub Release `CADCraft v<version>`. A `workflow_dispatch` run with `version: X.Y.Z-rc.N` makes a test build. A dispatch on any other branch is a dry run: the unsigned jobs (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) build, the `release` environment refuses the signing jobs (macOS, Windows), and no release is drafted.
|
||||
|
||||
+1
-2
@@ -26,8 +26,7 @@ Items 2–5 and 7 are the [alpha gate](#alpha-gate) rows.
|
||||
|
||||
## Alpha gate
|
||||
|
||||
The core workflows a typical AutoCAD professional does every day (craftrules progress-docs
|
||||
standard). CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are
|
||||
The core workflows a typical AutoCAD professional does every day. CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are
|
||||
the alpha checklist. Main platform: macOS.
|
||||
|
||||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|
||||
@@ -65,8 +65,7 @@ mainstream and essentials numbers use multiplicative discounts.
|
||||
## Mainstream practitioner
|
||||
|
||||
The typical AutoCAD professional is a 2D drafter or detailer: draw, modify, dimension, use
|
||||
blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded per
|
||||
the standard: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces,
|
||||
blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces,
|
||||
Share), Sheet Set Manager administration, specialist hardware, languages other than English.
|
||||
|
||||
| Area (weekly use) | Weight | Depth | Weighted |
|
||||
@@ -215,7 +214,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of
|
||||
| Date | Feature breadth | Weighted parity | Remaining | Note |
|
||||
|---|---|---|---|---|
|
||||
| 2026-10-10 (merge) | unchanged (233/491 re-scanned) | ready ≈ 38% (37.7%), mainstream ≈ 28%, essentials ≈ 46% | unchanged within rounding | main landed dynamic input pointer boxes (#60), the Ukrainian catalog (#36), WIPEOUT/gradient DXF fixes (#147, #162): UI/UX 40% → 43%, localization 3% → 5% |
|
||||
| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added (craftrules standard) |
|
||||
| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added |
|
||||
| 2026-10-10 | 245/491 (50%), engine-only 233/491 | features ≈ 41%, ready for real work ≈ 37% | 1,300–2,100 h | full re-measure in this format (this file) |
|
||||
| 2026-10-07 | 233/491 (47%) | ≈ 29% overall, 2D ≈ 55% | ≈ 570 h | old ROADMAP: milestone percentages reported by the building agents |
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@ APP_ID=ai.storyteller.cadcraft
|
||||
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
TEST=1
|
||||
OCI=0
|
||||
TARBALL=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-test) TEST=0; shift ;;
|
||||
--oci) OCI=1; shift ;;
|
||||
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
*) TARBALL="$1"; shift ;;
|
||||
@@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak"
|
||||
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
|
||||
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
|
||||
echo "wrote $OUT"
|
||||
if [[ "$OCI" = 1 ]]; then
|
||||
OUT_OCI="$DIST/$BASENAME-oci"
|
||||
rm -rf "$OUT_OCI"
|
||||
flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
|
||||
echo "wrote OCI layout to $OUT_OCI"
|
||||
fi
|
||||
|
||||
if [[ "$TEST" = 1 ]]; then
|
||||
flatpak install --user -y --noninteractive --reinstall "$OUT"
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build cadcraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
|
||||
# for consumption by Flatpak OCI clients (flatpak-oci-specs).
|
||||
#
|
||||
# Usage:
|
||||
# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY Target registry image (default: ghcr.io/storytold/cadcraft)
|
||||
# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH)
|
||||
# GITHUB_TOKEN Token for GHCR authentication when --push is used
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
HERE="$ROOT/packaging/linux"
|
||||
|
||||
PUSH=0
|
||||
TARBALL=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--push) PUSH=1; shift ;;
|
||||
-h | --help) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
*) TARBALL="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ;;
|
||||
aarch64 | arm64) ARCH=aarch64 ;;
|
||||
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
BASENAME="cadcraft-$VERSION-linux-$ARCH"
|
||||
OUT_OCI="$DIST/$BASENAME-oci"
|
||||
|
||||
echo "==> Building Flatpak OCI layout for $BASENAME"
|
||||
"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
|
||||
|
||||
[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
|
||||
|
||||
REGISTRY="${REGISTRY:-ghcr.io/storytold/cadcraft}"
|
||||
TAG="${TAG:-$VERSION-$ARCH}"
|
||||
|
||||
echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
|
||||
|
||||
if [[ "$PUSH" = 1 ]]; then
|
||||
command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
|
||||
|
||||
AUTH_ARGS=()
|
||||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||||
AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
|
||||
fi
|
||||
|
||||
echo "==> Pushing to $REGISTRY:$TAG"
|
||||
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
|
||||
|
||||
# Also update architecture-specific stable tag
|
||||
echo "==> Pushing to $REGISTRY:stable-$ARCH"
|
||||
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
|
||||
|
||||
echo "==> Push complete"
|
||||
fi
|
||||
Reference in New Issue
Block a user