Compare commits

..
4 Commits
Author SHA1 Message Date
Dylan M. Taylor a54d694f4f Release 0.17.1 2026-11-04 19:50:38 -05:00
Dylan M. Taylor d12f592d80 Release 0.17.1 2026-11-04 19:48:04 -05:00
Dylan M. Taylor 567dfd49ed Run the signed release from the release environment 2026-11-04 19:35:54 -05:00
Dylan M. Taylor 8d90dec9ec Release 0.17.1 2026-11-04 18:50:12 -05:00
135 changed files with 640 additions and 8625 deletions
-28
View File
@@ -1,28 +0,0 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+7 -55
View File
@@ -1,62 +1,13 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# CADCraft — instructions for agents
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).
## Start every session here
Read [`ROADMAP.md`](ROADMAP.md) and [`docs/gaps.md`](docs/gaps.md). `plan/` is an optional gitignored notebook (status, execution notes, `plan/autocad/*`). Do not ask for it and do not stop when it is absent. Licence choices stay inside the house-rules allowlist.
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).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask unless it lists the decision as the owner's.
`plan/` is gitignored (local only).
## Never crash
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
@@ -77,8 +28,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
## Running and looking at the app
- `cargo run --release -p cadcraft -- --sample --control PORT` (sample drawing + control channel). Pick a free port; other crafting apps use control ports too.
-1
View File
@@ -78,4 +78,3 @@ Generated-in-code assets are original and have no file to list:
| `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 |
| `crates/ui-egui/src/i18n/ka.tsv` | Davit Matchakhelidze (@devius), native speaker, with Claude Code | Original Georgian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; terminology and doubtful strings decided by a native Georgian speaker; no font assets added (the Georgian face comes from craft-fonts) |
+52 -1
View File
@@ -1 +1,52 @@
@AGENTS.md
# CADCraft — instructions for agents
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).
## 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).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask unless it lists the decision as the owner's.
`plan/` is gitignored (local only).
## Never crash
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.
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`, embedded by `crates/fonts/build.rs`), never a `Cargo.toml` dependency. Code and tests must work without it.
- **Shared test corpora** live in separate repos (`storytold/<app>-corpus`); never commit large binary fixtures here.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
## Running and looking at the app
- `cargo run --release -p cadcraft -- --sample --control PORT` (sample drawing + control channel). Pick a free port; other crafting apps use control ports too.
- Drive it with JSON lines on `127.0.0.1:PORT`:
- `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` — type at the command line exactly like a user (prompts, keywords, `@dx,dy`, `@d<a`, direct distance entry).
- `{"id":2,"method":"engine.execute","params":{"command":"line","params":{"points":[[0,0],[10,0]]}}}` — JSON form.
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- Methods: `crates/ui-egui/src/control.rs`; protocol doc: `docs/control-protocol.md`.
- **For UI work, look at the result** (screenshot, read the PNG) and compare with `plan/autocad/screenshots/`. If no frame is presented (screen locked) use `ui.render` or `cadcraft-cli`.
- Headless: `cadcraft-cli run --sample --script 'LINE 0,0 10,10\n' --export out.png`; `cadcraft-cli info file.dxf`; `cadcraft-cli mcp`.
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
- 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. 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.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+36 -61
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -644,7 +644,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -660,14 +660,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -677,7 +677,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -688,7 +688,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"acadrust",
"log",
@@ -696,7 +696,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"encoding_rs",
"log",
@@ -705,7 +705,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -726,7 +726,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-geom",
"encoding_rs",
@@ -738,14 +738,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -767,7 +767,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -776,12 +776,11 @@ dependencies = [
"getrandom 0.4.3",
"log",
"serde_json",
"tiny-skia",
]
[[package]]
name = "cadcraft-render"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -795,7 +794,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -809,7 +808,6 @@ dependencies = [
"craft-window",
"egui",
"egui-wgpu",
"egui_extras",
"egui_kittest",
"image",
"log",
@@ -822,13 +820,12 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"craft-web-host",
"eframe",
"egui",
"log",
@@ -1232,15 +1229,6 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -1525,19 +1513,6 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1606,26 +1581,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -5977,7 +5932,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.17.1"
dependencies = [
"chrono",
"serde_json",
@@ -6292,3 +6247,23 @@ dependencies = [
"syn 3.0.9",
"winnow",
]
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+4 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.17.0"
version = "0.17.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -63,8 +63,6 @@ quick-xml = "0.42"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
@@ -118,5 +116,8 @@ egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+1 -1
View File
@@ -118,7 +118,7 @@ numbers):
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar), Turkish (Türkçe), Greek (Ελληνικά), Bosnian (Bosanski) and Georgian (ქართული), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
## Quick start
+5 -17
View File
@@ -162,31 +162,19 @@ pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
let mut ole = 0usize;
for e in d.model.iter() {
// An OLE2FRAME's frame outline is drawn, so it isn't an undrawn entity; its content is
// not, which gets a note of its own.
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
if u.is_ole_frame() && cadcraft_engine::doc::ole_frame(&u.tags).is_some() {
ole = ole.saturating_add(1);
continue;
}
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
let mut lines = Vec::new();
if n > 0 {
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
lines.push(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")));
if n == 0 {
return None;
}
if ole > 0 {
let objects = if ole == 1 { "object" } else { "objects" };
lines.push(format!("{path}: {ole} OLE {objects} shown as the frame only (the OLE content is not drawn)"));
}
if lines.is_empty() { None } else { Some(lines.join("\n")) }
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,
-7
View File
@@ -16,13 +16,6 @@ fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() {
let read = cadcraft_io::read(&written, "a.dwg").unwrap();
assert_eq!(read.model.len(), drawing.model.len());
// A drawing whose kept objects came from a 2018 file: the worker gets that version with the
// request and reads the bridge DXF as it, as this process does.
let mut kept = drawing.clone();
kept.foreign = std::sync::Arc::new(cadcraft_engine::doc::foreign::Foreign { version: Some("AC1032".into()), ..Default::default() });
let written = cadcraft_io::write(&kept, "a.dwg").unwrap();
assert_eq!(cadcraft_io::read(&written, "a.dwg").unwrap().model.len(), drawing.model.len());
// A decode error comes back as that error, not as the worker stopping.
let mut bad = in_process.clone();
bad.truncate(64);
+3 -54
View File
@@ -7,25 +7,6 @@ fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
}
/// A fresh scratch directory that is removed on drop, also when an assertion fails.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("cc-cli-{tag}-{}", std::process::id()));
// A directory left behind by an earlier run with the same process id must not leak in.
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).expect("create scratch dir");
Scratch(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A line, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
@@ -36,8 +17,8 @@ const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
#[test]
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
let scratch = Scratch::new("undrawn");
let dir = scratch.0.clone();
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("proxies.dxf");
std::fs::write(&input, PROXIES).unwrap();
let input = input.to_str().unwrap();
@@ -68,37 +49,5 @@ fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
}
/// An OLE2FRAME's frame outline is drawn, so an image export doesn't call it an undrawn entity (#2422); it
/// says once that the OLE content inside the frame is not drawn (review on #2587).
#[test]
fn an_ole_frame_is_noted_as_frame_only() {
let scratch = Scratch::new("ole");
let dir = scratch.0.clone();
let input = dir.join("ole.dxf");
let ole = "0\nSECTION\n2\nENTITIES\n0\nOLE2FRAME\n8\n0\n100\nAcDbOle2Frame\n10\n0\n20\n0\n30\n0\n11\n5\n21\n-3\n31\n0\n0\nENDSEC\n0\nEOF\n";
std::fs::write(&input, ole).unwrap();
let out = cli(&["convert", input.to_str().unwrap(), dir.join("out.png").to_str().unwrap()]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(!err.contains("entity not drawn") && !err.contains("entities not drawn"), "{err}");
let notes: Vec<&str> = err.lines().filter(|l| l.contains("OLE")).collect();
assert_eq!(notes, [format!("{}: 1 OLE object shown as the frame only (the OLE content is not drawn)", input.display())], "{err}");
}
/// An OLE2FRAME without usable corners (here group 11/21 missing) has no outline to draw, so it is
/// an undrawn entity, not "shown as the frame only" (review on #2587).
#[test]
fn an_ole_frame_without_corners_is_counted_as_not_drawn() {
let scratch = Scratch::new("ole-nocorner");
let dir = scratch.0.clone();
let input = dir.join("ole.dxf");
let ole = "0\nSECTION\n2\nENTITIES\n0\nOLE2FRAME\n8\n0\n100\nAcDbOle2Frame\n10\n0\n20\n0\n30\n0\n0\nENDSEC\n0\nEOF\n";
std::fs::write(&input, ole).unwrap();
let out = cli(&["convert", input.to_str().unwrap(), dir.join("out.png").to_str().unwrap()]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(&format!("{}: 1 entity not drawn (OLE2FRAME: 1)", input.display())), "{err}");
assert!(!err.contains("frame only"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
+1 -3
View File
@@ -19,8 +19,6 @@ cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-fonts = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -33,4 +31,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
+102 -62
View File
@@ -1,37 +1,28 @@
//! Host modes: a page that runs CADCraft opens drawings in it and stores what it saves instead
//! of the browser downloading. The messages and the two transports are `craft-web-host`'s
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
//! `cadcraft:` prefix. This module is CADCraft's part of it.
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! CADCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//!
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `cadcraft:ready` once the app is listening; send a drawing now. CADCraft reads bytes, so it
//! says `file: false` and a host sends `bytes`.
//! - `cadcraft:open` (from the host): `{ name, bytes }`. It opens like a file picked with
//! File › Open or dropped on the window; an oversized drawing is refused before it is copied.
//! - `cadcraft:save` (to the host): `{ name, saveAs, bytes }` for every file CADCraft would
//! otherwise download (SAVE, SAVEAS, exports): it replaces the engine's delivery hook. The host
//! stores the file and reports failures itself. CADCraft doesn't tell the host which saves
//! make a file the document, so `saveAs` is always `false`.
//! - `cadcraft:dirty` whenever unsaved changes appear or go away, `cadcraft:failed` when the app
//! couldn't start.
//! - `cadcraft:ready` (to the parent): `{ version }`, the app is listening; send a drawing now.
//! - `cadcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a file picked with File › Open or dropped on the window.
//! - `cadcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
//! file CADCraft would otherwise download (SAVE, SAVEAS, exports): it replaces the engine's
//! delivery hook. The parent stores the file and reports failures itself.
//! - `cadcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `cadcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! In both host modes the app starts on the Start tab rather than an empty Drawing1, and the
//! host guards leaving the page.
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
//! starts on the Start tab rather than an empty Drawing1, and the parent guards leaving the page.
use std::cell::RefCell;
use std::rc::Rc;
use std::cell::{Cell, RefCell};
use cadcraft_ui_egui::CadApp;
use craft_web_host::web::{FileData, Host, Message};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use web_sys::js_sys;
/// The app id in the host messages (`cadcraft:ready`).
const APP: &str = "cadcraft";
/// The name a drawing gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Drawing.dxf";
/// Longest file name taken from the parent page.
const MAX_NAME_CHARS: usize = 255;
thread_local! {
/// The bridge [`deliver`] posts through (the engine's hook is a plain `fn`).
@@ -40,15 +31,25 @@ thread_local! {
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
}
/// The unsaved-changes state last reported to the parent.
#[derive(Default)]
pub struct DirtyReport(Cell<Option<bool>>);
impl Bridge {
/// The bridge for this page, or `None` when CADCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin })
}
/// Make this the bridge [`deliver`] uses.
@@ -57,55 +58,94 @@ impl Bridge {
}
pub fn post_ready(&self) {
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
self.send(&[("type", "cadcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
pub fn post_failed(&self, error: &str) {
let _ = self.host.post_failed(error, None);
self.send(&[("type", "cadcraft:failed".into()), ("error", error.into())]);
}
/// Hear drawings from the host; they arrive through the upload inbox.
/// Hear drawings from the parent; they arrive through the upload inbox.
pub fn listen(&self, inbox: crate::upload::Inbox) {
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
receive_open(&name, &data, &inbox);
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("cadcraft:open") {
return;
}
let name = string(&data, "name")
.filter(|n| !n.trim().is_empty())
.map(|n| n.chars().take(MAX_NAME_CHARS).collect())
.unwrap_or_else(|| "Drawing.dxf".to_string());
// Refuse an oversized drawing before copying it into the app's memory.
let Some(array) = byte_array(&data) else { return };
if let Some(e) = CadApp::open_size_error(&name, u64::from(array.length())) {
inbox.refuse(e);
return;
}
inbox.receive(name, array.to_vec());
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// After each logic callback (including a hidden tab's): tell the host when unsaved changes
/// appear or go away. The crate does not repeat a state it already reported, so this is
/// idempotent.
pub fn report_dirty(&self, app: &CadApp) {
self.host.post_dirty(app.session.docs.iter().any(|d| d.is_dirty()));
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.0.set(Some(dirty));
}
}
fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
self.host.post_save(name, bytes, None, false)
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[("type", "cadcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
/// The engine's delivery hook in host mode ([`cadcraft_engine::cmd::file::set_deliver`]): hand
/// the file to the host page. `Ok` once it was sent; the engine then counts a drawing as saved.
/// the file to the parent page. `Ok` once it was posted; the engine then counts a drawing as saved.
pub fn deliver(name: &str, _mime: &str, bytes: &[u8]) -> Result<(), String> {
let bridge = BRIDGE.with(|b| b.borrow().clone()).ok_or("the page that embeds CADCraft is gone")?;
bridge.post_save(name, bytes)
}
/// The `cadcraft:open` payload, `{ name, bytes }` where `bytes` holds a drawing: it opens like a
/// file picked with File › Open or dropped on the window. The crate has cleaned the name (and cut
/// it to 255 characters); a blank one comes as `document` and becomes `Drawing.dxf`. An oversized
/// drawing is refused before it is copied.
fn receive_open(name: &str, data: &FileData, inbox: &crate::upload::Inbox) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
inbox.refuse(format!("Open: {name} came as a File; CADCraft reads bytes"));
return;
};
// Refuse an oversized drawing before copying it into the app's memory (the data's own length).
if let Some(e) = CadApp::open_size_error(&name, data.size() as u64) {
inbox.refuse(e);
return;
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.
fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.clone())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes))
} else {
None
}
inbox.receive(name, array.to_vec());
}
+24 -24
View File
@@ -6,8 +6,7 @@
//! with egui shapes instead of the GPU canvas, `?sample` opens the sample drawing. Saving downloads
//! the drawing through the browser (`download.rs`). With `?host=parent` (inside an `<iframe>`), a
//! same-origin parent page sends the drawing to open and stores what CADCraft saves instead
//! (`host.rs`); a page can also host CADCraft in its own document (no iframe) with
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
//! (`host.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
@@ -28,11 +27,16 @@ mod web {
const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, the
/// host page (`?host=parent`, or `data-host="page"` on the canvas), the browser's
/// `prefers-reduced-motion` (it stays
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
/// and the document title last written to the page.
struct Shell(CadApp, crate::upload::Inbox, Option<crate::host::Bridge>, Option<web_sys::MediaQueryList>, Option<String>);
struct Shell(
CadApp,
crate::upload::Inbox,
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
Option<web_sys::MediaQueryList>,
Option<String>,
);
impl eframe::App for Shell {
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
@@ -47,10 +51,10 @@ mod web {
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// The crate suppresses duplicates, so reporting after every logic callback
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some(bridge) = &self.2 {
bridge.report_dirty(&self.0);
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
@@ -96,10 +100,8 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// The app's canvas: the one element `index.html` and every embedding page must provide,
/// with the id the app looks up.
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
web_sys::window()?.document()?.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
@@ -126,17 +128,10 @@ mod web {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
let Some(canvas) = app_canvas() else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
// drawings in it and stores what it saves. Otherwise `data-host="page"` on the canvas
// opts in a page that runs CADCraft in its own document (no iframe) and talks over DOM
// `CustomEvent`s on the canvas (`host`). With neither, there is no file system here:
// saved files reach the user as browser downloads; an unframed `?host=parent` stays
// that normal app.
let bridge = crate::host::Bridge::connect(&canvas);
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
// files reach the user as browser downloads.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
match &bridge {
Some(bridge) => {
bridge.install();
@@ -147,6 +142,10 @@ mod web {
let app_bridge = bridge.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
@@ -158,7 +157,7 @@ mod web {
canvas,
options,
Box::new(move |cc| {
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
// Embedded, the parent sends the drawing: start on the Start tab, not Drawing1.
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
let mut app = CadApp::new(session, services());
app.system_languages =
@@ -193,9 +192,10 @@ mod web {
picker.pick();
None
}));
let host = app_bridge.inspect(|bridge| {
let host = app_bridge.map(|bridge| {
bridge.listen(inbox.clone());
bridge.post_ready();
(bridge, crate::host::DirtyReport::default())
});
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
-7
View File
@@ -88,10 +88,6 @@ sync_github_name = false
real_name = "Robert Lee"
sync_github_name = true
[people.assamaral]
real_name = "assamaral"
sync_github_name = false
[people.2ez4gcx]
real_name = "Doan Huynh Huu Khuong"
display_name = "Doan Huynh Huu Khuong"
@@ -113,6 +109,3 @@ real_name = "Andrea Brandi"
[people.qinshah]
sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
-8
View File
@@ -60,13 +60,6 @@ pub struct Unknown {
pub tags: Vec<RawTag>,
}
impl Unknown {
/// Whether this is an OLE2FRAME (the type name is matched without regard to case).
pub fn is_ole_frame(&self) -> bool {
self.dxf_type.eq_ignore_ascii_case("OLE2FRAME")
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum EntityKind {
@@ -476,7 +469,6 @@ impl EntityKind {
let a = if mirror { t.rotation() } else { t.rotation() + rot };
t.dir = Vec2::from_angle(cadcraft_geom::norm_angle(a)).to3(0.0);
}
EntityKind::Unknown(u) if u.is_ole_frame() => crate::ole_frame_transform(&mut u.tags, m),
EntityKind::Unknown(_) => {}
}
}
+4 -597
View File
@@ -9,239 +9,6 @@ use crate::{Drawing, Entity, EntityKind, LwPolyline, Prim};
/// Nested block references deeper than this are ignored (cyclic or hostile files).
pub const MAX_BLOCK_DEPTH: usize = 16;
/// Positions in `tags` of the groups outside application-defined `102 {` .. `102 }` groups, whose
/// codes are the application's own and not the entity's.
fn outside_app(tags: &[crate::RawTag]) -> impl Iterator<Item = usize> + '_ {
let mut in_app = false;
tags.iter().enumerate().filter_map(move |(i, t)| {
if t.code == 102 {
in_app = t.value.starts_with('{');
None
} else {
(!in_app).then_some(i)
}
})
}
/// The positions of the frame's corner groups 10, 20, 11, 21 (in that order) and their values:
/// the first of each outside application groups, found in one pass that stops at the last.
fn ole_corner_groups(tags: &[crate::RawTag]) -> Option<[(usize, f64); 4]> {
let mut found: [Option<(usize, f64)>; 4] = [None; 4];
for i in outside_app(tags) {
let Some(t) = tags.get(i) else { continue };
let slot = match t.code {
10 => 0,
20 => 1,
11 => 2,
21 => 3,
_ => continue,
};
if let Some(f) = found.get_mut(slot).filter(|f| f.is_none()) {
*f = Some((i, t.value.trim().parse::<f64>().ok().filter(|v| v.is_finite())?));
if found.iter().all(Option::is_some) {
break;
}
}
}
let [Some(a), Some(b), Some(c), Some(d)] = found else { return None };
Some([a, b, c, d])
}
/// The closed outline of an OLE2FRAME from its upper-left (groups 10, 20) and lower-right
/// (groups 11, 21) corners (DXF Reference, OLE2FRAME); `None` when either is missing or not finite.
pub fn ole_frame(tags: &[crate::RawTag]) -> Option<[Vec2; 5]> {
let [(_, x1), (_, y1), (_, x2), (_, y2)] = ole_corner_groups(tags)?;
let (a, b) = (Vec2::new(x1, y1), Vec2::new(x2, y2));
Some([a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y), a])
}
/// The bytes of the OLE data before its corners: a 2-byte marker.
const OLE_HEADER: usize = 2;
/// The corners in the OLE data: four of x, y, z as little-endian doubles (upper left, upper right,
/// lower right, lower left).
const OLE_CORNERS: usize = 12;
/// Replace the four corners at the start of the OLE data (the 310 groups' hex, in order) by `new`
/// (upper left, upper right, lower right, lower left), keeping z and every group's length and
/// letter case. Only the groups that cover the header are decoded and rewritten; the rest of the
/// payload is not touched. Data that is not valid hex, too short, or whose corners are not the old
/// `frame`'s is left alone.
fn ole_data_transform(tags: &mut [crate::RawTag], new: [Vec2; 4], frame: [f64; 4]) {
const END: usize = OLE_HEADER + OLE_CORNERS * 8;
let mut bytes: Vec<u8> = Vec::with_capacity(END);
// The groups read, and how many leading bytes of each belong to the header.
let mut spans: Vec<(usize, usize)> = Vec::new();
for i in outside_app(tags).filter(|&i| tags.get(i).is_some_and(|t| t.code == 310)) {
if bytes.len() >= END {
break;
}
let Some(t) = tags.get(i) else { return };
let s = t.value.trim();
if s.len() % 2 != 0 || !s.is_ascii() {
return;
}
let take = (s.len() / 2).min(END - bytes.len());
for k in 0..take {
// `from_str_radix` also takes a sign, so each digit is checked first.
let Some(h) = s.get(2 * k..2 * k + 2).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())) else { return };
let Ok(b) = u8::from_str_radix(h, 16) else { return };
bytes.push(b);
}
spans.push((i, take));
}
if bytes.len() < END {
return;
}
// Only data whose corners are this frame's corners (UL, UR, LR, LL) has the layout above; any
// other header is not ours to rewrite.
let [x1, y1, x2, y2] = frame;
let want = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
// Two corners match within a fixed tolerance for ordinary coordinates (CORNER_EPS at most), or
// within a few ulps of the coordinate where that is larger, which is from about 1.1e9 up.
let near = |a: f64, b: f64| {
let m = a.abs().max(b.abs());
(a - b).abs() <= (1e-9 * m.max(1.0)).min(CORNER_EPS).max(4.0 * f64::EPSILON * m)
};
for (c, (wx, wy)) in want.iter().enumerate() {
let at = OLE_HEADER + c * 24;
let rd = |o: usize| bytes.get(o..o + 8).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes);
let (Some(x), Some(y)) = (rd(at), rd(at + 8)) else { return };
if !(near(x, *wx) && near(y, *wy)) {
return;
}
}
for (c, p) in new.iter().enumerate() {
let at = OLE_HEADER + c * 24;
if let Some(s) = bytes.get_mut(at..at + 8) {
s.copy_from_slice(&p.x.to_le_bytes());
}
if let Some(s) = bytes.get_mut(at + 8..at + 16) {
s.copy_from_slice(&p.y.to_le_bytes());
}
}
let mut pos = 0;
for (i, n) in spans {
let (Some(t), Some(chunk)) = (tags.get_mut(i), bytes.get(pos..pos + n)) else { return };
// Leave any padding around the digits, and the digits after the header, as they were.
let start = t.value.len() - t.value.trim_start().len();
// Only the rewritten digits and a few after them, for the case of the nearest letter: a
// hostile 310 line can be as long as the file (the value is checked to be ASCII above).
let end = start.saturating_add(2 * n + 64).min(t.value.len());
let hex = recase(chunk, t.value.get(start..end).unwrap_or_default());
t.value.replace_range(start..start + 2 * n, &hex);
pos += n;
}
}
/// The hex of `chunk`, each digit in the case of the digit at the same place in `old`. A digit that
/// was a number and becomes a letter takes the case of the nearest letter in `old`, upper when there
/// is none, so bytes that did not change keep their exact text.
fn recase(chunk: &[u8], old: &str) -> String {
let old = old.as_bytes();
// For each place, whether the letter nearest to it (the one at it, else the closest before or
// after; the one before on a tie) is lower case: one pass for the next letter at or after each
// place, one for the previous, then the nearer of the two.
let mut next: Vec<Option<(usize, bool)>> = vec![None; old.len()];
let mut seen: Option<(usize, bool)> = None;
for (i, c) in old.iter().enumerate().rev() {
if c.is_ascii_alphabetic() {
seen = Some((i, c.is_ascii_lowercase()));
}
if let Some(slot) = next.get_mut(i) {
*slot = seen;
}
}
let mut lower_at = vec![false; old.len()];
let mut prev: Option<(usize, bool)> = None;
for (i, c) in old.iter().enumerate() {
if c.is_ascii_alphabetic() {
prev = Some((i, c.is_ascii_lowercase()));
}
let after = next.get(i).copied().flatten();
let lower = match (prev, after) {
(Some((p, lp)), Some((q, lq))) => {
if i - p <= q - i {
lp
} else {
lq
}
}
(Some((_, l)), None) | (None, Some((_, l))) => l,
(None, None) => false,
};
if let Some(slot) = lower_at.get_mut(i) {
*slot = lower;
}
}
let mut out = String::with_capacity(chunk.len() * 2);
for (k, b) in chunk.iter().enumerate() {
for (j, d) in [b >> 4, b & 15].into_iter().enumerate() {
let digit = char::from_digit(d.into(), 16).unwrap_or('0');
out.push(if lower_at.get(2 * k + j).copied().unwrap_or(false) { digit } else { digit.to_ascii_uppercase() });
}
}
out
}
/// The fixed part of the tolerance when matching the corners in the OLE data to the frame's. The
/// header holds the same f64 the frame groups print, so they agree to a few ulps. Up to about 1.1e9
/// a fixed tolerance of at most this decides, so that distinct geometry does not match; above that,
/// four ulps of the coordinate (`4 * EPSILON * |x|`) exceed it and decide instead.
const CORNER_EPS: f64 = 1e-6;
/// Transform an OLE2FRAME by `m`: its two corners are mapped through `m` (a DWG save writes the
/// frame's position from these groups), and so are the corners at the start of its OLE data (310
/// groups), which a reader of the saved file may trust instead. The frame stays axis aligned with
/// its upper-left corner first. A matrix that keeps the axes or swaps them (a move, scale, mirror or
/// quarter turn) gives the box of the two mapped corners. Any other turn would collapse that box
/// (a square turned 45° maps its two corners level), so the frame's centre is mapped and its size
/// kept, scaled by `m`'s, its sides swapped when the turn is nearer a quarter turn. Commands refuse
/// a rotation or mirror (see [`ole_frame_accepts`]), but internal callers such as EXPLODE, REFEDIT
/// and paste still apply them, and must leave a consistent frame. A frame with no usable corners is
/// left alone.
pub fn ole_frame_transform(tags: &mut [crate::RawTag], m: &cadcraft_geom::Mat3) {
let Some([(ix1, x1), (iy1, y1), (ix2, x2), (iy2, y2)]) = ole_corner_groups(tags) else { return };
let frame = [x1, y1, x2, y2];
let t = 1e-12 * (m.a.abs() + m.b.abs() + m.c.abs() + m.d.abs());
let axes_kept = (m.b.abs() <= t && m.c.abs() <= t) || (m.a.abs() <= t && m.d.abs() <= t);
let (a, b) = if axes_kept {
(m.apply(Vec2::new(x1, y1)), m.apply(Vec2::new(x2, y2)))
} else {
let center = m.apply(Vec2::new((x1 + x2) / 2.0, (y1 + y2) / 2.0));
// A plain turn keeps the size exactly; cos² + sin² can miss 1 by an ulp or two.
let s = (m.a * m.d - m.b * m.c).abs().sqrt();
let scale = if (s - 1.0).abs() < 1e-12 { 1.0 } else { s };
let (mut w, mut h) = ((x2 - x1).abs() * scale, (y2 - y1).abs() * scale);
// Where the frame's x axis goes: nearer to y than to x is nearer a quarter turn.
if m.b.abs() > m.a.abs() {
std::mem::swap(&mut w, &mut h);
}
(center - Vec2::new(w, h) / 2.0, center + Vec2::new(w, h) / 2.0)
};
if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
return;
}
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
let (ul, lr) = (Vec2::new(lo.x, hi.y), Vec2::new(hi.x, lo.y));
for (i, v) in [(ix1, ul.x), (iy1, ul.y), (ix2, lr.x), (iy2, lr.y)] {
if let Some(t) = tags.get_mut(i) {
t.value = v.to_string();
}
}
ole_data_transform(tags, [ul, Vec2::new(lr.x, ul.y), lr, Vec2::new(ul.x, lr.y)], frame);
}
/// Whether `m` can move an OLE2FRAME: a translation (move, copy, stretch of the whole frame) or a
/// uniform scale with a positive factor, which keep the frame axis aligned and its upper-left corner
/// upper-left.
pub fn ole_frame_accepts(m: &cadcraft_geom::Mat3) -> bool {
const T: f64 = 1e-12;
// The tolerance grows with the scale: a matrix built from two angles (ALIGN) leaves rounding noise
// in b and c of about `scale * 1e-16`.
let t = T * m.a.abs();
m.b.abs() < t && m.c.abs() < t && m.a > 0.0 && (m.a - m.d).abs() <= t
}
/// Bounds of a single-line TEXT / ATTDEF / ATTRIB of `len` characters, following its justification
/// the same way the renderer places it (the stroke font is about 0.88 of the height per character).
fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
@@ -420,15 +187,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
b = Bounds2::from_points(c.polygon().into_iter().map(|p| m.apply(p)));
}
if ins.cols > 1 || ins.rows > 1 {
// The grid's four corner copies (as many as are drawn): turned, the far corner
// alone misses the side ones.
let (cols, rows) =
(f64::from(ins.cols.clamp(1, 10_000).saturating_sub(1)), f64::from(ins.rows.clamp(1, 10_000).saturating_sub(1)));
let one = b;
for (c, r) in [(cols, 0.0), (0.0, rows), (cols, rows)] {
let off = Vec2::new(ins.col_spacing * c, ins.row_spacing * r).rotate(ins.rotation);
b = b.union(&Bounds2::from_points(one.corners().map(|p| p + off)));
}
let off =
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
}
}
for a in &ins.attribs {
@@ -516,8 +278,6 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
Bounds2::from_points([o, o + u, o + v, o + u + v])
}
EntityKind::LwPolyline(p) => lwpolyline_bounds(p),
// An OLE object's content is not drawn, but its frame is, so the frame counts (#2422).
EntityKind::Unknown(u) if u.is_ole_frame() => ole_frame(&u.tags).map_or(Bounds2::EMPTY, Bounds2::from_points),
kind => {
let mut b = Bounds2::EMPTY;
for p in kind.prims() {
@@ -713,41 +473,6 @@ mod tests {
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
}
/// An OLE2FRAME counts in the extents through its corner tags (#2422); a missing or
/// non-finite corner counts as nothing.
#[test]
fn ole_frame_counts_in_extents() {
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
let frame = |tags: Vec<crate::RawTag>| EntityKind::Unknown(crate::Unknown { dxf_type: "OLE2FRAME".into(), tags });
let b = bounds(frame(vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")]));
assert_eq!((b.min, b.max), (Vec2::new(30.0, -22.0), Vec2::new(35.0, -19.0)));
assert!(bounds(frame(vec![tag(10, "1"), tag(20, "2")])).is_empty(), "one corner is not a frame");
assert!(bounds(frame(vec![tag(10, "NaN"), tag(20, "0"), tag(11, "1"), tag(21, "inf")])).is_empty());
assert!(bounds(EntityKind::Unknown(crate::Unknown { dxf_type: "OTHER".into(), tags: vec![tag(10, "5"), tag(20, "5")] })).is_empty());
}
/// Groups 10/20 inside an application-defined `102 {...}` group belong to the application, not
/// to the frame: the real corners come after it (review on #2587).
#[test]
fn ole_frame_skips_application_groups() {
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
let tags = vec![
tag(102, "{ACAD_REACTORS"),
tag(10, "900"),
tag(20, "900"),
tag(11, "901"),
tag(21, "901"),
tag(102, "}"),
tag(10, "30"),
tag(20, "-19"),
tag(11, "35"),
tag(21, "-22"),
];
let q = ole_frame(&tags).expect("frame");
assert_eq!((q[0], q[2]), (Vec2::new(30.0, -19.0), Vec2::new(35.0, -22.0)));
assert!(ole_frame(&tags[..6]).is_none(), "corners only inside the group are not a frame");
}
#[test]
fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() {
let line = |a: (f64, f64), b: (f64, f64), layer: &str| {
@@ -797,322 +522,4 @@ mod tests {
assert!(extents.width() > drawn.width());
}
}
/// A turned array of references reaches its side copies, not only the first and the last;
/// a hostile count measures only the copies that are drawn.
#[test]
fn a_rotated_array_counts_every_corner_copy() {
let mut d = Drawing::new_metric();
let mut b = crate::Block::new("B");
b.entities.push(Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) })));
d.blocks.insert("B".into(), std::sync::Arc::new(b));
let array = |cols: u32, rows: u32, rotation: f64| {
let ins = crate::Insert {
block: "B".into(),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation,
cols,
rows,
col_spacing: 10.0,
row_spacing: 10.0,
..crate::Insert::default()
};
Entity::new(crate::Handle(2), EntityKind::Insert(ins))
};
// Turned 45°: the copies sit at (0, 0), (7.07, 7.07), (-7.07, 7.07) and (0, 14.14).
let b = entity_bounds(&d, &array(2, 2, std::f64::consts::FRAC_PI_4), 0);
let side = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
assert!((b.min.x + side).abs() < 1e-9 && b.max.x >= side, "{b:?}");
assert!((b.max.y - 2.0 * side - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-9, "{b:?}");
// The renderer draws at most 10,000 columns.
let b = entity_bounds(&d, &array(u32::MAX, 1, 0.0), 0);
assert!((b.max.x - (10.0 * 9_999.0 + 1.0)).abs() < 1e-6, "{b:?}");
}
/// An OLE2FRAME at large coordinates, with its OLE data header holding the frame's corners
/// shifted by `dx` in x.
fn big_frame(dx: f64) -> Vec<crate::RawTag> {
let (x1, y1, x2, y2) = (1e9, 1e9, 1e9 + 10.0, 1e9 + 5.0);
let mut blob = vec![1u8, 0];
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
for v in [x + dx, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let tag = |code: i32, value: String| crate::RawTag { code, value };
vec![
tag(10, x1.to_string()),
tag(20, y1.to_string()),
tag(11, x2.to_string()),
tag(21, y2.to_string()),
tag(310, blob.iter().map(|b| format!("{b:02X}")).collect()),
]
}
/// The x of the first corner in the OLE data of a [`big_frame`].
fn data_x(tags: &[crate::RawTag]) -> f64 {
let hex = tags.last().map(|t| t.value.as_str()).unwrap_or_default();
let bytes: Vec<u8> = (0..hex.len() / 2).filter_map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
bytes.get(2..10).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes).unwrap_or(f64::NAN)
}
/// The match between the frame and the corners in its OLE data does not loosen with the
/// coordinate: half a unit off at 1e9 is not the frame, so the bytes stay as they are (review
/// on #2587).
#[test]
fn a_half_unit_offset_at_large_coordinates_is_not_the_frame() {
let mut tags = big_frame(0.5);
let before = tags.last().cloned();
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1.0, 1.0)));
assert_eq!(tags.first().map(|t| t.value.as_str()), Some("1000000001"), "the frame tags move");
assert_eq!(tags.last().map(|t| &t.value), before.as_ref().map(|t| &t.value), "the 310 bytes stay identical");
}
#[test]
fn exact_corners_at_large_coordinates_still_move() {
let mut tags = big_frame(0.0);
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1.0, 1.0)));
assert_eq!(data_x(&tags), 1e9 + 1.0);
}
/// A translation, and a uniform positive scale about any point, keep a frame axis aligned and
/// upper-left; a rotation, a mirror, a non-uniform or non-positive scale do not.
#[test]
fn ole_frame_accepts_translations_and_uniform_positive_scales_only() {
use cadcraft_geom::Mat3;
let p = Vec2::new(3.0, -4.0);
assert!(ole_frame_accepts(&Mat3::translate(p)));
assert!(ole_frame_accepts(&Mat3::scale_about(p, 2.5)));
assert!(ole_frame_accepts(&Mat3::scale_about(p, 1e-3)));
assert!(!ole_frame_accepts(&Mat3::scale(2.0, 3.0)));
assert!(!ole_frame_accepts(&Mat3::scale_about(p, -1.0)));
assert!(!ole_frame_accepts(&Mat3::scale_about(p, 0.0)));
assert!(!ole_frame_accepts(&Mat3::rotate_about(p, 0.5)));
assert!(!ole_frame_accepts(&Mat3::mirror(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0))));
}
/// ALIGN with a scale builds its matrix from two angles that agree only to rounding, so a large
/// scale leaves a rotation part of about `scale * 1e-16`. That is a scale, not a turn: the
/// tolerance for it grows with the scale, while a real turn is still refused (review on #2587).
#[test]
fn ole_frame_accepts_a_large_scale_with_rounding_noise_in_its_rotation() {
use cadcraft_geom::Mat3;
let p = Vec2::new(3.0, -4.0);
let noisy = Mat3::scale_about(p, 1e6).then_before(Mat3::rotate_about(p, 2e-16));
assert!(noisy.b.abs() > 1e-12, "the setup must exceed an absolute tolerance: {}", noisy.b);
assert!(ole_frame_accepts(&noisy));
let turned = Mat3::scale_about(p, 1e6).then_before(Mat3::rotate_about(p, 1e-6));
assert!(!ole_frame_accepts(&turned));
}
/// The corners (x, y) in the OLE data header of a frame built by [`big_frame`]-style tags.
fn header_corners(tags: &[crate::RawTag]) -> Vec<(f64, f64)> {
let hex: String = tags.iter().filter(|t| t.code == 310).map(|t| t.value.as_str()).collect();
let bytes: Vec<u8> = (0..hex.len() / 2).filter_map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
let f = |o: usize| bytes.get(o..o + 8).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes).unwrap_or(f64::NAN);
(0..4).map(|c| (f(2 + c * 24), f(10 + c * 24))).collect()
}
fn small_frame() -> Vec<crate::RawTag> {
let mut blob = vec![1u8, 0];
for (x, y) in [(30.0f64, -19.0f64), (35.0, -19.0), (35.0, -22.0), (30.0, -22.0)] {
for v in [x, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
vec![
tag(10, "30"),
tag(20, "-19"),
tag(11, "35"),
tag(21, "-22"),
crate::RawTag { code: 310, value: blob.iter().map(|b| format!("{b:02X}")).collect() },
]
}
/// Internal callers (EXPLODE, REFEDIT, paste, array placement) transform a frame by any matrix.
/// It stays axis aligned: groups 10/20 are the upper-left of its box, 11/21 the lower-right,
/// and the OLE data holds the same box (review on #2587). A mirror keeps the axes, so the box
/// is that of the mapped corners; a 30° turn keeps the frame's own 5 x 3 about its turned
/// centre, where the corners' box would flatten it.
#[test]
fn a_rotated_frame_stays_axis_aligned_and_consistent() {
use cadcraft_geom::Mat3;
let rotate = Mat3::rotate_about(Vec2::new(1.0, 2.0), 30f64.to_radians());
let mirror = Mat3::mirror(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0));
for (m, turned) in [(rotate, true), (mirror, false)] {
let mut tags = small_frame();
let mapped = if turned {
let c = m.apply(Vec2::new(32.5, -20.5));
[c - Vec2::new(2.5, 1.5), c + Vec2::new(2.5, 1.5)]
} else {
[m.apply(Vec2::new(30.0, -19.0)), m.apply(Vec2::new(35.0, -22.0))]
};
let (minx, maxx) = (mapped[0].x.min(mapped[1].x), mapped[0].x.max(mapped[1].x));
let (miny, maxy) = (mapped[0].y.min(mapped[1].y), mapped[0].y.max(mapped[1].y));
ole_frame_transform(&mut tags, &m);
let g = |code: i32| tags.iter().find(|t| t.code == code).and_then(|t| t.value.parse::<f64>().ok()).unwrap_or(f64::NAN);
assert_eq!((g(10), g(20), g(11), g(21)), (minx, maxy, maxx, miny));
assert_eq!(header_corners(&tags), vec![(minx, maxy), (maxx, maxy), (maxx, miny), (minx, miny)]);
}
}
/// `from_str_radix` accepts a leading sign, so "+A" must not count as a hex byte (review on
/// #2587): data with one is not valid and MOVE leaves its bytes alone.
#[test]
fn a_plus_sign_in_ole_data_is_not_hex() {
let mut tags = small_frame();
let hex = tags.last().map(|t| t.value.clone()).unwrap_or_default();
// The marker's first byte is 01: "+1" would decode to the same byte.
let signed = format!("+1{}", hex.get(2..).unwrap_or_default());
if let Some(t) = tags.last_mut() {
t.value = signed.clone();
}
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
assert_eq!(tags.first().map(|t| t.value.as_str()), Some("40"), "the frame tags still move");
assert_eq!(tags.last().map(|t| t.value.as_str()), Some(signed.as_str()));
}
/// A payload of 1,000 groups of 127 bytes: only the group(s) holding the 98-byte header are
/// rewritten. Later groups stay as they were, even ones that are not valid hex or have padding
/// (the old code re-encoded the whole payload and gave up on any bad group).
#[test]
fn a_large_ole_payload_only_rewrites_its_leading_groups() {
let mut tags = small_frame();
let first = tags.pop().map(|t| t.value).unwrap_or_default();
let mut head = first.clone();
// Fill the first group up to 127 bytes.
head.push_str(&"ab".repeat(127 - first.len() / 2));
tags.push(crate::RawTag { code: 310, value: head });
for g in 1..1000 {
let value = match g {
2 => format!(" {} ", "Ab".repeat(127)),
500 => "ZZ".repeat(127),
_ => "ab".repeat(127),
};
tags.push(crate::RawTag { code: 310, value });
}
let rest: Vec<String> = tags.iter().skip(5).map(|t| t.value.clone()).collect();
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
assert_eq!(header_corners(&tags[..5]), vec![(40.0, -14.0), (45.0, -14.0), (45.0, -17.0), (40.0, -17.0)]);
let after: Vec<String> = tags.iter().skip(5).map(|t| t.value.clone()).collect();
assert_eq!(after, rest, "every group after the first is string-identical");
assert_eq!(tags.get(4).map(|t| t.value.len()), Some(254), "the first group keeps its length");
}
/// Above about 8.6e9 one ulp of an f64 is more than the fixed ceiling on the corner match, so a
/// text corner one ulp from the header double must still count as the same corner (review on
/// #2587).
#[test]
fn ole_frame_moves_at_2e10_with_a_one_ulp_text_corner() {
let mut tags = big_frame(0.0);
// Rebuild the frame at 2e10: the header keeps the exact corners, the text is one ulp off.
let (x1, y1, x2, y2) = (2e10f64, 2e10f64, 2e10 + 10.0, 2e10 + 5.0);
let mut blob = vec![1u8, 0];
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
for v in [x, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let text_x1 = f64::from_bits(x1.to_bits() + 1);
tags[0].value = text_x1.to_string();
tags[1].value = y1.to_string();
tags[2].value = x2.to_string();
tags[3].value = y2.to_string();
if let Some(t) = tags.last_mut() {
t.value = blob.iter().map(|b| format!("{b:02X}")).collect();
}
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1e4, 1e4)));
assert_eq!(header_corners(&tags)[0].0, text_x1 + 1e4, "the header moved with the frame");
}
/// Below scale 1 the rotation bound is still relative to the scale: a small real turn at a tiny
/// scale is refused, and so is a non-uniform scale whose two factors differ by more than a
/// relative 1e-12 (review on #2587).
#[test]
fn ole_frame_refuses_a_small_turn_at_a_tiny_scale() {
use cadcraft_geom::Mat3;
let p = Vec2::new(3.0, -4.0);
let turned = Mat3::scale_about(p, 1e-9).then_before(Mat3::rotate_about(p, 0.05f64.to_radians()));
assert!(!ole_frame_accepts(&turned));
assert!(ole_frame_accepts(&Mat3::scale_about(p, 1e-9)));
assert!(!ole_frame_accepts(&Mat3::scale(1e-6, 1e-6 + 5e-13)));
}
/// Hex digits keep the case they had: bytes the transform does not change come back as the
/// identical text, also in a group that mixes upper and lower case (review on #2587).
#[test]
fn moving_an_ole_frame_keeps_each_hex_digits_case() {
let mut tags = small_frame();
let old = tags.last().map(|t| t.value.clone()).unwrap_or_default();
let mixed: String = old.chars().enumerate().map(|(i, c)| if i < 24 { c.to_ascii_lowercase() } else { c }).collect();
assert_ne!(mixed, old);
if let Some(t) = tags.last_mut() {
t.value = mixed.clone();
}
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
let new = tags.last().map(|t| t.value.clone()).unwrap_or_default();
assert_eq!(new.len(), mixed.len());
let byte = |s: &str, i: usize| s.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
let mut same = 0;
for i in 0..mixed.len() / 2 {
if byte(&mixed, i) == byte(&new, i) {
same += 1;
assert_eq!(mixed.get(2 * i..2 * i + 2), new.get(2 * i..2 * i + 2), "byte {i}");
}
}
assert!(same > 40, "most of the header is unchanged: {same}");
assert_eq!(header_corners(&tags)[0], (40.0, -14.0));
}
/// A frame from `(x1, y1)` (upper left) to `(x2, y2)` (lower right), its OLE header holding the
/// same corners.
fn frame_at(x1: f64, y1: f64, x2: f64, y2: f64) -> Vec<crate::RawTag> {
let mut blob = vec![1u8, 0];
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
for v in [x, y, 0.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let tag = |code: i32, value: String| crate::RawTag { code, value };
vec![
tag(10, x1.to_string()),
tag(20, y1.to_string()),
tag(11, x2.to_string()),
tag(21, y2.to_string()),
tag(310, blob.iter().map(|b| format!("{b:02X}")).collect()),
]
}
fn size(tags: &[crate::RawTag]) -> (f64, f64, Vec2) {
let o = ole_frame(tags).unwrap();
let b = Bounds2::from_points(o);
(b.width(), b.height(), b.center())
}
/// A frame turned by an angle that isn't a quarter turn keeps its size about its turned
/// centre instead of collapsing to the box of its two corners (a 10 x 10 square turned 45°
/// became 14.14 x 0), and the OLE header follows (#2587 review).
#[test]
fn a_frame_turned_off_the_axes_keeps_its_size() {
use cadcraft_geom::Mat3;
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
let mut tags = frame_at(0.0, 10.0, 10.0, 0.0);
ole_frame_transform(&mut tags, &Mat3::rotate(std::f64::consts::FRAC_PI_4));
let (w, h, c) = size(&tags);
assert!(close(w, 10.0) && close(h, 10.0), "{w} x {h}");
assert!(close(c.x, 0.0) && close(c.y, 50f64.sqrt()), "{c:?}");
let (ul, lr) = (header_corners(&tags)[0], header_corners(&tags)[2]);
assert!(close(ul.0, -5.0) && close(lr.1, 50f64.sqrt() - 5.0), "the header follows: {ul:?} {lr:?}");
// Nearer a quarter turn, the sides swap: a 10 wide, 4 tall frame turned 100° is 4 x 10.
let mut tags = frame_at(0.0, 4.0, 10.0, 0.0);
ole_frame_transform(&mut tags, &Mat3::rotate(100f64.to_radians()));
let (w, h, _) = size(&tags);
assert!(close(w, 4.0) && close(h, 10.0), "{w} x {h}");
// A turn and its inverse bring the frame back.
let mut tags = frame_at(2.0, 9.0, 7.0, 3.0);
let m = Mat3::rotate(0.3);
ole_frame_transform(&mut tags, &m);
ole_frame_transform(&mut tags, &m.inverse().unwrap());
let (w, h, c) = size(&tags);
assert!(close(w, 5.0) && close(h, 6.0) && close(c.x, 4.5) && close(c.y, 6.0), "{w} x {h} at {c:?}");
}
}
-36
View File
@@ -75,19 +75,9 @@ pub struct Foreign {
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
pub data_store: Vec<RawTag>,
/// The `$ACADVER` of the file all this was read from: its groups are in that version's form.
/// The drawing's own `$ACADVER` is the version it is saved as, which SAVEAS changes, so a
/// later save must not take it for this one (#2471).
pub version: Option<String>,
}
impl Foreign {
/// The class of the file's CLASSES a proxy's group 91 names (see [`class_number`]), if any.
/// Saving as DWG keeps a proxy with that class and leaves out one whose class it can't name.
pub fn proxy_class(&self, tags: &[RawTag]) -> Option<&str> {
class_index(class_number(tags.iter().map(|r| (r.code, r.value.as_str())))).and_then(|i| self.class_order.get(i)).map(String::as_str)
}
pub fn is_empty(&self) -> bool {
self.objects.is_empty()
&& self.dictionary.is_empty()
@@ -266,29 +256,3 @@ impl Foreign {
}
}
}
/// Where a proxy's class number is: its first group 91 outside the `102 {NAME` … `102 }` groups
/// (an application's can hold a 91 of its own) and before any xdata.
pub fn class_group<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)>) -> Option<usize> {
let mut in_group = false;
for (i, (code, value)) in groups.into_iter().enumerate() {
match code {
1000.. => return None,
102 => in_group = value.as_ref().trim().starts_with('{'),
91 if !in_group => return Some(i),
_ => {}
}
}
None
}
/// A proxy's class number (see [`class_group`]).
pub fn class_number<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)> + Clone) -> Option<i64> {
let i = class_group(groups.clone())?;
groups.into_iter().nth(i).and_then(|(_, v)| v.as_ref().trim().parse().ok())
}
/// The place in CLASSES a class number names (500 is the first class).
pub fn class_index(number: Option<i64>) -> Option<usize> {
usize::try_from(number?.checked_sub(500)?).ok()
}
-17
View File
@@ -90,23 +90,6 @@ impl Header {
.filter(|p| p.is_finite() && if allow_zero { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 })
.unwrap_or(Vec2::new(0.5, 0.5))
}
/// The model view the drawing was saved at: `$VIEWCTR` (centre) and `$VIEWSIZE` (height, in
/// drawing units), when both are present and usable. A DXF file keeps the pair in HEADER and
/// repeats it in the `*ACTIVE` VPORT record (12/22 and 40); a reader opens the drawing on it
/// rather than on the extents, so a drawing saved on one detail of a large sheet opens there.
pub fn saved_view(&self) -> Option<(Vec2, f64)> {
let center = self.point("VIEWCTR")?.xy();
let height = self.get("VIEWSIZE").and_then(HVal::as_f64)?;
(center.is_finite() && height.is_finite() && height > 1e-9).then_some((center, height))
}
/// Record the view the drawing is saved at (see [`Header::saved_view`]). A view that cannot
/// be shown is not recorded, so a usable stored view is never replaced by an unusable one.
pub fn set_saved_view(&mut self, center: Vec2, height: f64) {
if center.is_finite() && height.is_finite() && height > 1e-9 {
self.set("VIEWCTR", HVal::Point(center.to3(0.0)));
self.set_f64("VIEWSIZE", height);
}
}
pub fn set_f64(&mut self, name: &str, v: f64) {
self.set(name, HVal::Real(v));
}
+1 -4
View File
@@ -29,10 +29,7 @@ pub use cadcraft_color as color;
pub use cadcraft_geom as geom;
pub use constraint::*;
pub use entity::*;
pub use extents::{
BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached, ole_frame, ole_frame_accepts,
ole_frame_transform,
};
pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached};
pub use format::Format;
pub use gdt::*;
pub use header::{HVal, Header};
-8
View File
@@ -799,13 +799,6 @@ pub struct PageSetup {
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
pub paper_mm: bool,
/// Paper size (DXF groups 44, 45) and plot rotation (group 73) as the file stored them.
/// Saving writes them back while they still describe `width_mm`, `height_mm` and
/// `landscape`, so each program's convention for media defined wider than tall survives a
/// round trip: AutoCAD stores a portrait size and a quarter turn, ZWCAD the media's own size
/// and no turn (#1463). `None` for page setups made in CADCraft.
#[serde(skip)]
pub file_paper: Option<(f64, f64, i64)>,
/// The paper image origin (DXF groups 148/149, in plot paper units) as the file had it, so a
/// save keeps where the sheet sits; `None` for a page CADCraft made, written as 0,0 (#2109).
#[serde(skip_serializing_if = "Option::is_none")]
@@ -831,7 +824,6 @@ impl Default for PageSetup {
show_plot_styles: false,
plot_transparency: false,
paper_mm: false,
file_paper: None,
image_origin: None,
}
}
+8 -363
View File
@@ -65,7 +65,7 @@ fn mb(n: u64) -> String {
#[cfg(not(no_dwg_bridge))]
pub mod worker;
#[cfg(not(no_dwg_bridge))]
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker};
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker};
#[cfg(not(no_dwg_bridge))]
mod native {
@@ -652,7 +652,7 @@ mod native {
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
/// (AC1018).
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
convert(dxf, None, None).map(|(dwg, _)| dwg)
convert(dxf, None).map(|(dwg, _)| dwg)
}
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
@@ -665,32 +665,7 @@ mod native {
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
convert(dxf, Some(writable(version)?), None)
}
/// Like [`dxf_to_dwg_as`], for a DXF that declares an older version than the groups it carries
/// over from another file. CADCraft's R2000 DWG bridge holds the objects it keeps without
/// modelling them (render settings, fields, associative and dynamic block data) as they were
/// read from a file of version `kept` ("AC1032"), and acadrust reads several of those
/// differently by version: read as R2000, an AC1032 file's mental ray render settings came out
/// a class version too high, for which the ODA rejects the whole DWG, and a field lost its
/// cached value. So the DXF is read as `kept` declares. A `kept` version that is unknown, or
/// not newer than the DXF's own, changes nothing.
pub fn dxf_to_dwg_kept_as(dxf: &[u8], version: &str, kept: &str) -> Result<Vec<u8>, String> {
dxf_to_dwg_kept_noted(dxf, version, kept).map(|(dwg, _)| dwg)
}
/// [`dxf_to_dwg_kept_as`], also returning what the DWG writer left out, as
/// [`dxf_to_dwg_noted`] does.
pub fn dxf_to_dwg_kept_noted(dxf: &[u8], version: &str, kept: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
let kept = acadrust::DxfVersion::from_version_string(kept.trim().to_ascii_uppercase().as_str());
let kept = (kept != acadrust::DxfVersion::Unknown).then_some(kept);
convert(dxf, Some(writable(version)?), kept)
}
/// The DWG version for a code from [`super::VERSIONS`].
fn writable(version: &str) -> Result<acadrust::DxfVersion, String> {
Ok(match version.trim().to_ascii_uppercase().as_str() {
let v = match version.trim().to_ascii_uppercase().as_str() {
"AC1015" => acadrust::DxfVersion::AC1015,
"AC1018" => acadrust::DxfVersion::AC1018,
"AC1021" => acadrust::DxfVersion::AC1021,
@@ -698,72 +673,8 @@ mod native {
"AC1027" => acadrust::DxfVersion::AC1027,
"AC1032" => acadrust::DxfVersion::AC1032,
_ => return Err(super::unsupported(version)),
})
}
/// The ASCII DXF with its `$ACADVER` set to `kept` when that is newer than the version it
/// declares, or `None` to read it as it is (binary, no `$ACADVER`, or non-ASCII text without a
/// `$DWGCODEPAGE` to decode it by). From AutoCAD 2007 (AC1021) a DXF is UTF-8, so other bytes
/// are first decoded with the code page the DXF declares; `\U+XXXX` escapes read the same in
/// every version, and CADCraft's R2000 DXF is ASCII with such escapes.
pub(super) fn declare_version(dxf: &[u8], kept: acadrust::DxfVersion) -> Option<Vec<u8>> {
if dxf.starts_with(b"AutoCAD Binary DXF") {
return None;
}
let (_, declared) = header_value(dxf, b"$ACADVER")?;
let declared = acadrust::DxfVersion::from_version_string(std::str::from_utf8(declared).ok()?);
if declared >= kept {
return None;
}
let text: std::borrow::Cow<'_, [u8]> = if dxf.is_ascii() || kept < acadrust::DxfVersion::AC1021 {
std::borrow::Cow::Borrowed(dxf)
} else {
// As acadrust decodes an older DXF: by its code page, Windows-1252 for one it doesn't
// know; a code page that names UTF-8 needs no decoding.
let (_, code_page) = header_value(dxf, b"$DWGCODEPAGE")?;
match acadrust::io::dxf::code_page::encoding_from_code_page(std::str::from_utf8(code_page).ok()?) {
Some(encoding) => {
let (text, errors) = encoding.decode_without_bom_handling(dxf);
if errors {
return None;
}
std::borrow::Cow::Owned(text.into_owned().into_bytes())
}
None => std::borrow::Cow::Borrowed(std::str::from_utf8(dxf).ok()?.as_bytes()),
}
};
let (at, value) = header_value(&text, b"$ACADVER")?;
let code = kept.to_string();
let mut out = Vec::with_capacity(text.len().saturating_add(code.len()));
out.extend_from_slice(text.get(..at)?);
out.extend_from_slice(code.as_bytes());
out.extend_from_slice(text.get(at.checked_add(value.len())?..)?);
Some(out)
}
/// The byte offset and the trimmed value of header variable `name` in an ASCII DXF: the value
/// of the group that follows the `9` group naming it. The file is read as (group code,
/// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end
/// of the HEADER section: a large input without one costs time, not memory (a line index of
/// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`.
fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
let mut offset = 0usize;
let mut lines = dxf.split(|b| *b == b'\n').map(|line| {
let at = offset.saturating_add(line.len() - line.trim_ascii_start().len());
offset = offset.saturating_add(line.len()).saturating_add(1);
(at, line.trim_ascii())
});
while let (Some((_, code)), Some((_, value))) = (lines.next(), lines.next()) {
if code == b"0" && value == b"ENDSEC" {
return None;
}
if code == b"9" && value == name {
let (_, code) = lines.next()?;
let (at, value) = lines.next()?;
return (code != b"0" && code != b"9").then_some((at, value));
}
}
None
convert(dxf, Some(v))
}
/// The current dimension and text styles' names from their handles: a DWG stores the
@@ -800,141 +711,6 @@ mod native {
}
}
/// The DXF names of an ASCII DXF's CLASS records, in CLASSES order: a proxy's class number
/// (group 91) is 500 plus its class's place here (DXF Reference, ACAD_PROXY_OBJECT). Empty for
/// one without a CLASSES section. `None` for a binary DXF, whose classes this doesn't read:
/// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public
/// conversion functions comes here.
pub(super) fn dxf_class_names(dxf: &[u8]) -> Option<Vec<String>> {
let mut names = Vec::new();
if dxf.starts_with(b"AutoCAD Binary DXF") {
return None;
}
let mut lines = dxf.split(|b| *b == b'\n').map(|l| l.trim_ascii());
let (mut in_classes, mut in_class) = (false, false);
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
match (code, value) {
(b"0", b"ENDSEC") if in_classes => break,
(b"2", b"CLASSES") => in_classes = true,
(b"0", v) if in_classes => {
in_class = v == b"CLASS";
// Every record keeps its place, named or not: a proxy numbers its class by
// place, so a record without a name must not move the ones after it. An empty
// name matches no class, and such a proxy is left out.
if in_class {
names.push(String::new());
}
}
(b"1", v) if in_class => {
if let Some(last) = names.last_mut() {
*last = String::from_utf8_lossy(v).into_owned();
}
in_class = false;
}
_ => {}
}
}
Some(names)
}
/// Proxy objects' and entities' class numbers as the DWG numbers its classes. A DXF proxy
/// names its class by its place in the DXF's CLASSES (`classes`, from 500), but acadrust's
/// reader puts its own default classes first and numbers the DXF's after them, so the DXF
/// number named another class in the DWG: a 2018 drawing's piping proxies became
/// LARGE_RADIAL_DIMENSION and SECTIONOBJECT, and the ODA File Converter refused the file. A
/// proxy whose number names no class of the DXF is left out: kept as it is, its number would
/// name some other class of the DWG, for the same reason.
///
/// Before 2013 (AC1027) acadrust writes only the classes it knows a pre-2013 DWG needs, and a
/// proxy's class is not one of them: the proxy would name a missing class (the ODA refuses
/// the file: "No ClassId") or, by its old number, some other class. Such a DWG leaves the
/// proxies out instead, as it does other programs' data it can't write (`not_saved` reports
/// the entities). So does a DXF whose classes are unknown (`classes` is `None`, a binary
/// DXF): its proxies' numbers can't be mapped, and kept as they are they would name the
/// wrong classes and the ODA would refuse the file. Returns the proxies left out, by DXF type, for
/// what the DWG writer left out ([`super::Unwritten`]).
fn proxy_classes(doc: &mut acadrust::CadDocument, classes: Option<&[String]>) -> super::Unwritten {
let classes = match classes {
Some(c) if doc.version >= acadrust::DxfVersion::AC1027 => c,
_ => {
let entities: Vec<acadrust::Handle> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Extended(x) if matches!(x.data, acadrust::entities::ExtendedEntityData::Proxy(_)) => {
Some(x.common.handle)
}
_ => None,
})
.collect();
let objects: Vec<acadrust::Handle> =
doc.objects.iter().filter_map(|(h, o)| matches!(o, acadrust::objects::ObjectType::ProxyObject(_)).then_some(*h)).collect();
for h in &entities {
doc.remove_entity(*h);
}
for h in &objects {
doc.objects.remove(h);
}
return left_out(objects.len(), entities.len());
}
};
let number = |id: i32| -> Option<i32> {
let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?;
doc.classes.get_by_name(name).map(|c| i32::from(c.class_number))
};
let objects: Vec<(acadrust::Handle, Option<i32>)> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::ProxyObject(p) => Some((*h, number(p.class_id))),
_ => None,
})
.collect();
let entities: Vec<(acadrust::Handle, Option<i32>)> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Extended(x) => match &x.data {
acadrust::entities::ExtendedEntityData::Proxy(p) => Some((x.common.handle, number(p.class_id))),
_ => None,
},
_ => None,
})
.collect();
let unmapped = |list: &[(acadrust::Handle, Option<i32>)]| list.iter().filter(|(_, n)| n.is_none()).map(|(h, _)| *h).collect::<Vec<_>>();
let (gone_objects, gone_entities) = (unmapped(&objects), unmapped(&entities));
for h in &gone_objects {
doc.objects.remove(h);
}
for h in &gone_entities {
doc.remove_entity(*h);
}
let objects = objects.into_iter().filter_map(|(h, n)| Some((h, n?)));
let entities = entities.into_iter().filter_map(|(h, n)| Some((h, n?)));
for (h, n) in objects {
if let Some(acadrust::objects::ObjectType::ProxyObject(p)) = doc.objects.get_mut(&h) {
p.class_id = n;
}
}
let entities: std::collections::HashMap<acadrust::Handle, i32> = entities.into_iter().collect();
for e in doc.entities_mut() {
if let acadrust::entities::EntityType::Extended(x) = e {
let Some(n) = entities.get(&x.common.handle).copied() else { continue };
if let acadrust::entities::ExtendedEntityData::Proxy(p) = &mut x.data {
p.class_id = n;
}
}
}
left_out(gone_objects.len(), gone_entities.len())
}
/// Proxy objects and entities left out of the DWG, by their DXF types.
fn left_out(objects: usize, entities: usize) -> super::Unwritten {
[("ACAD_PROXY_OBJECT", objects), ("ACAD_PROXY_ENTITY", entities)]
.into_iter()
.filter(|(_, n)| *n > 0)
.map(|(t, n)| (t.to_string(), n))
.collect()
}
/// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG
/// stores the handle of the layer, and the one read with the DXF needn't be the layer's in
/// the document, so the drawing reopened with layer "0" current (#2252).
@@ -946,8 +722,7 @@ mod native {
}
}
/// `kept`: the version the DXF's carried-over groups were read from ([`dxf_to_dwg_kept_as`]).
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>, kept: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
@@ -959,19 +734,7 @@ mod native {
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
// Decoding a code page to UTF-8 can grow the text, so the limit applies again.
let data = match kept.and_then(|k| declare_version(dxf, k)) {
Some(d) if d.len() > Limits::DEFAULT.max_dxf_bytes => {
return Err(format!(
"DXF: the input is {} as UTF-8, larger than the {} CADCraft converts to DWG",
mb(d.len() as u64),
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
Some(d) => d,
None => dxf.to_vec(),
};
let classes = dxf_class_names(&data);
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
.map_err(|e| format!("DXF: {e}"))?;
@@ -986,15 +749,10 @@ mod native {
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
let unwritten = guarded(AssertUnwindSafe(|| {
super::fail_in_test("convert");
let proxies = proxy_classes(&mut doc, classes.as_deref());
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
let mut unwritten = drop_unwritable(&mut doc);
for (kind, n) in proxies {
let total = unwritten.entry(kind).or_insert(0);
*total = total.saturating_add(n);
}
let unwritten = drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
@@ -1162,9 +920,7 @@ mod native {
}
#[cfg(not(no_dwg_bridge))]
pub use native::{
dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_kept_as, dxf_to_dwg_kept_noted, dxf_to_dwg_noted,
};
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_noted};
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
/// can't write.
@@ -1219,13 +975,6 @@ pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
dxf_to_dwg_noted(dxf, version)
}
#[cfg(no_dwg_bridge)]
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, Unwritten), String> {
match kept {
Some(kept) => dxf_to_dwg_kept_noted(dxf, version, kept),
None => dxf_to_dwg_noted(dxf, version),
}
}
#[cfg(no_dwg_bridge)]
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
@@ -1251,14 +1000,6 @@ pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg_kept_as(_dxf: &[u8], _version: &str, _kept: &str) -> Result<Vec<u8>, String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg_kept_noted(_dxf: &[u8], _version: &str, _kept: &str) -> Result<(Vec<u8>, Unwritten), String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(test)]
mod tests {
@@ -1544,102 +1285,6 @@ mod tests {
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
}
/// An R2000 DXF header (`\r\n` lines, as CADCraft writes them) with `extra` after `$ACADVER`.
#[cfg(not(no_dwg_bridge))]
fn r2000(extra: &[u8]) -> Vec<u8> {
let mut dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n 9\r\n$DWGCODEPAGE\r\n 3\r\nANSI_1252\r\n".to_vec();
dxf.extend_from_slice(b" 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\n");
dxf.extend_from_slice(extra);
dxf.extend_from_slice(b"\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n");
dxf
}
/// The kept version replaces only the `$ACADVER` value; everything else stays byte for byte.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_changes_only_acadver() {
let dxf = r2000(b"A\\U+00E7\\U+00E3o");
let out = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), String::from_utf8(dxf).unwrap().replacen("AC1015", "AC1032", 1));
}
/// A kept version not newer than the declared one, a binary DXF and a DXF without `$ACADVER`
/// are read as they are.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_leaves_what_it_need_not_change() {
let dxf = r2000(b"x");
assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1015), None);
assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1009), None);
assert_eq!(native::declare_version(b"AutoCAD Binary DXF\r\n\x1a\0", acadrust::DxfVersion::AC1032), None);
let no_version = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
assert_eq!(native::declare_version(no_version, acadrust::DxfVersion::AC1032), None);
// A `$ACADVER` outside the HEADER section is not the file's version.
let late = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n";
assert_eq!(native::declare_version(late, acadrust::DxfVersion::AC1032), None);
}
/// The header is read as group code and value pairs up to the first `0`/`ENDSEC`: a `$ACADVER`
/// name whose value would come after it, or a file that never ends its HEADER, is no version
/// to replace, and `\r\n` lines keep the value's offset right.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_reads_the_header_in_pairs_up_to_endsec() {
let cut = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 0\r\nENDSEC\r\n 1\r\nAC1015\r\n";
assert_eq!(native::declare_version(cut, acadrust::DxfVersion::AC1032), None);
let endless = vec![b'\n'; 1 << 20];
assert_eq!(native::declare_version(&endless, acadrust::DxfVersion::AC1032), None);
let crlf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1015\r\n 0\r\nENDSEC\r\n";
let out = native::declare_version(crlf, acadrust::DxfVersion::AC1032).unwrap();
assert_eq!(out, b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1032\r\n 0\r\nENDSEC\r\n");
}
/// Declared as 2007 or later, a DXF is UTF-8: code page text is decoded first. Declared as an
/// older version, the code page bytes stay as they are.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_decodes_a_code_page_for_utf8_versions() {
let dxf = r2000(b"A\xe7\xe3o");
let utf8 = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap();
let text = String::from_utf8(utf8).unwrap();
assert!(text.contains("\r\nAção\r\n") && text.contains("$ACADVER\r\n 1\r\nAC1032\r\n"), "{text}");
let legacy = native::declare_version(&dxf, acadrust::DxfVersion::AC1018).unwrap();
assert!(legacy.windows(4).any(|w| w == b"A\xe7\xe3o"), "the code page bytes stay");
assert!(legacy.windows(6).any(|w| w == b"AC1018"));
// A code page it doesn't know is read as Windows-1252, as acadrust reads an older DXF.
let at = dxf.windows(9).position(|w| w == b"ANSI_1252").unwrap();
let unknown = [&dxf[..at], b"NO_SUCH_CP".as_slice(), &dxf[at + 9..]].concat();
let text = String::from_utf8(native::declare_version(&unknown, acadrust::DxfVersion::AC1032).unwrap()).unwrap();
assert!(text.contains("\r\nAção\r\n"), "{text}");
// Without a `$DWGCODEPAGE` there is nothing to decode it by: it is read as it is.
let none = [&dxf[..at - 21], &dxf[at + 11..]].concat();
assert!(!none.windows(12).any(|w| w == b"$DWGCODEPAGE"), "{}", String::from_utf8_lossy(&none));
assert_eq!(native::declare_version(&none, acadrust::DxfVersion::AC1032), None);
}
/// An unknown kept version converts as [`dxf_to_dwg_as`] does.
#[cfg(not(no_dwg_bridge))]
#[test]
fn unknown_kept_version_converts_as_before() {
let dxf = r2000(b"x");
assert_eq!(dxf_to_dwg_kept_as(&dxf, "AC1032", "AC9999"), dxf_to_dwg_as(&dxf, "AC1032"));
assert!(dxf_to_dwg_kept_as(&dxf, "AC0000", "AC1032").is_err(), "an unwritable version is still an error");
}
/// The CLASS names in CLASSES order; nothing for a binary DXF or one without CLASSES.
#[cfg(not(no_dwg_bridge))]
#[test]
fn dxf_class_names_in_classes_order() {
let dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 1\r\nWIDGETA\r\n 2\r\nAcDbWidgetA\r\n 3\r\nApp\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 2\r\nAcDbWidgetB\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\nNOT_A_CLASS\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
assert_eq!(native::dxf_class_names(dxf).unwrap(), ["WIDGETA", "WIDGETB"]);
// A CLASS record without a name keeps its place, so the classes after it keep theirs
// (#2837 review).
let unnamed = b" 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 2\r\nAcDbNoName\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
assert_eq!(native::dxf_class_names(unnamed).unwrap(), ["", "WIDGETB"]);
assert_eq!(native::dxf_class_names(b" 0\nSECTION\n 2\nHEADER\n 0\nENDSEC\n 0\nEOF\n"), Some(Vec::new()));
assert_eq!(native::dxf_class_names(b"AutoCAD Binary DXF\r\n\x1a\0"), None);
}
/// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three
/// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD
/// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales
+5 -50
View File
@@ -26,7 +26,7 @@ use super::Limits;
/// The argument that makes the app a worker.
pub const WORKER_ARG: &str = "--dwg-worker";
/// Bumped when the messages change: an app updated while running may start a newer copy.
const PROTOCOL: u8 = 2;
const PROTOCOL: u8 = 1;
const DECODE: u8 = 1;
const ENCODE: u8 = 2;
const CRASH: u8 = 0xff;
@@ -88,29 +88,11 @@ pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
/// back as notes of `TYPE<tab>count`.
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
encode_kept_noted(dxf, version, None)
}
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
// A kept version too long to send is no version CADCraft knows: it changes nothing.
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
let mut header = request(ENCODE, Limits::DEFAULT, version);
push_str(&mut header, kept.unwrap_or(""));
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&notes))),
None => encode_here(dxf, version, kept),
}
}
/// The conversion in this process.
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
match kept {
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
None => super::dxf_to_dwg_noted(dxf, version),
}
}
@@ -135,16 +117,11 @@ fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
let mut r = vec![PROTOCOL, op];
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
push_str(&mut r, version);
r.push(u8::try_from(version.len()).unwrap_or(0));
r.extend(version.as_bytes());
r
}
/// `text` with a one-byte length before it; callers keep it under 256 bytes.
fn push_str(r: &mut Vec<u8>, text: &str) {
r.push(u8::try_from(text.len()).unwrap_or(0));
r.extend(text.as_bytes());
}
type Reply = Result<(Vec<u8>, Vec<String>), String>;
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
@@ -280,13 +257,7 @@ fn handle(mut b: &[u8]) -> Reply {
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
match op {
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
ENCODE => {
// The version the DXF's carried-over groups were read from; empty for none.
let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
encode_here(b, version, (!kept.is_empty()).then_some(kept))
.map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect()))
}
ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
CRASH => std::process::abort(),
_ => Err(bad()),
}
@@ -351,22 +322,6 @@ mod tests {
}
}
/// An encode request carries the kept version after the target one; a request without it is
/// one it can't read, and one with it reaches the converter.
#[test]
fn an_encode_request_carries_the_kept_version() {
let bad = "the DWG worker got a request it can't read (a different CADCraft version?)";
let short = self::request(ENCODE, Limits::DEFAULT, "AC1032");
assert_eq!(handle(&short).unwrap_err(), bad);
for kept in ["", "AC1032"] {
let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032");
push_str(&mut request, kept);
request.extend(b"not a drawing");
let err = handle(&request).unwrap_err();
assert_ne!(err, bad, "kept {kept:?}");
}
}
#[test]
fn serving_reports_a_decode_failure_as_a_reply() {
let mut request = self::request(DECODE, Limits::DEFAULT, "");
+10 -78
View File
@@ -129,7 +129,7 @@ pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
/// line (the group code `3` between them), looked for in the HEADER section only (the scan stops
/// at the first `ENDSEC`). Works on the raw bytes, before they are decoded.
fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
let mut lines = bytes.split(|&b| b == b'\n' || b == b'\r').map(<[u8]>::trim_ascii).filter(|l| !l.is_empty());
let mut lines = bytes.split(|&b| b == b'\n').map(<[u8]>::trim_ascii);
while let Some(line) = lines.next() {
if line == b"ENDSEC" {
return None;
@@ -144,35 +144,10 @@ fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
None
}
impl Decoder {
/// True for the multi-byte East Asian code pages (Shift-JIS, GBK, EUC-KR/Wansung, Big5).
/// Their short double-byte strings are often also valid UTF-8, so UTF-8 is only trusted for
/// a whole file there (see [`decode_ascii_file`] and [`BinaryText`]).
fn is_east_asian(self) -> bool {
use encoding_rs as e;
matches!(self, Decoder::Encoding(x) if x == e::SHIFT_JIS || x == e::GBK || x == e::EUC_KR || x == e::BIG5)
}
}
/// Decode one line of an ASCII DXF file: valid UTF-8 is read as UTF-8 (R2007+ files, pure-ASCII
/// text, and older files from writers that ignored the code page); anything else comes from the
/// code page `d`, so one stray byte spoils one line, not the whole file. Binary strings don't use
/// this rule: a pre-R2007 binary file has no way to escape to UTF-8, so its strings are read
/// from the code page (see [`BinaryText::string`]).
pub(crate) fn decode_text(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
match std::str::from_utf8(bytes) {
Ok(s) => Cow::Borrowed(s),
Err(_) => decode(bytes, d),
}
}
/// Decode a whole ASCII DXF file. A file that is valid UTF-8 throughout is UTF-8. Otherwise, for
/// a single-byte code page, each line is decoded on its own (see [`decode_text`]): lines that
/// aren't valid UTF-8 come from the file's `$DWGCODEPAGE`, or from Windows-1252 (the usual
/// `ANSI_1252`) when the header has none or names a code page CADCraft doesn't know, which is
/// logged as a warning. For a multi-byte East Asian code page the whole file is decoded from
/// that code page, because a short double-byte string (two half-width katakana, many kanji
/// pairs) can be valid UTF-8 by accident and must not be misread line by line.
/// Decode a whole ASCII DXF file. Valid UTF-8 is read as UTF-8: R2007+ files, pure-ASCII files,
/// and older files from writers that ignored the code page. Anything else is decoded from the
/// file's `$DWGCODEPAGE`, or from Windows-1252 (the usual `ANSI_1252`) when the header has none
/// or names a code page CADCraft doesn't know, which is logged as a warning.
pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
if let Ok(s) = std::str::from_utf8(bytes) {
return Cow::Borrowed(s);
@@ -187,19 +162,10 @@ pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
}
None => Decoder::Utf8,
};
if d.is_east_asian() {
return decode(bytes, d);
}
// Split after each line terminator (CR, LF or CRLF are all ASCII, so they never occur inside
// a multi-byte character of the supported code pages) and decode each line on its own.
let mut out = String::with_capacity(bytes.len());
for line in bytes.split_inclusive(|&b| b == b'\n' || b == b'\r') {
out.push_str(&decode_text(line, d));
}
Cow::Owned(out)
decode(bytes, d)
}
/// The text state of a binary DXF file, learnt from the header as the tags go by: `$ACADVER`
/// The text state of a binary DXF file, learnt from its header as the tags go by: `$ACADVER`
/// (R2007+ strings are UTF-8) and `$DWGCODEPAGE` (the code page of older files). Both come
/// first in the HEADER section and are ASCII, so every later string is decoded right.
#[derive(Default)]
@@ -208,12 +174,6 @@ pub(crate) struct BinaryText {
var: Option<HeaderVar>,
utf8: bool,
code_page: Option<Decoder>,
/// Decode every string of an East Asian code page file from the code page, never as UTF-8
/// (the second pass of [`BinaryText::needs_code_page_pass`]).
code_page_only: bool,
/// Non-ASCII strings seen under an East Asian code page: valid UTF-8 / not valid UTF-8.
ea_utf8: bool,
ea_other: bool,
}
#[derive(Clone, Copy, PartialEq)]
@@ -223,40 +183,12 @@ enum HeaderVar {
}
impl BinaryText {
/// The state for the second pass over a file whose strings are a mix of valid UTF-8 and
/// not: every string of an East Asian code page is decoded from that code page.
pub(crate) fn code_page_pass() -> Self {
Self { code_page_only: true, ..Self::default() }
}
/// True when the file declared a multi-byte East Asian code page and its non-ASCII strings are
/// not all valid UTF-8 although some are: those can be short double-byte strings that look
/// like UTF-8 by accident, so the file must be parsed again with [`Self::code_page_pass`]. (A
/// file whose strings are all valid UTF-8 is UTF-8; one with none is plainly the code page.)
pub(crate) fn needs_code_page_pass(&self) -> bool {
!self.code_page_only && self.ea_utf8 && self.ea_other
}
/// Decode one string value of group `code` and update the state from it. Strings of R2007+
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); a
/// pre-R2007 file's strings are in its code page, which a binary DXF cannot escape to, so
/// they are read from the code page even when their bytes are valid UTF-8 by accident —
/// except under an East Asian code page, where a file of all-valid-UTF-8 strings is taken
/// as UTF-8 (see [`Self::needs_code_page_pass`]).
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); older
/// files' strings are in their code page.
pub(crate) fn string(&mut self, code: i32, raw: &[u8]) -> String {
let d = if self.utf8 { Decoder::Utf8 } else { self.code_page.unwrap_or(Decoder::Utf8) };
let s = if d.is_east_asian() && !self.code_page_only {
if !raw.is_ascii() {
if std::str::from_utf8(raw).is_ok() {
self.ea_utf8 = true;
} else {
self.ea_other = true;
}
}
decode_text(raw, d).into_owned()
} else {
decode(raw, d).into_owned()
};
let s = decode(raw, d).into_owned();
match (code, self.var.take()) {
(9, _) => {
self.var = match s.trim() {
+20 -118
View File
@@ -65,9 +65,7 @@ impl Value {
match self {
Value::Int(i) => *i,
Value::Real(r) if r.is_finite() => *r as i64,
Value::Str(s) => {
s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| f as i64))
}
Value::Str(s) => s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().map(|f| f as i64).unwrap_or(0)),
Value::Bool(b) => i64::from(*b),
_ => 0,
}
@@ -159,26 +157,15 @@ fn typed(code: i32, text: &str) -> Value {
match kind_of(code) {
Kind::Real => t.parse::<f64>().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)),
Kind::Int16 | Kind::Int32 | Kind::Int64 => {
// A float spelling ("1e3", "12.0") is accepted when finite, clamped to what the
// group's width holds, signed or unsigned (files write 16-bit flags up to 65535, as
// the integer spelling reads them); NaN, inf and text become 0.
let (lo, hi) = match kind_of(code) {
Kind::Int16 => (i64::from(i16::MIN), i64::from(u16::MAX)),
Kind::Int32 => (i64::from(i32::MIN), i64::from(u32::MAX)),
_ => (i64::MIN, i64::MAX),
};
t.parse::<i64>()
.map(Value::Int)
.unwrap_or_else(|_| Value::Int(t.parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| (f as i64).clamp(lo, hi))))
t.parse::<i64>().map(Value::Int).unwrap_or_else(|_| Value::Int(t.parse::<f64>().map(|f| f as i64).unwrap_or(0)))
}
Kind::Bool => Value::Bool(t.parse::<i64>().unwrap_or(0) != 0),
Kind::Hex => Value::Hex(normalize_hex(t)),
Kind::Hex => Value::Hex(t.to_string()),
Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()),
}
}
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
@@ -214,10 +201,8 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
return parse_binary_with_limit(body, max_tags);
}
let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes);
// Count nonblank group-code/value pairs (LF, CRLF and a lone CR each end a line), stopping at
// EOF just as the parser does. Blank lines do not count toward the limit.
let mut lines = byte_lines(bytes);
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
let mut lines = bytes.split(|&b| b == b'\n');
let mut tags = 0;
while let Some(code) = lines.next() {
let code = code.trim_ascii();
@@ -304,39 +289,6 @@ fn unescape_unicode(s: &str) -> String {
out
}
/// The lines of an ASCII DXF text: LF, CRLF and a lone CR (old Mac files) all end a line, like
/// [`str::lines`] otherwise (no empty line after a final terminator).
fn ascii_lines(text: &str) -> impl Iterator<Item = &str> {
let mut rest = text;
std::iter::from_fn(move || {
if rest.is_empty() {
return None;
}
let end = rest.find(['\r', '\n']).unwrap_or(rest.len());
let line = rest.get(..end).unwrap_or_default();
let after = rest.get(end..).unwrap_or_default();
rest = after.strip_prefix("\r\n").or_else(|| after.strip_prefix(['\r', '\n'])).unwrap_or(after);
Some(line)
})
}
/// Byte-level twin of [`ascii_lines`] for the up-front limit check, so the count matches the parser.
fn byte_lines(mut rest: &[u8]) -> impl Iterator<Item = &[u8]> {
std::iter::from_fn(move || {
if rest.is_empty() {
return None;
}
let end = rest.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(rest.len());
let line = &rest[..end];
let after = &rest[end..];
rest = match after {
[b'\r', b'\n', tail @ ..] | [b'\r' | b'\n', tail @ ..] => tail,
_ => after,
};
Some(line)
})
}
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
parse_ascii_counted(text, max_tags(), 0)
}
@@ -349,7 +301,7 @@ pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
let mut lines = ascii_lines(text.strip_prefix('\u{feff}').unwrap_or(text)).enumerate();
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
loop {
let Some((ln, code_line)) = lines.next() else { break };
let ct = code_line.trim();
@@ -367,7 +319,7 @@ fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<V
if tags.len() > max_tags {
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
}
if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") {
if code == 0 && tags.last().is_some_and(|t| t.str() == "EOF") {
break;
}
}
@@ -433,19 +385,8 @@ pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
}
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
let mut text = codepage::BinaryText::default();
let tags = parse_binary_pass(b, max_tags, &mut text)?;
if !text.needs_code_page_pass() {
return Ok(tags);
}
// The first pass's tags go before the second pass runs: both held at once would double
// the peak near the tag limit.
drop(tags);
parse_binary_pass(b, max_tags, &mut codepage::BinaryText::code_page_pass())
}
fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) -> Result<Vec<Tag>> {
let mut c = Cur { b, p: 0 };
let mut text = codepage::BinaryText::default();
let mut tags = Vec::new();
while c.p < b.len() {
let mut code = i32::from(c.i16()?);
@@ -454,7 +395,7 @@ fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText)
}
let v = match kind_of(code) {
Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))),
Kind::Real => Value::Real(Some(c.f64()?).filter(|v| v.is_finite()).unwrap_or(0.0)),
Kind::Real => Value::Real(c.f64()?),
Kind::Int16 => Value::Int(i64::from(c.i16()?)),
Kind::Int32 => Value::Int(i64::from(c.i32()?)),
Kind::Int64 => Value::Int(c.i64()?),
@@ -469,7 +410,7 @@ fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText)
Value::Hex(s)
}
};
let eof = code == 0 && matches!(&v, Value::Str(s) if s.trim_end() == "EOF");
let eof = code == 0 && matches!(&v, Value::Str(s) if s == "EOF");
tags.push(Tag { code, value: v });
if tags.len() > max_tags {
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
@@ -544,22 +485,7 @@ fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
Value::Hex(h) => {
// The DXF Reference limits a binary chunk line to 254 hex digits (127 bytes);
// longer data continues in further groups of the same code. Only hex digits are
// written, so a hostile value can't inject tags.
let digits = normalize_hex(h);
let mut chunks: Vec<&str> = digits.as_bytes().chunks(254).map(|c| std::str::from_utf8(c).unwrap_or_default()).collect();
if chunks.is_empty() {
chunks.push("");
}
for (n, chunk) in chunks.into_iter().enumerate() {
if n > 0 {
let _ = write!(out, "\r\n{:>3}\r\n", t.code);
}
out.extend_from_slice(chunk.as_bytes());
}
}
Value::Hex(h) => out.extend_from_slice(h.as_bytes()),
}
out.extend_from_slice(b"\r\n");
}
@@ -607,11 +533,13 @@ fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
}
Kind::Int16 => {
out.extend_from_slice(&code_bytes);
out.extend_from_slice(&wrap_i16(t.value.as_i64()));
let v = t.value.as_i64().clamp(i64::from(i16::MIN), i64::from(i16::MAX));
out.extend_from_slice(&i16::try_from(v).unwrap_or_default().to_le_bytes());
}
Kind::Int32 => {
out.extend_from_slice(&code_bytes);
out.extend_from_slice(&wrap_i32(t.value.as_i64()));
let v = t.value.as_i64().clamp(i64::from(i32::MIN), i64::from(i32::MAX));
out.extend_from_slice(&i32::try_from(v).unwrap_or_default().to_le_bytes());
}
Kind::Int64 => {
out.extend_from_slice(&code_bytes);
@@ -639,38 +567,12 @@ fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
out
}
/// Hex text cleaned up: only the hex digits are kept, and an odd trailing digit is padded with
/// `0` (so the bytes before it are never shifted). Used by the reader and both writers.
fn normalize_hex(s: &str) -> String {
let mut out: String = s.chars().filter(char::is_ascii_hexdigit).collect();
if out.len() % 2 == 1 {
out.push('0');
}
out
}
/// The bytes of hex text, two digits each (see [`normalize_hex`]).
/// The bytes of hex text, two digits each; anything that isn't a pair of hex digits is skipped.
fn hex_bytes(s: &str) -> Vec<u8> {
let digits: Vec<u8> = normalize_hex(s).bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
let digits: Vec<u8> = s.bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
digits.as_chunks::<2>().0.iter().map(|[hi, lo]| (hi << 4) | lo).collect()
}
/// A 16- or 32-bit integer for binary DXF: values that fit the unsigned width wrap into the
/// signed one (65535 is -1, as writers that store unsigned flags expect); anything else clamps.
fn wrap_i16(v: i64) -> [u8; 2] {
match u16::try_from(v) {
Ok(u) => u.to_le_bytes(),
Err(_) => i16::try_from(v.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default().to_le_bytes(),
}
}
fn wrap_i32(v: i64) -> [u8; 4] {
match u32::try_from(v) {
Ok(u) => u.to_le_bytes(),
Err(_) => i32::try_from(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX))).unwrap_or_default().to_le_bytes(),
}
}
/// Whether `bytes` are a binary DXF file (they start with the binary sentinel).
pub fn is_binary(bytes: &[u8]) -> bool {
bytes.starts_with(BINARY_SENTINEL)
@@ -732,12 +634,12 @@ pub fn sections(tags: &[Tag]) -> Vec<Section> {
let mut out = Vec::new();
let mut i = 0;
while i < tags.len() {
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str().trim_end() == "SECTION");
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str() == "SECTION");
if is_sec {
let name = tags.get(i + 1).filter(|t| t.code == 2).map(|t| t.str().trim_end().to_string()).unwrap_or_default();
let name = tags.get(i + 1).filter(|t| t.code == 2).map(Tag::str).unwrap_or_default();
let start = i + 2;
let mut j = start;
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str().trim_end() == "ENDSEC")) {
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str() == "ENDSEC")) {
j += 1;
}
out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() });
-220
View File
@@ -1,220 +0,0 @@
//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding.
use cadcraft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary};
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
fn bin_with(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
let mut b = SENTINEL.to_vec();
f(&mut b);
b.extend_from_slice(&0i16.to_le_bytes());
b.extend_from_slice(b"EOF\0");
b
}
#[test]
fn binary_reals_that_are_not_finite_become_zero() {
for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let b = bin_with(|b| {
b.extend_from_slice(&10i16.to_le_bytes());
b.extend_from_slice(&bits.to_le_bytes());
});
let tags = parse(&b).unwrap();
assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}");
}
}
#[test]
fn ascii_accepts_bom_and_cr_only_line_ends() {
let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")];
let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r";
assert_eq!(parse(text.as_bytes()).unwrap(), want);
let mut bom = b"\xEF\xBB\xBF".to_vec();
bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes());
assert_eq!(parse(&bom).unwrap(), want);
// Mixed terminators.
assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want);
}
#[test]
fn cr_only_files_count_against_the_tag_limit() {
let mut text = String::new();
for _ in 0..50 {
text.push_str("999\rcomment\r");
}
text.push_str("0\rEOF\r");
assert!(parse_with_limit(text.as_bytes(), 10).is_err());
assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51);
}
#[test]
fn one_bad_byte_spoils_one_line_not_the_file() {
// Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte.
let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec();
b.extend_from_slice("Größe".as_bytes());
b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n");
let t = parse(&b).unwrap();
assert_eq!(t[1].str(), "ANSI_1251");
assert_eq!(t[2].str(), "Größe");
assert_eq!(t[3].str(), "Привет");
}
#[test]
fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() {
// ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII
// reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string
// from the code page, because a pre-R2007 binary file has no way to escape to UTF-8.
let utf8 = "Größe".as_bytes();
let latin = b"Caf\xE9";
let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec();
a.extend_from_slice(utf8);
a.extend_from_slice(b"\n1\n");
a.extend_from_slice(latin);
a.extend_from_slice(b"\n0\nEOF\n");
let from_ascii = parse(&a).unwrap();
let b = bin_with(|b| {
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] {
b.extend_from_slice(&code.to_le_bytes());
b.extend_from_slice(s);
b.push(0);
}
});
let from_binary = parse(&b).unwrap();
assert_eq!(from_ascii[2].str(), "Größe");
assert_eq!(from_ascii[3].str(), "Café");
assert_eq!(from_binary[2].str(), "Größe");
assert_eq!(from_binary[3].str(), "Café");
}
#[test]
fn trailing_spaces_keep_sections_and_eof() {
let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap();
// The EOF with a trailing space still ends the file.
assert_eq!(t.len(), 5);
let s = sections(&t);
assert_eq!(s.len(), 1);
assert_eq!(s[0].name, "ENTITIES");
assert_eq!(s[0].tags.len(), 1);
// Binary EOF too.
let mut b = SENTINEL.to_vec();
b.extend_from_slice(&0i16.to_le_bytes());
b.extend_from_slice(b"EOF \0");
b.extend_from_slice(&1i16.to_le_bytes());
b.extend_from_slice(b"after\0");
assert_eq!(parse(&b).unwrap().len(), 1);
}
#[test]
fn integer_groups_reject_non_finite_and_clamp() {
let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap();
assert_eq!(t[0], Tag::i(70, 0));
assert_eq!(t[1], Tag::i(70, 0));
// Clamped to what the group's width holds, unsigned values included (the binary writer
// keeps up to 65535 and 4294967295 as their bit patterns).
assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX)));
assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN)));
assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX)));
assert_eq!(t[5], Tag::i(160, i64::MAX));
assert_eq!(t[6], Tag::i(160, 0));
assert_eq!(t[7], Tag::i(62, 12));
assert_eq!(Value::Str("inf".into()).as_i64(), 0);
}
/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review):
/// `65535.0` is 65535 like `65535`, not 32767.
#[test]
fn unsigned_values_read_the_same_in_either_spelling() {
let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap();
assert_eq!(t[0], t[1]);
assert_eq!(t[0], Tag::i(70, 65535));
assert_eq!(t[2], t[3]);
assert_eq!(t[2], Tag::i(90, 4_294_967_295));
}
#[test]
fn hex_groups_pad_odd_digits_and_drop_junk() {
let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap();
assert_eq!(t[0].value, Value::Hex("ABC0".into()));
assert_eq!(t[1].value, Value::Hex("ABC1".into()));
assert_eq!(t[2].value, Value::Hex(String::new()));
assert_eq!(t[3].value, Value::Hex("0aF0".into()));
// The binary writer agrees with the reader: the odd digit is padded, nothing shifts.
let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap();
assert_eq!(back[0].value, Value::Hex("ABC0".into()));
}
#[test]
fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() {
let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into()));
let text = write_ascii(&[evil, Tag::s(0, "EOF")]);
let back = parse(text.as_bytes()).unwrap();
assert_eq!(back.len(), 2, "{text:?}");
assert_eq!(back[0].value, Value::Hex("AB0EF0".into()));
let long = "AB".repeat(300);
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap();
let sizes: Vec<usize> = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect();
assert_eq!(sizes, vec![254, 254, 92]);
// An empty chunk is still one group.
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap();
assert_eq!(back[0].code, 310);
}
#[test]
fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() {
let tags = [
Tag::i(70, 65535),
Tag::i(70, 40000),
Tag::i(70, 70000),
Tag::i(70, -40000),
Tag::i(90, 0xFFFF_FFFF),
Tag::i(90, 1 << 40),
Tag::i(90, -(1 << 40)),
Tag::s(0, "EOF"),
];
let back = parse(&write_binary(&tags, true)).unwrap();
assert_eq!(back[0], Tag::i(70, -1));
assert_eq!(back[1], Tag::i(70, 40000 - 65536));
assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX)));
assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN)));
assert_eq!(back[4], Tag::i(90, -1));
assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX)));
assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN)));
}
/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file.
fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec<Tag>; 2] {
let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes();
for s in strings {
a.extend_from_slice(b"1\n");
a.extend_from_slice(s);
a.push(b'\n');
}
a.extend_from_slice(b"0\nEOF\n");
let b = bin_with(|b| {
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) {
b.extend_from_slice(&code.to_le_bytes());
b.extend_from_slice(s);
b.push(0);
}
});
[parse(&a).unwrap(), parse(&b).unwrap()]
}
#[test]
fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() {
// C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second
// string is plainly Shift-JIS, so the file is not UTF-8.
for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) {
assert_eq!(t[2].str(), "\u{FF83}\u{FF71}");
assert_eq!(t[3].str(), "図面");
}
}
#[test]
fn east_asian_file_that_is_all_utf8_reads_as_utf8() {
for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) {
assert_eq!(t[2].str(), "図面");
assert_eq!(t[3].str(), "ñ");
assert_eq!(t[4].str(), "plain");
}
}
+9 -74
View File
@@ -40,12 +40,8 @@ fn arc_geom(a: &cadcraft_doc::Arc) -> cadcraft_geom::Arc {
/// Where a snap sits on an object now.
pub fn snap_point(d: &Drawing, a: &DimAssoc) -> Option<Vec2> {
snap_on(d, &d.entity(a.handle)?.kind, &a.snap)
}
/// Where `snap` sits on an object of kind `kind` (an intersection finds its other line in `d`).
fn snap_on(d: &Drawing, kind: &EntityKind, snap: &AssocSnap) -> Option<Vec2> {
match (kind, snap) {
let e = d.entity(a.handle)?;
match (&e.kind, &a.snap) {
(EntityKind::Line(l), AssocSnap::Start) => Some(l.a.xy()),
(EntityKind::Line(l), AssocSnap::End) => Some(l.b.xy()),
(EntityKind::Line(l), AssocSnap::Mid) => Some(l.a.xy().mid(l.b.xy())),
@@ -92,61 +88,6 @@ fn candidates(kind: &EntityKind) -> Vec<(AssocSnap, Vec2)> {
}
}
/// Whether two snaps are the same kind of object point: an endpoint or vertex, a centre, or a
/// midpoint.
fn same_kind(a: &AssocSnap, b: &AssocSnap) -> bool {
matches!(
(a, b),
(AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. }, AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. })
| (AssocSnap::Center, AssocSnap::Center)
| (AssocSnap::Mid, AssocSnap::Mid)
)
}
/// BREAK or TRIM reshaped the object `pieces[0]` (which was `old`) and added the other pieces:
/// move the dimension attachments on it as AutoCAD 2024 does. An attachment whose point is still
/// a point of the same kind on the object stays there (a closed polyline opened by BREAK numbers
/// its vertices from the break); one whose point is on a new piece moves to that piece, so a
/// dimension across a line broken in its middle stays where it was; any other is left as it was,
/// so the dimension follows the object's new end when that end was cut off. Attachments at an
/// angle on a circle or arc and at an intersection stay on the object. [`update`] moves the
/// definition points after the command.
pub fn follow_pieces(d: &mut Drawing, old: &EntityKind, pieces: &[Handle]) {
let Some(&h) = pieces.first() else { return };
let mut changes: Vec<(Handle, Vec<DimAssoc>)> = Vec::new();
for store in std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)) {
for e in store.iter() {
let EntityKind::Dimension(dm) = &e.kind else { continue };
if !dm.assoc.iter().any(|a| a.handle == h) {
continue;
}
let assoc: Vec<DimAssoc> = dm.assoc.iter().map(|a| on_piece(d, old, pieces, a).unwrap_or_else(|| a.clone())).collect();
if assoc != dm.assoc {
changes.push((e.handle, assoc));
}
}
}
for (dh, assoc) in changes {
let _ = d.modify_entity(dh, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.assoc = assoc;
}
});
}
}
/// The piece attachment `a` moves to when its object became `pieces` (see [`follow_pieces`]).
fn on_piece(d: &Drawing, old: &EntityKind, pieces: &[Handle], a: &DimAssoc) -> Option<DimAssoc> {
if pieces.first() != Some(&a.handle) || matches!(a.snap, AssocSnap::OnCircle { .. } | AssocSnap::Intersection { .. }) {
return None;
}
let p = snap_on(d, old, &a.snap)?;
pieces.iter().find_map(|&ph| {
let (snap, _) = candidates(&d.entity(ph)?.kind).into_iter().find(|(s, q)| same_kind(&a.snap, s) && close(p, *q))?;
Some(DimAssoc { point: a.point.clone(), handle: ph, snap })
})
}
/// An object point exactly at `p` (an endpoint, vertex, centre or midpoint) to attach to.
pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSnap)> {
if !p.is_finite() {
@@ -303,10 +244,8 @@ pub fn recompute(dm: &Dimension, prev: Option<&Dimension>, before: &Drawing, aft
let was = prev.unwrap_or(dm);
nd.assoc.retain(|a| {
let Some(old) = def_point(was, &a.point) else { return false };
// Only follow if the point sat on the object before the change, as attached then (BREAK
// and TRIM may have moved the attachment to a piece of the object since).
let held = was.assoc.iter().find(|o| o.point == a.point).unwrap_or(a);
if let Some(prev) = snap_point(before, held)
// Only follow if the point sat on the object before the change.
if let Some(prev) = snap_point(before, a)
&& !close(prev, old.xy())
{
return false;
@@ -365,20 +304,16 @@ pub fn update(after: &mut Arc<Drawing>, before: &Drawing) -> usize {
if dm.assoc.is_empty() {
continue;
}
let prev = match before.entity(e.handle).map(|x| &x.kind) {
Some(EntityKind::Dimension(p)) => Some(p),
_ => None,
};
// Its objects changed, or the command moved an attachment (BREAK and TRIM move them
// to new pieces, which a later pick of the same TRIM may reshape again).
let touched = dm.assoc.iter().any(|a| {
src_changed(before, d, a.handle)
|| matches!(a.snap, AssocSnap::Intersection { other } if src_changed(before, d, other))
|| prev.is_some_and(|p| !p.assoc.contains(a))
src_changed(before, d, a.handle) || matches!(a.snap, AssocSnap::Intersection { other } if src_changed(before, d, other))
});
if !touched {
continue;
}
let prev = match before.entity(e.handle).map(|x| &x.kind) {
Some(EntityKind::Dimension(p)) => Some(p),
_ => None,
};
let nd = recompute(dm, prev, before, d);
if nd != *dm {
changes.push((e.handle, nd));
+2 -9
View File
@@ -248,16 +248,9 @@ fn max_count(s: &Session, others: u64, objs: usize) -> u64 {
}
fn copy_all(s: &mut Session, hs: &[Handle], mats: impl Iterator<Item = Mat3>) -> Result<usize> {
// Every copy is checked before the first is made: a refusal part way would leave the copies
// already added in the drawing (a failed command is not rolled back).
let mats: Vec<Mat3> = mats.collect();
// A matrix every frame accepts needs no look at the objects; otherwise they are looked at once.
if mats.iter().any(|m| !cadcraft_doc::ole_frame_accepts(m)) && super::modify::holds_ole_frame(s, hs, true)? {
return Err(super::modify::ole_refusal());
}
let mut n = 0;
for m in &mats {
n += transform_entities(s, hs, m, true)?.len();
for m in mats {
n += transform_entities(s, hs, &m, true)?.len();
}
Ok(n)
}
+2 -2
View File
@@ -50,7 +50,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
}
/// Load the xrefs of drawing `i`, just opened (OPEN): the opened state counts as saved, and the
/// view frames the drawing with them, unless the drawing opened on the view it was saved at.
/// view frames the drawing with them.
pub(crate) fn after_open(s: &mut Session, i: usize) {
let px = s.viewport_px;
let Some(st) = s.docs.get_mut(i) else { return };
@@ -61,7 +61,7 @@ pub(crate) fn after_open(s: &mut Session, i: usize) {
let out = load::load_all(Arc::make_mut(&mut st.doc), path.as_deref());
st.saved = st.doc.clone();
let ext = st.doc.extents(&cadcraft_doc::Space::Model);
if !ext.is_empty() && crate::view::saved_view(&st.doc).is_none() {
if !ext.is_empty() {
// Fit in the frame of the saved twist, which open_drawing has restored.
let twist = st.view().twist;
let (center, w, h) = crate::View::twisted_box(ext, twist);
+3 -19
View File
@@ -365,12 +365,10 @@ pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option
fn opened(s: &Session, i: usize, entities: bool) -> Value {
let d = s.docs.get(i).map(|st| &st.doc);
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
// Opened on the view it was saved at: a viewer keeps it instead of fitting the extents.
let saved_view = d.is_some_and(|d| crate::view::saved_view(d).is_some());
if entities {
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings, "saved_view": saved_view })
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
} else {
json!({ "index": i, "warnings": warnings, "saved_view": saved_view })
json!({ "index": i, "warnings": warnings })
}
}
@@ -779,10 +777,7 @@ const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
const DWG_WRITES: [&str; 1] = ["OLE2FRAME"];
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`], nor
/// another program's proxy entity whose class the file names: the DWG writes it with that class.
/// Before 2013, or when its group 91 names no class of the file, the DWG leaves the proxy out and
/// it is counted.
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`].
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
@@ -799,21 +794,10 @@ pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap
if DWG_WRITES.contains(&u.dxf_type.as_str()) {
continue;
}
if version >= Version::R2013 && u.dxf_type.eq_ignore_ascii_case("ACAD_PROXY_ENTITY") && d.foreign.proxy_class(&u.tags).is_some() {
continue;
}
let n = out.entry(u.dxf_type.clone()).or_insert(0);
*n = n.saturating_add(1);
}
}
// Another program's proxy objects (in the OBJECTS section) follow the same rule as its proxy
// entities: the DWG writer leaves them out before 2013, or when their class can't be named.
for o in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_PROXY_OBJECT")) {
if version < Version::R2013 || d.foreign.proxy_class(&o.tags).is_none() {
let n = out.entry("ACAD_PROXY_OBJECT".to_string()).or_insert(0);
*n = n.saturating_add(1);
}
}
out
}
+4 -27
View File
@@ -786,28 +786,10 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
// Checked here for a clear error; the plotter resolves the names the same way.
let sheets = p.get("layouts").map(|v| plot_sheets(cmd, s.doc()?, v)).transpose()?;
// `layout` and `layouts` must name the same sheet(s), or the reply would name one sheet and the render another.
if let (Some(n), Some(names)) = (str_param(p, "layout"), sheets.as_deref())
&& !matches!(names, [only] if only.eq_ignore_ascii_case(n))
{
return Err(bad(cmd, "`layout` and `layouts` name different sheets; give only one of them"));
}
let is_image = p.get("output").and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("png") || o.eq_ignore_ascii_case("svg"));
let space = match str_param(p, "layout") {
Some(n) if n.eq_ignore_ascii_case("model") => Space::Model,
Some(n) => Space::Paper(layout_name(cmd, s.doc()?, n)?),
// A PNG or SVG holds one render: a single `layouts` entry names the sheet to draw (the PDF
// path resolves `layouts` itself), not whatever space the session happens to show.
None => match sheets.as_deref() {
Some([only]) if is_image => {
if only.eq_ignore_ascii_case("model") {
Space::Model
} else {
Space::Paper(only.clone())
}
}
_ => s.space(),
},
None => s.space(),
};
let mut opts = if p.is_object() { p.clone() } else { json!({}) };
if let Some(raw) = p.get("offset") {
@@ -846,17 +828,12 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
None => o.remove("plotTransparency"),
};
}
// A PNG or SVG holds one render: several sheets would report `pages: N` but write only one.
if is_image && sheets.as_ref().is_some_and(|names| names.len() > 1) {
return Err(bad(cmd, "`layouts` with several sheets needs PDF output; PNG and SVG hold one sheet, so plot each layout separately"));
}
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
let layout = match &sheets {
Some(names) => names.first().cloned().unwrap_or_default(),
None => space.tab_name().to_string(),
};
let pages = sheets.as_ref().map_or(1, Vec::len);
let format = opts.get("output").and_then(Value::as_str).map(str::to_ascii_lowercase).unwrap_or_else(|| "pdf".into());
match str_param(p, "path") {
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
@@ -866,17 +843,17 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
let folder = super::file::new_folder(path);
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format });
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages });
super::file::report_new_folder(s, &mut r, folder);
Ok(r)
}
#[cfg(target_arch = "wasm32")]
{
super::exchange::deliver_file(cmd, _path, &bytes, super::file::deliver())?;
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format }))
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
}
}
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format })),
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages })),
}
}
-65
View File
@@ -230,24 +230,6 @@ fn plot_goes_through_the_hook() {
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
assert!(s.execute("plot", &json!({"layout": "Model", "scale": -1})).is_err());
// Image output holds one sheet: several `layouts` would report pages:N but write one render.
for out in ["x.png", "x.svg"] {
let e = s.execute("plot", &json!({"path": out, "layouts": ["Layout1", "Layout2"]})).unwrap_err();
assert!(e.to_string().contains("layouts"), "{e}");
}
assert!(s.execute("plot", &json!({"output": "png", "layouts": ["Layout1", "Layout2"]})).is_err());
// `layout` and `layouts` naming different sheets are refused (the reply would name one sheet and the render another); agreeing ones pass.
for p in
[json!({"output": "png", "layout": "Model", "layouts": ["Layout1"]}), json!({"output": "pdf", "layout": "Layout1", "layouts": ["Model"]})]
{
let e = s.execute("plot", &p).unwrap_err().to_string();
assert!(e.contains("different"), "{e}");
}
assert_eq!(s.execute("plot", &json!({"output": "png", "layout": "layout1", "layouts": ["Layout1"]})).unwrap()["layout"], "Layout1");
let one = s.execute("plot", &json!({"output": "png", "layouts": ["Layout1"]})).unwrap();
assert_eq!(one["layout"], "Layout1");
let text = String::from_utf8(super::super::file::base64_decode(one["data"].as_str().unwrap()).unwrap()).unwrap();
assert!(text.contains("Paper(\"Layout1\")"), "one sheet renders that layout, not the session space: {text}");
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id()));
@@ -325,50 +307,3 @@ fn plot_help_names_the_device_and_the_built_in_devices() {
assert!(help.contains(name), "plot help lacks {name}");
}
}
/// The path's extension picks the plot format as EXPORT does, in any case; one that names no
/// format (or none) is refused instead of writing a PDF under that name, and a device that writes
/// another format is refused.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn plot_path_extension_picks_the_format_like_export() {
super::super::file::set_io(super::super::file::IoHooks {
read: |_, _| Err("no reader in tests".into()),
write: |_, _| Err("no writer in tests".into()),
plot: Some(fake_plot),
});
if io().and_then(|h| h.plot).is_none() {
return; // another test installed hooks without a plotter first
}
let mut s = session_with_model();
let dir = std::env::temp_dir().join(format!("cadcraft-plot-ext-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
let upper = s.execute("plot", &json!({"path": path("X.PNG")})).unwrap();
assert_eq!(upper["format"], "png", "an upper-case extension still picks png");
let e = s.execute("plot", &json!({"path": path("x.txt")})).unwrap_err().to_string();
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
assert!(!dir.join("x.txt").exists(), "nothing written under an unknown extension");
let e = s.execute("plot", &json!({"path": path("noext")})).unwrap_err().to_string();
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
assert!(!dir.join("noext").exists());
let e = s.execute("plot", &json!({"path": path("y.png"), "device": "CADCraft PDF"})).unwrap_err().to_string();
assert!(e.contains("writes pdf") && e.contains("png"), "{e}");
// A device that resolves to none, or a null/unknown `output`, names no format: the refusal stands.
for p in [
json!({"path": path("x.txt"), "device": "None"}),
json!({"path": path("x.txt"), "output": null}),
json!({"path": path("x.txt"), "output": "bogus"}),
] {
let e = s.execute("plot", &p).unwrap_err().to_string();
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
assert!(!dir.join("x.txt").exists(), "nothing written under an unknown extension");
}
// An explicit output still decides for an odd name.
assert_eq!(s.execute("plot", &json!({"path": path("z.txt"), "output": "pdf"})).unwrap()["format"], "pdf");
// But one that contradicts the extension is refused, as a device that does is (#2585 review).
let e = s.execute("plot", &json!({"path": path("w.png"), "output": "svg"})).unwrap_err().to_string();
assert!(e.contains("svg") && e.contains("png"), "{e}");
assert!(!dir.join("w.png").exists(), "nothing written when `output` and the path disagree");
std::fs::remove_dir_all(&dir).ok();
}
+4 -4
View File
@@ -35,7 +35,7 @@ use oops::run_oops;
pub(crate) use oops::{Erased, erase_kept};
use select_then::{DisplaceM, Op, SelectThen};
use stretch::{StretchM, run_stretch};
pub(crate) use transform::{holds_ole_frame, ole_refusal, refuse_ole_frames, transform_entities};
pub(crate) use transform::transform_entities;
use transform::{run_copy, run_erase, run_mirror, run_move, run_rotate, run_scale};
pub(crate) use trim::{Edges, extend, trim};
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Modify", "Trim"])
.alias(&["tr"])
.params(
"{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\", mode?: \"quick\"|\"standard\"} → {handles: the object (kept, reshaped) then the new pieces; none when it was erased} (pick) | {trimmed} (fence, crossing)",
"{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\", mode?: \"quick\"|\"standard\"}",
)
.interactive(|_| Ok(Box::new(TrimM::new(false)))),
CommandSpec::new("extend", "Extend", trimextend::run_extend)
@@ -112,9 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("break", "Break", run_break)
.menu(&["Modify", "Break"])
.alias(&["br"])
.params("{handle, p1, p2} → {handles: the object (kept, reshaped) then the new pieces; none when nothing is left}")
.params("{handle, p1, p2}")
.interactive(|_| Ok(Box::new(BreakM::default()))),
CommandSpec::new("breakatpoint", "Break At Point", run_breakat).menu(&["Modify", "Break At Point"]).params("{handle, at} → {handles: the object (kept, reshaped) then the new piece}"),
CommandSpec::new("breakatpoint", "Break At Point", run_breakat).menu(&["Modify", "Break At Point"]).params("{handle, at}"),
CommandSpec::new("join", "Join", run_join)
.menu(&["Modify", "Join"])
.alias(&["j"])
+14 -2
View File
@@ -1,4 +1,4 @@
use super::trim::{param_on, replace_with_pieces, sub_polyline};
use super::trim::{param_on, sub_polyline};
use super::{
Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Polyline, Prompt, Result, Segment, Session, Step,
Value, Vec2, arc, bad, json, lwpoly, norm_angle, point_param, point_req, targets,
@@ -105,7 +105,19 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
}
_ => return Err(EngineError::Other("Cannot break that object.".into())),
};
replace_with_pieces(s, h, pieces)
let common = e.common.clone();
let space = s.space();
let d = s.doc_mut()?;
d.remove_entity(h);
let mut out = Vec::new();
for k in pieces {
let nh = d.new_handle();
if let Some(st) = d.space_mut(&space) {
st.push(Entity { handle: nh, common: common.clone(), kind: k });
out.push(nh);
}
}
Ok(out)
}
pub(super) fn run_break(s: &mut Session, p: &Value) -> Result<Value> {
-30
View File
@@ -5,7 +5,6 @@ use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, er
// ---------------- core operations ----------------
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
refuse_ole_frames(s, hs, m, copy)?;
edit_entities(s, hs, copy, |e| e.kind.transform(m))
}
@@ -13,7 +12,6 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
/// 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);
refuse_ole_frames(s, hs, &m, copy)?;
edit_entities(s, hs, copy, |e| {
e.kind.transform(&m);
e.kind.scale_heights(base.z, f);
@@ -21,34 +19,6 @@ pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64,
})
}
/// An OLE2FRAME only translates or scales uniformly (its corners stay axis aligned): refuse any other `m` before anything
/// changes, instead of reporting success and leaving the frame where it was.
pub(crate) fn refuse_ole_frames(s: &Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<()> {
if cadcraft_doc::ole_frame_accepts(m) || !holds_ole_frame(s, hs, copy)? {
return Ok(());
}
Err(ole_refusal())
}
/// The refusal for a transform an OLE2FRAME cannot take.
pub(crate) fn ole_refusal() -> crate::EngineError {
crate::EngineError::Other(
"An OLE object can only be moved, copied or scaled uniformly; other transforms (rotation, mirroring, non-uniform scale, alignment) are not supported.".into(),
)
}
/// Whether `hs` holds an OLE2FRAME a transform would touch. An in-place edit (`copy` false) skips
/// an object on a locked layer (see edit_entities), so such a frame cannot be refused.
pub(crate) fn holds_ole_frame(s: &Session, hs: &[Handle], copy: bool) -> Result<bool> {
let d = s.doc()?;
Ok(hs.iter().any(|h| {
d.entity(*h).is_some_and(|e| {
let locked = !copy && d.layer(&e.common.layer).is_some_and(|l| l.locked);
!locked && matches!(&e.kind, cadcraft_doc::EntityKind::Unknown(u) if u.is_ole_frame())
})
}))
}
/// `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());
+8 -36
View File
@@ -170,46 +170,18 @@ pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Resu
EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?,
k => crate::cmd::modify2::trim_other(k, &cut, pick)?,
};
replace_with_pieces(s, h, pieces)
}
/// Put `pieces` in place of object `h` as AutoCAD 2024's BREAK and TRIM leave them: the first
/// piece (the one holding the object's start, or the only piece of a circle or closed polyline)
/// is the object itself, keeping its handle, its place in the drawing order, its groups and its
/// extended data; the others are new objects with its properties, added at the end of its space
/// (`entlast`); with no pieces the object is erased. Returns the pieces' handles. (A line broken
/// in its middle keeps its handle on the piece from its start point, the piece to its end point is
/// new; a circle broken or trimmed becomes an arc with the circle's handle.)
///
/// Dimensions attached to the object follow [`crate::assoc::follow_pieces`]. Its parametric
/// constraints are removed: its new shape no longer meets them, and solving them again would move
/// the objects they tie it to.
pub(super) fn replace_with_pieces(s: &mut Session, h: Handle, pieces: Vec<EntityKind>) -> Result<Vec<Handle>> {
let common = e.common.clone();
let space = s.space();
let d = s.doc_mut()?;
let mut pieces = pieces.into_iter();
let Some(first) = pieces.next() else {
d.remove_entity(h);
return Ok(Vec::new());
};
let (Some(space), Some(old)) = (d.space_of(h), d.entity(h).cloned()) else {
return Err(EngineError::Other("no such object".into()));
};
d.modify_entity(h, |e| e.kind = first)?;
let mut out = vec![h];
for kind in pieces {
let handle = d.new_handle();
d.remove_entity(h);
let mut out = Vec::new();
for k in pieces {
let nh = d.new_handle();
if let Some(st) = d.space_mut(&space) {
st.push(Entity { handle, common: old.common.clone(), kind });
out.push(handle);
st.push(Entity { handle: nh, common: common.clone(), kind: k });
out.push(nh);
}
}
crate::assoc::follow_pieces(d, &old.kind, &out);
let n = d.constraints.len();
d.constraints.retain(|c| c.refs.iter().all(|r| r.handle != h));
let removed = n - d.constraints.len();
if removed > 0 {
s.echo(format!("{removed} constraint(s) of the broken or trimmed object removed."));
}
Ok(out)
}
+1 -10
View File
@@ -16,7 +16,7 @@ use super::other;
use crate::cmd::curves::KW;
use crate::cmd::helpers::line;
use crate::cmd::machines::{SelOutcome, SelectPhase, number};
use crate::cmd::modify::{refuse_ole_frames, transform_entities};
use crate::cmd::modify::transform_entities;
use crate::cmd::{bad, point_req, point_value, targets};
use crate::prompt::Drag;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
@@ -108,15 +108,6 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
}
// Every option is checked before the drawing changes.
let ops = list.iter().enumerate().map(|(i, v)| parse_op(i, v)).collect::<Result<Vec<_>>>()?;
// An OLE frame only translates or scales uniformly: a rotate step (or any step) that would refuse it must do so before an
// earlier move or copy changes the drawing (a refused command is not rolled back).
for op in &ops {
match *op {
Op::Rotate(a) => refuse_ole_frames(s, &objs, &Mat3::rotate_about(base, a), false)?,
Op::Scale(f) => refuse_ole_frames(s, &objs, &Mat3::scale_about(base, f), false)?,
_ => {}
}
}
let mut copies = Vec::new();
for op in ops {
copies.extend(apply(s, &objs, &mut base, op)?);
+2 -11
View File
@@ -35,23 +35,14 @@ pub(crate) fn arraypath(
};
crate::cmd::array::check_size(s, n as u64, objs.len())?;
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
let mut mats = Vec::new();
let mut out = Vec::new();
for i in 1..n {
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
let mut m = Mat3::translate(p - p0);
if align {
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
}
mats.push(m);
}
// Every copy is checked before the first is made: a refusal part way would leave the copies
// already added in the drawing (a failed command is not rolled back).
if mats.iter().any(|m| !cadcraft_doc::ole_frame_accepts(m)) && crate::cmd::modify::holds_ole_frame(s, &objs, true)? {
return Err(crate::cmd::modify::ole_refusal());
}
let mut out = Vec::new();
for m in &mats {
out.extend(crate::cmd::modify::transform_entities(s, &objs, m, true)?);
out.extend(crate::cmd::modify::transform_entities(s, &objs, &m, true)?);
}
Ok(out)
}
+2 -40
View File
@@ -265,11 +265,6 @@ pub fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
Ok(false)
}
/// The output a path's extension (any case) asks for: png, svg or pdf; other extensions ask for none.
fn path_output(path: &str) -> Option<PlotOutput> {
PlotOutput::parse(&std::path::Path::new(path).extension()?.to_string_lossy())
}
/// The options PLOT / EXPORTPDF hand the plotter with `device` and the device's papers
/// resolved: `device` (else the layout's page setup device) picks the output (`output`: pdf,
/// png or svg; EXPORTPDF always writes PDF) and `resolution`; a paper of the device becomes
@@ -291,42 +286,9 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
None => layout.as_ref().and_then(|pg| find_device(&pg.device)),
};
let mut out = if p.is_object() { p.clone() } else { json!({}) };
// The path's extension names the format, like EXPORT, unless a device is given; a given
// device that writes another format is refused. EXPORTPDF always writes PDF.
let by_path = if cmd == "exportpdf" { None } else { str_param(p, "path").and_then(path_output) };
// A path whose extension names no format (or has none) would hold a PDF under that name:
// refuse it, as EXPORT does, unless `output` or a device says what to write.
if cmd == "plot"
&& by_path.is_none()
&& !p.get("output").and_then(Value::as_str).is_some_and(|o| PlotOutput::parse(o).is_some())
&& !(dev.is_some() && given.is_some_and(|n| !n.is_empty()))
&& let Some(path) = str_param(p, "path")
{
return Err(bad(
cmd,
format!("can't tell the format of `{path}`: give a path ending in .png, .svg or .pdf, or set `output` to png, svg or pdf"),
));
}
if let (Some(want), Some(dev), true) = (by_path, &dev, given.is_some_and(|n| !n.is_empty()))
&& dev.output != want
{
return Err(bad(cmd, format!("plotter `{}` writes {}, but the path asks for {}", dev.name, dev.output.as_str(), want.as_str())));
}
// An `output` that says another format than the path's extension is refused too, as a device
// that does is: either way the file would not hold what its name says.
if let (Some(want), Some(asked)) = (by_path, p.get("output").and_then(Value::as_str).and_then(PlotOutput::parse))
&& asked != want
{
return Err(bad(cmd, format!("`output` is {}, but the path asks for {}", asked.as_str(), want.as_str())));
}
let (Some(dev), Some(o)) = (dev, out.as_object_mut()) else {
if let (Some(want), Some(o)) = (by_path, out.as_object_mut()) {
o.insert("output".into(), json!(want.as_str()));
}
return Ok(out);
};
let (Some(dev), Some(o)) = (dev, out.as_object_mut()) else { return Ok(out) };
o.remove("device");
let output = if cmd == "exportpdf" { PlotOutput::Pdf } else { by_path.unwrap_or(dev.output) };
let output = if cmd == "exportpdf" { PlotOutput::Pdf } else { dev.output };
o.insert("output".into(), json!(output.as_str()));
o.insert("resolution".into(), json!(dev.resolution));
let paper = match str_param(p, "paper") {
+9 -77
View File
@@ -10,7 +10,7 @@ mod rename;
pub use matchprop::MatchProps;
use cadcraft_color::Color;
use cadcraft_doc::{EntityKind, Handle, Lineweight, Transparency};
use cadcraft_doc::{EntityKind, Lineweight, Transparency};
use cadcraft_geom::Vec3;
use serde_json::{Value, json};
@@ -21,9 +21,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)} → {changed, locked, message?}: objects on locked layers (and, during REFEDIT, outside the working set) are left out, and `message` says how many"),
CommandSpec::new("chprop", "Change Properties", run_set)
.params("as properties.set, leaving out objects on locked layers (and, during REFEDIT, outside the working set) → {changed, locked, message?} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
CommandSpec::new("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"])
@@ -196,74 +196,11 @@ fn set_xy(p: &mut Vec3, v: &Value) {
}
}
/// The objects `properties.set` leaves alone, as CHPROP, MATCHPROP and MOVE do: those on locked
/// layers and, while a reference is edited in place, those outside the working set.
#[derive(Default)]
struct LeftOut {
locked: usize,
outside: usize,
}
impl LeftOut {
/// `hs` without the objects that can't be edited, each object once, and how many those were. A
/// handle that names no object is an error, before anything changes.
fn split(s: &Session, hs: Vec<Handle>) -> Result<(Vec<Handle>, LeftOut)> {
let d = s.doc()?;
// The working set, once: `RefEdit::is_working` searches a list.
let working: Option<std::collections::HashSet<Handle>> = d.refedit.as_ref().map(|r| r.items.iter().map(|i| i.copy).collect());
let mut seen = std::collections::HashSet::new();
let mut out = LeftOut::default();
let mut free = Vec::new();
for h in hs {
let Some(e) = d.entity(h) else {
return Err(cadcraft_doc::DocError::NoEntity(h).into());
};
if !seen.insert(h) {
continue;
}
if working.as_ref().is_some_and(|w| !w.contains(&h)) {
out.outside += 1;
} else if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
out.locked += 1;
} else {
free.push(h);
}
}
Ok((free, out))
}
/// `{changed, locked}`, and a message saying how many were left out when any were.
fn result(&self, changed: usize) -> Value {
let mut notes = Vec::new();
match self.locked {
0 => {}
1 => notes.push("1 object was on a locked layer.".to_string()),
n => notes.push(format!("{n} objects were on a locked layer.")),
}
match self.outside {
0 => {}
1 => notes.push("1 object was not in the working set.".to_string()),
n => notes.push(format!("{n} objects were not in the working set.")),
}
if changed == 0 && !notes.is_empty() {
notes.push("Nothing was changed.".to_string());
}
let mut r = json!({ "changed": changed, "locked": self.locked + self.outside });
if !notes.is_empty()
&& let Some(o) = r.as_object_mut()
{
o.insert("message".into(), json!(notes.join(" ")));
}
r
}
}
fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if hs.is_empty() {
return Err(bad("properties.set", "nothing selected"));
}
let (hs, left_out) = LeftOut::split(s, hs)?;
let color = p.get("color").map(|c| parse_color(c).ok_or_else(|| bad("properties.set", "bad colour"))).transpose()?;
let transparency = p
.get("transparency")
@@ -274,7 +211,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
.map(|v| v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("properties.set", "thickness must be a finite number")))
.transpose()?;
let lw = p.get("lineweight").map(|v| super::layer::parse_lw(v).ok_or_else(|| bad("properties.set", "bad lineweight"))).transpose()?;
let d = s.doc()?;
let d = s.doc_mut()?;
// An explicit object linetype must be ByLayer, ByBlock or a loaded definition (as `linetype current` requires).
let linetype = str_param(p, "linetype")
.map(|n| {
@@ -285,18 +222,13 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
}
})
.transpose()?;
if let Some(l) = str_param(p, "layer") {
d.ensure_layer(l);
}
let dim_style = str_param(p, "dimStyle")
.map(|n| d.dim_style(n).map(|st| st.name.clone()).ok_or_else(|| bad("properties.set", format!("dimension style `{n}` not found"))))
.transpose()?;
let dim_over = dim_overrides(p)?;
if hs.is_empty() {
// Every object was left out: the drawing stays as it was, with no undo step.
return Ok(left_out.result(0));
}
let d = s.doc_mut()?;
if let Some(l) = str_param(p, "layer") {
d.ensure_layer(l);
}
let anno_scale = d.current_anno_scale();
let common = CommonEntityProperties { anno_scale: &anno_scale, color, linetype: linetype.as_deref(), lineweight: lw, transparency, thickness };
for h in &hs {
@@ -305,7 +237,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
apply_kind_properties(e, p, dim_style.as_ref(), &dim_over);
})?;
}
Ok(left_out.result(hs.len()))
Ok(json!({ "changed": hs.len() }))
}
fn apply_kind_properties(
+30 -7
View File
@@ -23,6 +23,7 @@ use serde_json::{Value, json};
use super::super::curves::{KW, is_locked};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::targets;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
@@ -89,18 +90,13 @@ impl ChpropM {
if vals.all(|v| v == first) { first } else { "varies".into() }
}
/// Apply `props` to the selection through `properties.set`, saying what it left out: objects
/// this command moved onto a locked layer, say.
/// Apply `props` to the selection through `properties.set`.
fn apply(&self, s: &mut Session, props: Value) -> Result<()> {
let mut p = json!({ "handles": self.handles.iter().map(|h| h.hex()).collect::<Vec<_>>() });
if let (Some(p), Value::Object(props)) = (p.as_object_mut(), props) {
p.extend(props);
}
let r = super::run_set(s, &p)?;
if let Some(m) = r.get("message").and_then(Value::as_str) {
s.echo(m.to_string());
}
Ok(())
super::run_set(s, &p).map(|_| ())
}
/// Take an answer to `ask`; `false` when it was refused (the prompt comes again).
@@ -241,6 +237,33 @@ pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input)
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> {
let hs = targets(s, p)?;
let locked = locked(s, &hs);
if locked.is_empty() {
return super::run_set(s, p).map(|r| with_locked(r, 0));
}
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
let r = if free.is_empty() {
json!({ "changed": 0 })
} else {
let mut q = p.as_object().cloned().unwrap_or_default();
q.insert("handles".into(), json!(free.iter().map(|h| h.hex()).collect::<Vec<_>>()));
super::run_set(s, &Value::Object(q))?
};
Ok(with_locked(r, locked.len()))
}
/// `r` with how many objects were left out on locked layers.
fn with_locked(mut r: Value, n: usize) -> Value {
if let Some(o) = r.as_object_mut() {
o.insert("locked".into(), json!(n));
}
r
}
/// A colour typed at CHPROP: 1–255, a name, BYLAYER or BYBLOCK (not 0 or 256, which COLOR takes).
fn color(t: &str) -> Option<Color> {
if t.bytes().all(|b| b.is_ascii_digit()) {
+3 -3
View File
@@ -36,7 +36,7 @@ fn misses_edges(e: &EngineError) -> bool {
}
/// Trim or extend `h` at `at`. A Quick-mode (`quick`) trim of an object no cutting edge crosses
/// erases it. Returns what `h` became: its pieces, `h` itself first (none when it was erased).
/// erases it. Returns the handles that replace `h` (none when it was erased).
fn apply(s: &mut Session, h: Handle, at: Vec2, extending: bool, edges: Edges, quick: bool) -> Result<Vec<Handle>> {
if extending {
extend(s, h, at, edges)?;
@@ -118,8 +118,8 @@ fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, qu
run_picks(s, work, extending, edges, quick, tol)
}
/// Trim (or extend) each object at its pick points. A trim leaves the object in pieces, so later
/// picks go to the piece within `tol` of the point (none: that part is already gone).
/// Trim (or extend) each object at its pick points. A trim replaces the object with pieces, so
/// later picks go to the piece within `tol` of the point (none: that part is already gone).
fn run_picks(s: &mut Session, work: Vec<(Handle, Vec<Vec2>)>, extending: bool, edges: Edges, quick: bool, tol: f64) -> Result<usize> {
let mut n = 0;
let mut last_err = None;
-1
View File
@@ -27,7 +27,6 @@ pub mod mtext_edit;
pub mod pointmod;
pub mod prompt;
mod recent;
pub mod render_look;
pub mod sample;
pub mod select;
pub mod settings_dir;
-196
View File
@@ -1,196 +0,0 @@
//! How a render request draws: its `background` and `plot_style` (#803). The app's control
//! channel and the headless MCP backend both answer `ui.render`, and both read these here.
//!
//! Colour 7 (white/black) follows the background, as on a plot: the rasteriser draws it white on
//! a dark background and black on a light one ([`cadcraft_color::display_rgb`]).
//!
//! A render with a plot style table draws on white unless `background` says otherwise, as a plot
//! prints on paper: the tables that change colours print colour 7 (and, in `monochrome.ctb`,
//! every colour) black, which can hardly be seen on the dark canvas.
//!
//! `plot_style: "none"` (or `""`) draws every object with its own properties, also in a layout
//! whose page setup has "Display plot styles" on; without `plot_style` such a layout draws with
//! its own table, as on screen.
use std::sync::Arc;
use cadcraft_color::Rgb;
use cadcraft_doc::{Drawing, PlotStyleTable};
use cadcraft_render::raster::RasterOptions;
use serde_json::Value;
/// The background a render draws on without `background`: the dark canvas.
pub const DARK: Rgb = RasterOptions::DARK_BACKGROUND;
/// The background of `"white"` and `"paper"`.
pub const WHITE: Rgb = Rgb(255, 255, 255);
/// What `background` accepts, for the error message and the docs.
pub const BACKGROUNDS: &str = "\"dark\" (the default without a plot style), \"white\" (the default with one), \"paper\" or \"#rrggbb\"";
/// Most table names an unknown `plot_style` error lists from the drawing's own tables (the
/// built-in ones are always listed).
const MAX_LISTED_TABLES: usize = 10;
/// Longest part of a refused value repeated in its error message, in characters.
const MAX_ECHO_CHARS: usize = 40;
/// The look of a render: what it draws on and with which plot style table.
#[derive(Clone, Debug)]
pub struct RenderLook {
pub background: Rgb,
/// `None` = draw with the objects' own properties (or, in a layout showing plot styles, its
/// page setup's table). `plot_style: "none"` is a table that keeps every property, so such a
/// layout's table doesn't apply either.
pub plot_style_table: Option<Arc<PlotStyleTable>>,
}
impl Default for RenderLook {
fn default() -> Self {
RenderLook { background: DARK, plot_style_table: None }
}
}
impl RenderLook {
/// `background` and `plot_style` of a request's params for drawing `d`; absent or `null`
/// keeps the default. `Err` says what is accepted.
pub fn from_params(d: &Drawing, p: &Value) -> Result<RenderLook, String> {
let text = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.as_str())),
Some(_) => Err(format!("`{key}` must be a string")),
};
let mut look = RenderLook::default();
if let Some(name) = text("plot_style")? {
look.plot_style_table = Some(Arc::new(match plot_style(d, name)? {
Some(t) => {
// Plotted on paper, unless `background` says otherwise.
look.background = WHITE;
t
}
None => object_properties(),
}));
}
if let Some(b) = text("background")? {
look.background = background(b).ok_or_else(|| format!("unknown background `{}`: use {BACKGROUNDS}", echo(b)))?;
}
Ok(look)
}
/// The raster options of a render with this look.
pub fn raster(&self) -> RasterOptions {
RasterOptions { background: self.background, ..RasterOptions::default() }
}
}
/// The colour of a `background` value (any case): `dark`, `white`, `paper` or `#rrggbb`.
pub fn background(v: &str) -> Option<Rgb> {
let v = v.trim();
match v.to_ascii_lowercase().as_str() {
"dark" => Some(DARK),
"white" | "paper" => Some(WHITE),
hex if hex.starts_with('#') => Rgb::parse_hex(hex),
_ => None,
}
}
/// The plot style table `name` names: one the drawing holds, or a built-in one (`.ctb` may be
/// left out); `none` or an empty name is no table. `Err` lists the tables there are.
fn plot_style(d: &Drawing, name: &str) -> Result<Option<PlotStyleTable>, String> {
if cadcraft_doc::no_plot_style_table(name) {
return Ok(None);
}
cadcraft_doc::plot_style_table(d, name.trim())
.map(Some)
.ok_or_else(|| format!("unknown plot style table `{}`: use none or one of {}", echo(name), table_list(d)))
}
/// The tables a drawing can use, for an error message: the built-in ones, then at most
/// [`MAX_LISTED_TABLES`] of the drawing's own and how many more there are.
fn table_list(d: &Drawing) -> String {
let builtin = |n: &String| cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().any(|b| b.eq_ignore_ascii_case(n));
let names = cadcraft_doc::plot_style_table_names(d);
let own: Vec<String> = names.iter().filter(|n| !builtin(n)).map(|n| echo(n)).collect();
let mut list: Vec<String> = cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().map(|b| (*b).to_string()).collect();
list.extend(own.iter().take(MAX_LISTED_TABLES).cloned());
let mut text = list.join(", ");
if own.len() > MAX_LISTED_TABLES {
text.push_str(&format!(" and {} more of the drawing's", own.len() - MAX_LISTED_TABLES));
}
text
}
/// A table that prints every object with its own properties: what `plot_style: "none"` draws
/// with, so a layout's own table doesn't apply either.
fn object_properties() -> PlotStyleTable {
PlotStyleTable::color_dependent("None", "Object properties", |_| None)
}
/// The start of a refused value, to repeat in its error message.
fn echo(v: &str) -> String {
v.chars().take(MAX_ECHO_CHARS).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn backgrounds() {
assert_eq!(background("dark"), Some(DARK));
assert_eq!(DARK, RasterOptions::default().background, "the default is the rasteriser's canvas");
assert_eq!(background(" Paper "), Some(WHITE));
assert_eq!(background("WHITE"), Some(WHITE));
assert_eq!(background("#336699"), Some(Rgb(0x33, 0x66, 0x99)));
for bad in ["", "purple", "336699", "#12345", "#1234567", "##336699", "#33669g"] {
assert_eq!(background(bad), None, "{bad}");
}
}
#[test]
fn from_params() {
let d = Drawing::new_imperial();
let look = RenderLook::from_params(&d, &json!({})).unwrap();
assert_eq!(look.background, DARK);
assert!(look.plot_style_table.is_none());
// "none" keeps the dark canvas and draws every object with its own properties, also in a
// layout that shows its own plot styles.
for none in ["none", "", " None "] {
let look = RenderLook::from_params(&d, &json!({"background": null, "plot_style": none})).unwrap();
assert_eq!(look.background, DARK, "{none:?}");
let t = look.plot_style_table.unwrap();
assert!(t.is_color_dependent() && t.styles.iter().all(|s| s.keeps_color() && s.lineweight.is_none()), "{none:?}");
}
let look = RenderLook::from_params(&d, &json!({"background": "white", "plot_style": "Monochrome"})).unwrap();
assert_eq!(look.raster().background, WHITE);
assert_eq!(look.plot_style_table.map(|t| t.name.clone()).as_deref(), Some("monochrome.ctb"));
// A plot style plots on paper unless `background` says otherwise.
for table in ["monochrome.ctb", "grayscale.ctb", "default.stb"] {
assert_eq!(RenderLook::from_params(&d, &json!({"plot_style": table})).unwrap().background, WHITE, "{table}");
let look = RenderLook::from_params(&d, &json!({"plot_style": table, "background": "dark"})).unwrap();
assert_eq!(look.background, DARK, "{table}");
}
let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err();
assert!(e.contains("nope.ctb") && e.contains("grayscale.ctb") && e.contains("none"), "{e}");
let e = RenderLook::from_params(&d, &json!({"background": "x".repeat(10_000)})).unwrap_err();
assert!(e.len() < 200 && e.contains("#rrggbb"), "{e}");
assert!(RenderLook::from_params(&d, &json!({"background": 1})).is_err());
assert!(RenderLook::from_params(&d, &json!({"plot_style": {}})).is_err());
}
/// An unknown plot style lists the built-in tables and only some of the drawing's own.
#[test]
fn unknown_plot_style_lists_a_few_tables() {
let mut d = Drawing::new_imperial();
for i in 0..500 {
d.plot_style_tables.push(PlotStyleTable::color_dependent(&format!("{}{i}.ctb", "own".repeat(30)), "", |_| None));
}
let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err();
for b in cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES {
assert!(e.contains(b), "{b}: {e}");
}
assert!(e.contains("and 490 more"), "{e}");
assert!(e.len() < 1000, "{} bytes: {e}", e.len());
assert!(RenderLook::from_params(&d, &json!({"plot_style": d.plot_style_tables[7].name.clone()})).is_ok());
}
}
-24
View File
@@ -36,13 +36,6 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec<Vec<Vec2>> {
}
v
}
// An OLE object's frame is drawn, so it is picked on its outline (the corners the extents and
// the renderer use); a frame with no width or height is drawn as a line, so it is picked as
// one. It stays out of prims(), which TRIM, object snap and the dimension commands read.
EntityKind::Unknown(u) if u.is_ole_frame() => match cadcraft_doc::ole_frame(&u.tags) {
Some(q) => vec![q.to_vec()],
None => Vec::new(),
},
kind => {
let mut out = Vec::new();
for p in kind.prims() {
@@ -514,23 +507,6 @@ mod tests {
assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line));
}
/// An OLE2FRAME draws as its frame, so a click on an edge picks it (review on #2587); a click in
/// the middle of the frame, or on a frame without both corners, does not.
#[test]
fn clicking_an_ole_frame_edge_selects_it() {
let tag = |code: i32, value: &str| cadcraft_doc::RawTag { code, value: value.into() };
let frame = |tags: Vec<cadcraft_doc::RawTag>| EntityKind::Unknown(cadcraft_doc::Unknown { dxf_type: "OLE2FRAME".into(), tags });
let mut d = Drawing::new_metric();
let h = d.add(&Space::Model, Common::default(), frame(vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")])).unwrap();
d.add(&Space::Model, Common::default(), frame(vec![tag(10, "80"), tag(20, "80")])).unwrap();
let line = d.add(&Space::Model, Common::default(), frame(vec![tag(10, "60"), tag(20, "-19"), tag(11, "60"), tag(21, "-22")])).unwrap();
assert_eq!(pick(&d, &Space::Model, Vec2::new(32.5, -19.0), 0.2), Some(h), "top edge");
assert_eq!(pick(&d, &Space::Model, Vec2::new(35.0, -20.5), 0.2), Some(h), "right edge");
assert_eq!(pick(&d, &Space::Model, Vec2::new(32.5, -20.5), 0.2), None, "inside the frame");
assert_eq!(pick(&d, &Space::Model, Vec2::new(80.0, 80.0), 0.2), None, "a frame without a second corner");
assert_eq!(pick(&d, &Space::Model, Vec2::new(60.0, -20.0), 0.2), Some(line), "a frame with no width is drawn as a line and picked as one");
}
/// A block holding a diagonal dashed line, inserted at `scale`.
fn dashed_block(length: f64, scale: f64) -> (Drawing, Handle) {
let mut d = Drawing::new_imperial();
+1 -8
View File
@@ -17,17 +17,10 @@ impl Session {
/// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index.
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
let mut st = DocState::new(d, title, path);
// The view the file was saved at is the one every other reader opens on. Fitting the
// extents instead showed a drawing whose extents are dominated by far-off geometry at a
// view hundreds of times its content, with the drawing sub-pixel and no way to zoom in
// (#1602). The extents remain the fallback for a drawing that stores no view.
let saved = crate::view::saved_view(&st.doc);
let ext = st.doc.extents(&Space::Model);
// The current tile (the first), or the lone untiled view, keeps its saved twist.
let twist = cadcraft_geom::norm_angle(st.doc.vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist));
if let Some(v) = saved {
st.set_view_quiet(View { twist, ..v });
} else if ext.is_empty() {
if ext.is_empty() {
st.set_view_quiet(View { twist, ..st.view() });
} else {
let (center, w, h) = View::twisted_box(ext, twist);
-61
View File
@@ -179,67 +179,6 @@ fn json_execute_and_errors() {
assert_eq!(s.state().unwrap().undo.len(), 1);
}
/// A drawing is opened on the view it was saved at, as every other reader opens it. Fitting the
/// extents instead showed a drawing whose extents are dominated by far-off geometry at a view
/// hundreds of thousands of times its content: sub-pixel, and `ZOOM Extents` fits the same box
/// (#1602). The extents are still the fallback for a drawing that stores no view.
#[test]
fn a_drawing_opens_on_the_view_it_was_saved_at() {
let mut d = cadcraft_doc::Drawing::new_metric();
for (a, b) in [([0.0, 0.0], [100.0, 60.0]), ([5e6, 5e6], [5e6 + 100.0, 5e6 + 60.0])] {
let line = cadcraft_doc::Line { a: cadcraft_geom::Vec3::new(a[0], a[1], 0.0), b: cadcraft_geom::Vec3::new(b[0], b[1], 0.0) };
d.add(&Space::Model, cadcraft_doc::Common::default(), EntityKind::Line(line)).unwrap();
}
d.header.set_saved_view(Vec2::new(50.0, 50.0), 20.0);
let mut s = Session::new();
s.open_drawing(d.clone(), "saved-view.dxf", None);
assert_eq!(s.state().unwrap().view(), View { center: Vec2::new(50.0, 50.0), height: 20.0, twist: 0.0 });
// No stored view: the extents, as before. They are what makes this drawing unusable without
// the stored one -- the fit is more than a hundred thousand times taller than the detail.
let mut plain = d.clone();
plain.header.vars.remove("VIEWCTR");
plain.header.vars.remove("VIEWSIZE");
let mut s = Session::new();
s.open_drawing(plain, "no-view.dxf", None);
assert!(s.state().unwrap().view().height > 5e6, "{:?}", s.state().unwrap().view());
// A stored view that cannot be shown is no view at all.
let mut broken = d;
broken.header.set("VIEWSIZE", cadcraft_doc::HVal::Real(0.0));
let mut s = Session::new();
s.open_drawing(broken, "broken-view.dxf", None);
assert!(s.state().unwrap().view().height > 5e6, "{:?}", s.state().unwrap().view());
}
/// Resolving a drawing's xrefs after OPEN refits the view only for a drawing that stores none:
/// one saved at a view keeps it, xref or not (#1626 review).
#[test]
fn a_drawing_with_an_xref_keeps_the_view_it_was_saved_at() {
let mut d = cadcraft_doc::Drawing::new_metric();
let line = cadcraft_doc::Line { a: cadcraft_geom::Vec3::ZERO, b: cadcraft_geom::Vec3::new(5e6, 5e6, 0.0) };
d.add(&Space::Model, cadcraft_doc::Common::default(), EntityKind::Line(line)).unwrap();
let mut xref = cadcraft_doc::Block::new("XR");
xref.xref_path = Some("missing-xref.dxf".into());
d.blocks.insert("XR".into(), std::sync::Arc::new(xref));
let mut plain = d.clone();
d.header.set_saved_view(Vec2::new(50.0, 50.0), 20.0);
let mut s = Session::new();
let i = s.open_drawing(d, "saved-view.dxf", None);
crate::cmd::blocks::xref::after_open(&mut s, i);
assert_eq!(s.state().unwrap().view(), View { center: Vec2::new(50.0, 50.0), height: 20.0, twist: 0.0 });
// With no stored view, the xrefs' pass still frames the drawing.
plain.header.vars.remove("VIEWCTR");
plain.header.vars.remove("VIEWSIZE");
let mut s = Session::new();
let i = s.open_drawing(plain, "no-view.dxf", None);
crate::cmd::blocks::xref::after_open(&mut s, i);
assert!(s.state().unwrap().view().height > 5e6, "{:?}", s.state().unwrap().view());
}
#[test]
fn move_with_pickfirst_and_window_selection() {
let mut s = Session::new();
-6
View File
@@ -35,12 +35,6 @@ pub(crate) fn usable_view(v: &View) -> bool {
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 && v.twist.is_finite()
}
/// The view drawing `d` was saved at, when it records a usable one: OPEN opens on it, and nothing
/// after the open (resolving xrefs, the UI's first frame) fits the extents over it.
pub(crate) fn saved_view(d: &cadcraft_doc::Drawing) -> Option<View> {
d.header.saved_view().map(|(center, height)| View { center, height, twist: 0.0 }).filter(usable_view)
}
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModelTiles {
@@ -45,8 +45,7 @@ fn break_and_breakatpoint_refuse_a_locked_line() {
let unlocked = snapshot(&s);
let r = s.execute("break", &json!({"handle": a, "p1": [3, 0], "p2": [7, 0]})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
// The line keeps its handle as the piece from its start.
assert_eq!(r["handles"][0].as_str(), Some(a.as_str()));
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(&a).unwrap()).is_none());
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(&control).unwrap()).is_some());
let broken = snapshot(&s);
s.execute("undo", &json!({})).unwrap();
@@ -1,444 +0,0 @@
//! BREAK, BREAKATPOINT and TRIM change the object itself, as AutoCAD 2024 does: the piece from
//! the object's start, or the only piece left, keeps its handle and its place in the drawing
//! order, and the other pieces are new objects added at the end (`entlast`). Groups keep the
//! object, an associative hatch whose boundary no longer closes keeps its shape and its link
//! ends, and a dimension attached to the object stays on the point it measured, or follows the
//! object's new end where that point was cut off.
//!
//! The two chains, the group, the two hatches and the three dimensions on a line were measured in
//! AutoCAD 2024 (accoreconsole) on the same drawings: the objects in database order (`entnext`)
//! after each chain, a new handle marked `+`, and the dimensions' DIMASSOC references. The other
//! cases follow from those rules, or say what they check.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{AssocSnap, Common, DimAssoc, Dimension, EntityKind, Handle, Hatch, Line, Space};
use cadcraft_engine::geom::{Vec2, Vec3};
use serde_json::{Value, json};
fn first_handle(v: &Value) -> Handle {
Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap()
}
/// Adds the object `cmd` makes from `p`, on layer `name`.
fn add(s: &mut Session, name: &str, cmd: &str, p: Value) -> Handle {
let h = first_handle(&s.execute(cmd, &p).unwrap());
s.execute("properties.set", &json!({ "handles": [h.hex()], "layer": name })).unwrap();
h
}
fn line(s: &mut Session, name: &str, a: [f64; 2], b: [f64; 2]) -> Handle {
add(s, name, "line", json!({ "points": [a, b] }))
}
fn arc(s: &mut Session, name: &str, c: [f64; 2], start: f64, end: f64) -> Handle {
add(s, name, "arc", json!({ "center": c, "radius": 10, "start": start, "end": end }))
}
fn pline(s: &mut Session, name: &str, vertices: Value, closed: bool) -> Handle {
add(s, name, "pline", json!({ "vertices": vertices, "closed": closed }))
}
/// The drawing the chains were measured on, each object on a layer named after it, made in this
/// order.
fn drawing() -> Session {
let mut s = Session::new();
line(&mut s, "LA", [0.0, 50.0], [100.0, 50.0]);
line(&mut s, "LB", [50.0, 0.0], [50.0, 100.0]);
pline(&mut s, "LP", json!([[0, 200], [20, 200], [20, 220]]), false);
line(&mut s, "LE", [200.0, 0.0], [210.0, 0.0]);
line(&mut s, "LF", [210.0, 0.0], [220.0, 0.0]);
line(&mut s, "LC", [0.0, 0.0], [100.0, 100.0]);
line(&mut s, "LD", [0.0, 100.0], [100.0, 0.0]);
arc(&mut s, "LR", [300.0, 0.0], 0.0, 180.0);
add(&mut s, "LO", "circle", json!({ "center": [400, 0], "radius": 10 }));
pline(&mut s, "LK", json!([[500, 0], [520, 0], [520, 20], [500, 20]]), true);
line(&mut s, "LG", [600.0, 0.0], [600.0, 100.0]);
line(&mut s, "LH", [590.0, 40.0], [610.0, 40.0]);
line(&mut s, "LI", [590.0, 60.0], [610.0, 60.0]);
line(&mut s, "LT", [650.0, 0.0], [650.0, 100.0]);
line(&mut s, "LU", [640.0, 40.0], [660.0, 40.0]);
line(&mut s, "LW", [395.0, -20.0], [395.0, 20.0]);
line(&mut s, "LX", [10.0, 190.0], [10.0, 210.0]);
line(&mut s, "LY", [15.0, 190.0], [15.0, 210.0]);
arc(&mut s, "LJ", [700.0, 0.0], 0.0, 90.0);
arc(&mut s, "LL", [700.0, 0.0], 90.0, 360.0);
line(&mut s, "LM", [800.0, 0.0], [810.0, 0.0]);
arc(&mut s, "LN", [810.0, 10.0], 270.0, 360.0);
pline(&mut s, "LQ", json!([[900, 0], [910, 0]]), false);
line(&mut s, "LS", [910.0, 0.0], [910.0, 10.0]);
line(&mut s, "LZ", [1000.0, 0.0], [1010.0, 0.0]);
s
}
/// `v` to `places` decimals without trailing zeros, as `rtos` printed the measurements.
fn num(v: f64, places: usize) -> String {
let t = format!("{v:.places$}");
let t = if t.contains('.') { t.trim_end_matches('0').trim_end_matches('.') } else { &t };
if t == "-0" { "0".into() } else { t.into() }
}
fn pt(p: Vec2) -> String {
format!("{},{}", num(p.x, 4), num(p.y, 4))
}
/// An angle in degrees in [0, 360).
fn deg(a: f64) -> String {
let d = a.to_degrees().rem_euclid(360.0);
num(if d >= 360.0 - 5e-4 { 0.0 } else { d }, 3)
}
/// The model space objects in order as `layer[+]:TYPE:shape`, `+` marking a handle that wasn't in
/// `original`.
fn table(s: &Session, original: &[Handle]) -> Vec<String> {
s.doc()
.unwrap()
.model
.iter()
.map(|e| {
let shape = match &e.kind {
EntityKind::Line(l) => format!("LINE:{}>{}", pt(l.a.xy()), pt(l.b.xy())),
EntityKind::Arc(a) => format!("ARC:c{}r{}a{}>{}", pt(a.center.xy()), num(a.radius, 4), deg(a.start), deg(a.end)),
EntityKind::Circle(c) => format!("CIRCLE:c{}r{}", pt(c.center.xy()), num(c.radius, 4)),
EntityKind::LwPolyline(p) => {
format!("LWPOLYLINE:f{}{}", u8::from(p.closed), p.vertices.iter().map(|v| format!(";{}", pt(v.p))).collect::<String>())
}
k => format!("{k:?}"),
};
let new = if original.contains(&e.handle) { "" } else { "+" };
format!("{}{new}:{shape}", e.common.layer)
})
.collect()
}
fn handles(s: &Session) -> Vec<Handle> {
s.doc().unwrap().model.iter().map(|e| e.handle).collect()
}
/// The object on layer `name` that was there first.
fn named(s: &Session, name: &str) -> Handle {
s.doc().unwrap().model.iter().find(|e| e.common.layer == name).unwrap().handle
}
/// Runs `commands` on the command line one by one, each to its end.
fn run(s: &mut Session, commands: &[&str]) {
for c in commands {
s.script(&format!("{c}\n")).unwrap();
assert!(s.running.is_none(), "{c}: {}", s.prompt_text());
}
}
/// The objects AutoCAD leaves untouched in the middle of both tables.
const UNTOUCHED: [&str; 8] = [
"LG:LINE:600,0>600,100",
"LH:LINE:590,40>610,40",
"LI:LINE:590,60>610,60",
"LT:LINE:650,0>650,100",
"LU:LINE:640,40>660,40",
"LW:LINE:395,-20>395,20",
"LX:LINE:10,190>10,210",
"LY:LINE:15,190>15,210",
];
/// The measured `entnext` table after the chain of breaks below. (LL and LN end at 360°, written
/// 0° here.)
fn after_breaks() -> Vec<&'static str> {
let mut v = vec![
"LA:LINE:10,50>100,50",
"LB:LINE:50,0>50,20",
"LP:LWPOLYLINE:f0;0,200;10,200",
"LE:LINE:200,0>210,0",
"LF:LINE:210,0>220,0",
"LC:LINE:0,0>100,100",
"LD:LINE:0,100>100,0",
"LR:ARC:c300,0r10a0>45",
"LO:ARC:c400,0r10a90>0",
"LK:LWPOLYLINE:f0;520,10;520,20;500,20;500,0;510,0",
];
v.extend(UNTOUCHED);
v.extend([
"LJ:ARC:c700,0r10a0>45",
"LL:ARC:c700,0r10a90>0",
"LM:LINE:800,0>810,0",
"LN:ARC:c810,10r10a270>0",
"LQ:LWPOLYLINE:f0;900,0;910,0",
"LS:LINE:910,0>910,10",
"LZ:LINE:1000,0>1005,0",
"LB+:LINE:50,30>50,100",
"LR+:ARC:c300,0r10a135>180",
"LP+:LWPOLYLINE:f0;20,210;20,220",
"LZ+:LINE:1005,0>1010,0",
"LJ+:ARC:c700,0r10a45>90",
]);
v
}
#[test]
fn a_chain_of_breaks_leaves_the_objects_in_autocads_order() {
let mut s = drawing();
let original = handles(&s);
run(
&mut s,
&[
// A line picked nearer its end: the piece from its start still keeps the handle.
"BREAK 50,30 50,20",
// Off the start: one piece, the line itself.
"BREAK 10,50 -10,50",
"BREAK 307.0710678,7.0710678 292.9289322,7.0710678",
// A circle becomes an arc with its handle.
"BREAK 410,0 400,10",
"BREAK 5,200 F 10,200 20,210",
// A closed polyline opens with its handle.
"BREAK 510,0 520,10",
],
);
// BREAKATPOINT, picking each object at the point it is broken at.
for (name, at) in [("LZ", [1005.0, 0.0]), ("LJ", [707.0710678, 7.0710678])] {
s.execute("breakatpoint", &json!({ "handle": named(&s, name).hex(), "at": at })).unwrap();
}
assert_eq!(table(&s, &original), after_breaks());
}
#[test]
fn a_chain_of_trims_leaves_the_objects_in_autocads_order() {
let mut s = drawing();
let original = handles(&s);
// Quick mode: every object cuts. Each pick is between the measured cutting edges.
run(&mut s, &["TRIM 600,50\n", "TRIM 650,80\n", "TRIM 50,10\n", "TRIM 390,0\n", "TRIM 12,200\n"]);
let mut want = vec![
"LA:LINE:0,50>100,50",
"LB:LINE:50,50>50,100",
"LP:LWPOLYLINE:f0;0,200;10,200",
"LE:LINE:200,0>210,0",
"LF:LINE:210,0>220,0",
"LC:LINE:0,0>100,100",
"LD:LINE:0,100>100,0",
"LR:ARC:c300,0r10a0>180",
"LO:ARC:c400,0r10a240>120",
"LK:LWPOLYLINE:f1;500,0;520,0;520,20;500,20",
"LG:LINE:600,0>600,40",
];
want.extend(&UNTOUCHED[1..3]);
want.push("LT:LINE:650,0>650,40");
want.extend(&UNTOUCHED[4..]);
want.extend([
"LJ:ARC:c700,0r10a0>90",
"LL:ARC:c700,0r10a90>0",
"LM:LINE:800,0>810,0",
"LN:ARC:c810,10r10a270>0",
"LQ:LWPOLYLINE:f0;900,0;910,0",
"LS:LINE:910,0>910,10",
"LZ:LINE:1000,0>1010,0",
"LG+:LINE:600,60>600,100",
"LP+:LWPOLYLINE:f0;15,200;20,200;20,220",
]);
assert_eq!(table(&s, &original), want);
}
#[test]
fn the_json_forms_return_the_kept_object_first() {
let mut s = Session::new();
let l = first_handle(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap());
let r = s.execute("break", &json!({ "handle": l.hex(), "p1": [20, 0], "p2": [30, 0] })).unwrap();
let hs = r["handles"].as_array().unwrap();
assert_eq!(hs.len(), 2);
assert_eq!(hs[0], l.hex());
assert_eq!(handles(&s), vec![l, Handle::parse_hex(hs[1].as_str().unwrap()).unwrap()]);
let r = s.execute("breakatpoint", &json!({ "handle": l.hex(), "at": [10, 0] })).unwrap();
assert_eq!(r["handles"][0], l.hex());
// Undo puts the line back, whole, under its own handle.
s.execute("u", &json!({})).unwrap();
s.execute("u", &json!({})).unwrap();
assert_eq!(table(&s, &[l]), ["0:LINE:0,0>100,0"]);
}
#[test]
fn the_new_piece_goes_to_the_space_of_the_object() {
// A paper space line broken through the JSON form while model space is current.
let mut s = Session::new();
let paper = Space::Paper("Layout1".into());
let line = EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(100.0, 0.0, 0.0) });
let l = s.doc_mut().unwrap().add(&paper, Common::default(), line).unwrap();
s.execute("break", &json!({ "handle": l.hex(), "p1": [20, 0], "p2": [30, 0] })).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.model.iter().count(), 0);
let pieces: Vec<Handle> = d.space(&paper).unwrap().iter().map(|e| e.handle).collect();
assert_eq!(pieces.len(), 2);
assert_eq!(pieces[0], l);
}
#[test]
fn a_group_keeps_the_broken_object_and_not_the_new_piece() {
let mut s = Session::new();
let a = first_handle(&s.execute("line", &json!({ "points": [[0, 50], [100, 50]] })).unwrap());
let b = first_handle(&s.execute("line", &json!({ "points": [[50, 0], [50, 100]] })).unwrap());
s.execute("group", &json!({ "name": "G1", "handles": [a.hex(), b.hex()] })).unwrap();
s.execute("break", &json!({ "handle": b.hex(), "p1": [50, 20], "p2": [50, 30] })).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.model.iter().count(), 3);
assert_eq!(d.groups.iter().find(|g| g.name == "G1").unwrap().members, vec![a, b]);
}
fn hatch(s: &Session, h: Handle) -> Hatch {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Hatch(x) => x.clone(),
k => panic!("not a hatch: {k:?}"),
}
}
fn loop_points(h: &Hatch) -> Vec<Vec<(f64, f64)>> {
h.loops.iter().map(|l| l.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()).collect()
}
#[test]
fn a_hatch_whose_polyline_is_broken_open_keeps_its_shape_and_loses_its_link() {
let mut s = Session::new();
let k = first_handle(&s.execute("pline", &json!({ "vertices": [[500, 0], [520, 0], [520, 20], [500, 20]], "closed": true })).unwrap());
let h = first_handle(&s.execute("hatch", &json!({ "handles": [k.hex()] })).unwrap());
assert!(hatch(&s, h).associative);
let square = loop_points(&hatch(&s, h));
s.execute("break", &json!({ "handle": k.hex(), "p1": [510, 0], "p2": [520, 10] })).unwrap();
assert!(matches!(&s.doc().unwrap().entity(k).unwrap().kind, EntityKind::LwPolyline(p) if !p.closed));
let after = hatch(&s, h);
assert!(!after.associative);
assert_eq!(loop_points(&after), square);
s.execute("u", &json!({})).unwrap();
assert!(hatch(&s, h).associative);
}
#[test]
fn a_hatch_whose_boundary_line_is_trimmed_keeps_its_shape_and_loses_its_link() {
let mut s = Session::new();
let sides = [
line(&mut s, "0", [500.0, 0.0], [520.0, 0.0]),
line(&mut s, "0", [520.0, 0.0], [520.0, 20.0]),
line(&mut s, "0", [520.0, 20.0], [500.0, 20.0]),
line(&mut s, "0", [500.0, 20.0], [500.0, 0.0]),
];
let h = first_handle(&s.execute("hatch", &json!({ "points": [[510, 10]] })).unwrap());
assert!(hatch(&s, h).associative);
let square = loop_points(&hatch(&s, h));
let k1 = line(&mut s, "0", [505.0, -5.0], [505.0, 5.0]);
let k2 = line(&mut s, "0", [515.0, -5.0], [515.0, 5.0]);
let before = handles(&s);
s.execute("trim", &json!({ "handle": sides[0].hex(), "pick": [510, 0], "edges": [k1.hex(), k2.hex()] })).unwrap();
let after = handles(&s);
assert_eq!(after[..before.len()], before[..]);
assert_eq!(after.len(), before.len() + 1);
let hh = hatch(&s, h);
assert!(!hh.associative);
assert_eq!(loop_points(&hh), square);
}
fn dim(s: &Session, h: Handle) -> Dimension {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Dimension(d) => d.clone(),
k => panic!("not a dimension: {k:?}"),
}
}
fn attachments(d: &Dimension) -> Vec<(String, Handle, AssocSnap)> {
let mut v: Vec<(String, Handle, AssocSnap)> = d.assoc.iter().map(|a: &DimAssoc| (a.point.clone(), a.handle, a.snap.clone())).collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
fn origins(d: &Dimension) -> [(f64, f64); 2] {
[(d.p13.x, d.p13.y), (d.p14.x, d.p14.y)]
}
/// A vertical line from (x, 0) to (x, 100) with a linear dimension attached to both its ends.
fn dimensioned_line(s: &mut Session, x: f64) -> (Handle, Handle) {
let l = first_handle(&s.execute("line", &json!({ "points": [[x, 0], [x, 100]] })).unwrap());
let d = first_handle(&s.execute("dimlinear", &json!({ "p1": [x, 0], "p2": [x, 100], "at": [x + 20.0, 50] })).unwrap());
assert_eq!(attachments(&dim(s, d)), [("p13".into(), l, AssocSnap::Start), ("p14".into(), l, AssocSnap::End)]);
(l, d)
}
#[test]
fn a_dimension_across_a_line_broken_in_its_middle_stays_on_both_ends() {
// Measured: the reference to the line's end moves to the new piece, the dimension is unchanged.
let mut s = Session::new();
let (l, d) = dimensioned_line(&mut s, 50.0);
run(&mut s, &["BREAK 50,20 50,30"]);
let piece = *handles(&s).last().unwrap();
assert!(piece != l && piece != d);
let dm = dim(&s, d);
assert_eq!(attachments(&dm), [("p13".into(), l, AssocSnap::Start), ("p14".into(), piece, AssocSnap::End)]);
assert_eq!(origins(&dm), [(50.0, 0.0), (50.0, 100.0)]);
s.execute("u", &json!({})).unwrap();
assert_eq!(attachments(&dim(&s, d)), [("p13".into(), l, AssocSnap::Start), ("p14".into(), l, AssocSnap::End)]);
}
#[test]
fn a_dimension_on_a_trimmed_off_end_follows_the_new_end() {
// Measured: the references stay on the line, the dimension measures 40 instead of 100.
let mut s = Session::new();
let (l, d) = dimensioned_line(&mut s, 650.0);
let edge = first_handle(&s.execute("line", &json!({ "points": [[640, 40], [660, 40]] })).unwrap());
s.execute("trim", &json!({ "handle": l.hex(), "pick": [650, 80], "edges": [edge.hex()] })).unwrap();
let dm = dim(&s, d);
assert_eq!(attachments(&dm), [("p13".into(), l, AssocSnap::Start), ("p14".into(), l, AssocSnap::End)]);
assert_eq!(origins(&dm), [(650.0, 0.0), (650.0, 40.0)]);
}
#[test]
fn a_dimension_on_a_broken_off_start_follows_the_new_start() {
// Measured: BREAK from the start to (650, 20); the dimension starts at (650, 20).
let mut s = Session::new();
let (l, d) = dimensioned_line(&mut s, 650.0);
run(&mut s, &["BREAK 650,10 F 650,0 650,20"]);
assert_eq!(handles(&s), [l, d]);
let dm = dim(&s, d);
assert_eq!(attachments(&dm), [("p13".into(), l, AssocSnap::Start), ("p14".into(), l, AssocSnap::End)]);
assert_eq!(origins(&dm), [(650.0, 20.0), (650.0, 100.0)]);
}
#[test]
fn a_dimension_follows_a_piece_a_later_pick_of_the_same_trim_shortens() {
// The measured rules pick by pick: the first pick moves the reference to the line's end onto
// the new piece, the second cuts that end off, so the dimension ends at the piece's new end.
let mut s = Session::new();
let other = first_handle(&s.execute("line", &json!({ "points": [[0, 20], [0, 30]] })).unwrap());
let l = first_handle(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap());
for x in [40, 60, 80] {
s.execute("line", &json!({ "points": [[x, -5], [x, 5]] })).unwrap();
}
let d = first_handle(&s.execute("dimlinear", &json!({ "p1": [0, 30], "p2": [100, 0], "at": [50, 60] })).unwrap());
assert_eq!(attachments(&dim(&s, d)), [("p13".into(), other, AssocSnap::End), ("p14".into(), l, AssocSnap::End)]);
s.script("TRIM 50,0 90,0\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let piece = *handles(&s).last().unwrap();
assert_eq!(table(&s, &[l]).last().unwrap(), "0+:LINE:60,0>80,0");
let dm = dim(&s, d);
assert_eq!(attachments(&dm), [("p13".into(), other, AssocSnap::End), ("p14".into(), piece, AssocSnap::End)]);
assert_eq!(origins(&dm), [(0.0, 30.0), (80.0, 0.0)]);
}
#[test]
fn a_dimension_on_a_closed_polyline_opened_by_break_keeps_its_vertices() {
// The vertices it measured are still vertices of the polyline, numbered from the break.
let mut s = Session::new();
let k = first_handle(&s.execute("pline", &json!({ "vertices": [[500, 0], [520, 0], [520, 20], [500, 20]], "closed": true })).unwrap());
let d = first_handle(&s.execute("dimlinear", &json!({ "p1": [500, 20], "p2": [520, 20], "at": [510, 40] })).unwrap());
assert_eq!(attachments(&dim(&s, d)), [("p13".into(), k, AssocSnap::Vertex { index: 3 }), ("p14".into(), k, AssocSnap::Vertex { index: 2 })]);
s.execute("break", &json!({ "handle": k.hex(), "p1": [510, 0], "p2": [520, 10] })).unwrap();
let dm = dim(&s, d);
assert_eq!(attachments(&dm), [("p13".into(), k, AssocSnap::Vertex { index: 2 }), ("p14".into(), k, AssocSnap::Vertex { index: 1 })]);
assert_eq!(origins(&dm), [(500.0, 20.0), (520.0, 20.0)]);
// Still attached: moving the polyline takes the dimension along.
s.execute("move", &json!({ "handles": [k.hex()], "delta": [0, 10] })).unwrap();
assert_eq!(origins(&dim(&s, d)), [(500.0, 30.0), (520.0, 30.0)]);
}
#[test]
fn constraints_on_a_broken_object_go_and_leave_the_others_where_they_are() {
let mut s = Session::new();
let a = first_handle(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap());
let b = first_handle(&s.execute("line", &json!({ "points": [[100, 0], [100, 50]] })).unwrap());
s.execute("geomconstraint", &json!({ "type": "coincident", "h1": a.hex(), "p1": "end", "h2": b.hex(), "p2": "start" })).unwrap();
s.execute("break", &json!({ "handle": a.hex(), "p1": [40, 0], "p2": [60, 0] })).unwrap();
let d = s.doc().unwrap();
assert!(d.constraints.is_empty());
let original = [a, b];
assert_eq!(table(&s, &original), ["0:LINE:0,0>40,0", "0:LINE:100,0>100,50", "0+:LINE:60,0>100,0"]);
}
-2
View File
@@ -19,7 +19,6 @@ mod blockreplace;
mod break_closed_polyline;
mod break_join_locked_layers;
mod break_keeps_z;
mod break_trim_keep_handle;
mod burst;
mod change_command;
mod chprop_command;
@@ -113,7 +112,6 @@ mod polyface_explode;
mod post_command_passes;
mod prompt_keywords;
mod prompt_or_once;
mod properties_set_locked;
mod props_bad_lineweight;
mod props_insert_scale_attribs;
mod purge_mlinestyles;
@@ -1,154 +0,0 @@
//! `properties.set`, which the Properties palette, Quick Properties and CHPROP edit through, leaves
//! objects on locked layers alone as CHPROP, MATCHPROP and MOVE do, and says how many it left out.
use cadcraft_engine::Session;
use cadcraft_engine::doc::color::Color;
use cadcraft_engine::doc::{Common, EntityKind, Handle, Insert, Space};
use cadcraft_engine::geom::Vec3;
use serde_json::{Value, json};
fn handle(v: &Value) -> Handle {
Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap()
}
fn line(s: &mut Session, y: f64) -> Handle {
handle(&s.execute("line", &json!({ "points": [[0, y], [50, y]] })).unwrap())
}
/// Line a on layer 0 and lines b and c on the locked layer L1; their handles.
fn locked_drawing() -> (Session, [Handle; 3]) {
let mut s = Session::new();
let hs = [line(&mut s, 0.0), line(&mut s, 20.0), line(&mut s, 40.0)];
s.execute("properties.set", &json!({ "handles": [hs[1].hex(), hs[2].hex()], "layer": "L1" })).unwrap();
s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap();
(s, hs)
}
fn counts(r: &Value) -> (Option<u64>, Option<u64>) {
(r["changed"].as_u64(), r["locked"].as_u64())
}
fn colors(s: &Session) -> Vec<Color> {
s.doc().unwrap().model.iter().map(|e| e.common.color).collect()
}
fn layers(s: &Session) -> Vec<String> {
s.doc().unwrap().model.iter().map(|e| e.common.layer.clone()).collect()
}
#[test]
fn objects_on_locked_layers_are_left_out() {
let (mut s, [a, b, c]) = locked_drawing();
let r = s.execute("properties.set", &json!({ "handles": [a.hex()], "color": 2 })).unwrap();
assert_eq!(counts(&r), (Some(1), Some(0)), "{r}");
assert!(r.get("message").is_none(), "{r}");
let r = s.execute("properties.set", &json!({ "handles": [a.hex(), b.hex()], "color": 3 })).unwrap();
assert_eq!(counts(&r), (Some(1), Some(1)), "{r}");
assert_eq!(r["message"], "1 object was on a locked layer.");
assert_eq!(colors(&s), [Color::Index(3), Color::ByLayer, Color::ByLayer]);
// The selection when no handles are given; a new layer leaves them on theirs.
s.set_selection(vec![a, b, c]);
let r = s.execute("properties.set", &json!({ "layer": "L2" })).unwrap();
assert_eq!(counts(&r), (Some(1), Some(2)), "{r}");
assert_eq!(r["message"], "2 objects were on a locked layer.");
assert_eq!(layers(&s), ["L2", "L1", "L1"]);
// An object listed twice counts once.
let r = s.execute("properties.set", &json!({ "handles": [a.hex(), a.hex(), b.hex(), b.hex()], "color": 6 })).unwrap();
assert_eq!(counts(&r), (Some(1), Some(1)), "{r}");
assert_eq!(r["message"], "1 object was on a locked layer.");
}
/// CHPROP says so when it leaves an edit out: an object it moved onto a locked layer keeps its
/// color.
#[test]
fn chprop_says_when_it_leaves_an_object_it_moved_onto_a_locked_layer_alone() {
let (mut s, _) = locked_drawing();
let d = line(&mut s, 60.0);
s.cmdline("(setq d (entlast))").unwrap();
s.log.clear();
s.cmdline("(command \"_.CHPROP\" d \"\" \"_LA\" \"L1\" \"_C\" 3 \"\")").unwrap();
let e = s.doc().unwrap().entity(d).unwrap().common.clone();
assert_eq!((e.layer.as_str(), e.color), ("L1", Color::ByLayer));
assert!(s.log.iter().any(|l| l == "1 object was on a locked layer. Nothing was changed."), "{:?}", s.log);
}
/// A handle that names no object is refused before anything changes.
#[test]
fn a_missing_object_is_refused_before_any_change() {
let (mut s, [a, b, _]) = locked_drawing();
let undo = s.state().unwrap().undo.len();
assert!(s.execute("properties.set", &json!({ "handles": [a.hex(), b.hex(), "FFFFF"], "color": 7 })).is_err());
assert_eq!(colors(&s), [Color::ByLayer; 3]);
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step");
}
#[test]
fn with_every_object_locked_nothing_changes() {
let (mut s, [_, b, c]) = locked_drawing();
let undo = s.state().unwrap().undo.len();
let r = s.execute("properties.set", &json!({ "handles": [b.hex(), c.hex()], "layer": "NEW", "color": 4 })).unwrap();
assert_eq!(counts(&r), (Some(0), Some(2)), "{r}");
assert_eq!(r["message"], "2 objects were on a locked layer. Nothing was changed.");
assert_eq!(colors(&s), [Color::ByLayer; 3]);
assert!(s.doc().unwrap().layer("NEW").is_none(), "no layer is made");
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step");
// A bad value is still refused, whichever it is.
for bad in [
json!({ "color": "nonsense" }),
json!({ "linetype": "NOT_LOADED" }),
json!({ "dimStyle": "Missing" }),
json!({ "textHeight": -1 }),
json!({ "precision": 9 }),
] {
let mut p = bad.clone();
p["handles"] = json!([b.hex()]);
assert!(s.execute("properties.set", &p).is_err(), "{bad}");
}
// CHPROP's JSON form is properties.set.
let r = s.execute("chprop", &json!({ "handles": [b.hex()], "color": 4 })).unwrap();
assert_eq!(counts(&r), (Some(0), Some(1)), "{r}");
assert_eq!(r["message"], "1 object was on a locked layer. Nothing was changed.");
}
/// While a reference is edited in place, only its working set changes; before, an edit that
/// reached outside it was refused whole.
#[test]
fn during_refedit_objects_outside_the_working_set_are_left_out() {
let mut s = Session::new();
let l = line(&mut s, 0.0);
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l.hex()], "keep": "delete" })).unwrap();
let kind = EntityKind::Insert(Insert {
block: "B".into(),
insert: Vec3::new(100.0, 0.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
});
let a = s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap();
let other = line(&mut s, 20.0);
let r = s.execute("refedit", &json!({ "handle": a.hex() })).unwrap();
let copy = handle(&json!({ "handle": r["working"][0] }));
let r = s.execute("properties.set", &json!({ "handles": [copy.hex(), other.hex()], "color": 5 })).unwrap();
assert_eq!(counts(&r), (Some(1), Some(1)), "{r}");
assert_eq!(r["message"], "1 object was not in the working set.");
let d = s.doc().unwrap();
assert_eq!(d.entity(copy).unwrap().common.color, Color::Index(5));
assert_eq!(d.entity(other).unwrap().common.color, Color::ByLayer);
assert!(d.refedit.is_some(), "the edit goes on");
// With only objects outside the working set, nothing changes: no layer, no undo step.
let undo = s.state().unwrap().undo.len();
let r = s.execute("properties.set", &json!({ "handles": [other.hex()], "color": 6, "layer": "NEW" })).unwrap();
assert_eq!(counts(&r), (Some(0), Some(1)), "{r}");
assert_eq!(r["message"], "1 object was not in the working set. Nothing was changed.");
assert_eq!(s.doc().unwrap().entity(other).unwrap().common.color, Color::ByLayer);
assert!(s.doc().unwrap().layer("NEW").is_none(), "no layer is made");
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step");
// A handle that names no object isn't counted as outside the working set: it's refused.
assert!(s.execute("properties.set", &json!({ "handles": [copy.hex(), "FFFFF"], "color": 6 })).is_err());
assert_eq!(s.doc().unwrap().entity(copy).unwrap().common.color, Color::Index(5));
}
-285
View File
@@ -1,285 +0,0 @@
//! An OLE2FRAME is window-selectable (it counts in the extents, #2422): MOVE and COPY carry it by
//! the offset, and the transforms that would not keep it axis aligned are refused before anything
//! reports success (review on #2587).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Common, Drawing, EntityKind, RawTag, Space, Unknown};
use serde_json::json;
fn session() -> (Session, String) {
let tag = |code: i32, v: &str| RawTag { code, value: v.into() };
let mut d = Drawing::new_imperial();
let kind =
EntityKind::Unknown(Unknown { dxf_type: "OLE2FRAME".into(), tags: vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")] });
let h = d.add(&Space::Model, Common::default(), kind).unwrap();
let mut s = Session::empty();
s.open_drawing(d, "ole.dxf", None);
(s, h.hex())
}
fn corners(s: &Session) -> Vec<(i32, String)> {
let d = s.doc().unwrap();
d.model
.iter()
.filter_map(|e| {
if let EntityKind::Unknown(u) = &e.kind { Some(u.tags.iter().map(|t| (t.code, t.value.clone())).collect::<Vec<_>>()) } else { None }
})
.flatten()
.collect()
}
#[test]
fn move_and_copy_carry_an_ole_frame() {
let (mut s, h) = session();
s.execute("move", &json!({ "handles": [h], "delta": [10, 5] })).unwrap();
let want: Vec<(i32, String)> = [(10, "40"), (20, "-14"), (11, "45"), (21, "-17")].iter().map(|(c, v)| (*c, v.to_string())).collect();
assert_eq!(corners(&s), want);
s.execute("copy", &json!({ "handles": [h], "delta": [10, 5] })).unwrap();
let all = corners(&s);
assert_eq!(all.len(), 8);
assert_eq!(all[4..].iter().map(|(_, v)| v.as_str()).collect::<Vec<_>>(), ["50", "-9", "55", "-12"]);
}
#[test]
fn rotate_and_mirror_refuse_an_ole_frame() {
let (mut s, h) = session();
let before = corners(&s);
for (cmd, p) in
[("rotate", json!({ "handles": [h], "base": [0, 0], "angle": 30 })), ("mirror", json!({ "handles": [h], "p1": [0, 0], "p2": [1, 0] }))]
{
let e = s.execute(cmd, &p).expect_err(cmd);
assert!(e.to_string().contains("OLE object"), "{cmd}: {e}");
}
assert_eq!(corners(&s), before);
assert_eq!(s.doc().unwrap().model.len(), 1, "nothing was copied");
}
/// The OLE data's marker and four corners (x, y, z doubles each), as hex split into chunks.
fn data_session(chunks: &[usize], hex_override: Option<&str>) -> (Session, String) {
let tag = |code: i32, v: &str| RawTag { code, value: v.into() };
let mut blob = vec![1u8, 0];
for (x, y) in [(30.0f64, -19.0f64), (35.0, -19.0), (35.0, -22.0), (30.0, -22.0)] {
for v in [x, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let hex: String = hex_override.map(str::to_string).unwrap_or_else(|| blob.iter().map(|b| format!("{b:02X}")).collect());
let mut tags = vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")];
let mut at = 0;
for &n in chunks {
let end = (at + n).min(hex.len());
tags.push(tag(310, &hex[at..end]));
at = end;
}
if at < hex.len() {
tags.push(tag(310, &hex[at..]));
}
let mut d = Drawing::new_imperial();
let h = d.add(&Space::Model, Common::default(), EntityKind::Unknown(Unknown { dxf_type: "OLE2FRAME".into(), tags })).unwrap();
let mut s = Session::empty();
s.open_drawing(d, "ole.dxf", None);
(s, h.hex())
}
fn data_tags(s: &Session) -> Vec<String> {
let d = s.doc().unwrap();
d.model
.iter()
.filter_map(|e| {
if let EntityKind::Unknown(u) = &e.kind {
Some(u.tags.iter().filter(|t| t.code == 310).map(|t| t.value.clone()).collect::<Vec<_>>())
} else {
None
}
})
.flatten()
.collect()
}
#[test]
fn move_also_moves_the_corners_in_the_ole_data() {
let (mut s, h) = data_session(&[10, 100, 40], None);
let before = data_tags(&s);
s.execute("move", &json!({ "handles": [h], "delta": [10, 5] })).unwrap();
let after = data_tags(&s);
assert_eq!(after.iter().map(String::len).collect::<Vec<_>>(), before.iter().map(String::len).collect::<Vec<_>>(), "chunk lengths are kept");
let hex: String = after.concat();
let bytes: Vec<u8> = (0..hex.len() / 2).map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).unwrap()).collect();
let f = |o: usize| f64::from_le_bytes(bytes[o..o + 8].try_into().unwrap());
let want = [(40.0, -14.0), (45.0, -14.0), (45.0, -17.0), (40.0, -17.0)];
for (c, (x, y)) in want.iter().enumerate() {
assert_eq!((f(2 + c * 24), f(10 + c * 24), f(18 + c * 24)), (*x, *y, 7.0), "corner {c}");
}
}
#[test]
fn move_leaves_malformed_or_short_ole_data_alone() {
for (chunks, hex) in [(vec![4], "0100"), (vec![3, 3], "ABCDE"), (vec![10], "ZZ0100ZZ0100"), (vec![2], "é")] {
let (mut s, h) = data_session(&chunks, Some(hex));
let before = data_tags(&s);
s.execute("move", &json!({ "handles": [h], "delta": [10, 5] })).unwrap();
assert_eq!(data_tags(&s), before, "{hex}");
assert_eq!(corners(&s)[0].1, "40", "the corner groups still move");
}
}
/// OLE data whose bytes are not this frame's corners (an unknown header, so the layout does not
/// apply) is not rewritten by MOVE; the frame's own corner groups still move (review on #2587).
#[test]
fn move_leaves_ole_data_that_is_not_the_frames_corners_alone() {
let mut blob = vec![9u8, 9];
for (x, y) in [(1.0f64, 2.0f64), (3.0, 2.0), (3.0, 4.0), (1.0, 4.0)] {
for v in [x, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let hex: String = blob.iter().map(|b| format!("{b:02X}")).collect();
let (mut s, h) = data_session(&[40, 100], Some(&hex));
let before = data_tags(&s);
s.execute("move", &json!({ "handles": [h], "delta": [10, 5] })).unwrap();
assert_eq!(data_tags(&s), before);
assert_eq!(corners(&s)[0].1, "40", "the corner groups still move");
}
#[test]
fn mocoro_refuses_before_it_changes_anything() {
let (mut s, h) = session();
let before = corners(&s);
let undo = s.state().unwrap().undo.len();
let ops = json!([{ "move": [10, 5] }, { "copy": [20, 0] }, { "rotate": 30 }]);
let e = s.execute("mocoro", &json!({ "handles": [h], "base": [0, 0], "ops": ops })).expect_err("rotate");
assert!(e.to_string().contains("OLE object"), "{e}");
assert_eq!(corners(&s), before, "the earlier move and copy were not applied");
assert_eq!(s.doc().unwrap().model.len(), 1);
assert_eq!(s.state().unwrap().undo.len(), undo);
}
#[test]
fn a_locked_ole_frame_does_not_block_an_in_place_rotation() {
let tag = |code: i32, v: &str| RawTag { code, value: v.into() };
let mut d = Drawing::new_imperial();
d.ensure_layer("L");
let common = Common { layer: "L".into(), ..Common::default() };
let kind =
EntityKind::Unknown(Unknown { dxf_type: "OLE2FRAME".into(), tags: vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")] });
let ole = d.add(&Space::Model, common, kind).unwrap();
if let Some(l) = d.layer_mut("L") {
l.locked = true;
}
let mut s = Session::empty();
s.open_drawing(d, "ole.dxf", None);
let ln = s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
let ln = ln["handle"].as_str().or_else(|| ln["handles"][0].as_str()).unwrap().to_string();
let before = corners(&s);
s.execute("rotate", &json!({ "handles": [ln.clone(), ole.hex()], "base": [0, 0], "angle": 90 })).unwrap();
assert_eq!(corners(&s), before, "the locked frame is left alone");
let d = s.doc().unwrap();
let e = d.entity(cadcraft_engine::doc::Handle::parse_hex(&ln).unwrap()).unwrap();
let cadcraft_engine::doc::EntityKind::Line(l) = &e.kind else { panic!("line") };
assert!((l.b.y - 10.0).abs() < 1e-9 && l.b.x.abs() < 1e-9, "{l:?}");
}
/// The four corners (x, y) in the OLE data header, read back from the 310 groups.
fn data_corners(s: &Session) -> Vec<(f64, f64)> {
let hex: String = data_tags(s).concat();
let bytes: Vec<u8> = (0..hex.len() / 2).map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).unwrap()).collect();
let f = |o: usize| f64::from_le_bytes(bytes[o..o + 8].try_into().unwrap());
(0..4).map(|c| (f(2 + c * 24), f(10 + c * 24))).collect()
}
fn line_in(s: &mut Session) -> String {
let ln = s.execute("line", &json!({ "points": [[1, 2], [4, 6]] })).unwrap();
ln["handle"].as_str().or_else(|| ln["handles"][0].as_str()).unwrap().to_string()
}
fn line_ends(s: &Session, h: &str) -> ((f64, f64), (f64, f64)) {
let e = s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(h).unwrap()).unwrap();
let EntityKind::Line(l) = &e.kind else { panic!("line") };
((l.a.x, l.a.y), (l.b.x, l.b.y))
}
/// SCALE with a uniform positive factor carries an OLE frame with the rest of the selection: the
/// corner groups and the corners in the OLE data are both mapped (review on #2587).
#[test]
fn scale_carries_an_ole_frame_with_the_rest_of_the_selection() {
let (mut s, h) = data_session(&[10, 100, 40], None);
let ln = line_in(&mut s);
s.execute("scale", &json!({ "handles": [ln.clone(), h], "base": [0, 0], "factor": 2 })).unwrap();
assert_eq!(line_ends(&s, &ln), ((2.0, 4.0), (8.0, 12.0)));
let want: Vec<(i32, String)> = [(10, "60"), (20, "-38"), (11, "70"), (21, "-44")].iter().map(|(c, v)| (*c, v.to_string())).collect();
let tags: Vec<(i32, String)> = corners(&s).into_iter().filter(|(c, _)| *c != 310).collect();
assert_eq!(tags, want);
assert_eq!(data_corners(&s), vec![(60.0, -38.0), (70.0, -38.0), (70.0, -44.0), (60.0, -44.0)]);
}
/// MOCORO with a scale step carries the frame too, like SCALE.
#[test]
fn mocoro_scales_an_ole_frame() {
let (mut s, h) = data_session(&[10, 100, 40], None);
s.execute("mocoro", &json!({ "handles": [h], "base": [0, 0], "ops": [{ "scale": 3 }] })).unwrap();
assert_eq!(data_corners(&s)[0], (90.0, -57.0));
assert_eq!(corners(&s)[0].1, "90");
}
/// ARRAYPATH with align rotates each copy, which an OLE frame refuses. The refusal comes before
/// the first copy is added: an error does not roll the drawing back (review on #2587).
#[test]
fn arraypath_refuses_before_adding_any_copy() {
let (mut s, h) = session();
// Straight for 30 units, then a right-angle bend: the first copies only move, a later one turns.
let path = s.execute("pline", &json!({ "points": [[0, 0], [30, 0], [30, 30]] })).unwrap();
let path = path["handle"].as_str().or_else(|| path["handles"][0].as_str()).unwrap().to_string();
let count = s.doc().unwrap().model.len();
let before = corners(&s);
let e = s.execute("arraypath", &json!({ "handles": [h], "path": path, "count": 7, "align": true })).expect_err("align");
assert!(e.to_string().contains("OLE object"), "{e}");
assert_eq!(s.doc().unwrap().model.len(), count, "no copy was added");
assert_eq!(corners(&s), before);
}
/// A refused command leaves the drawing and its undo history alone: UNDO afterwards takes back the
/// earlier MOVE, not a phantom step (review on #2587).
#[test]
fn a_refused_rotation_adds_no_undo_step() {
let (mut s, h) = session();
let original = corners(&s);
s.execute("move", &json!({ "handles": [h.clone()], "delta": [10, 5] })).unwrap();
let undo = s.state().unwrap().undo.len();
s.execute("rotate", &json!({ "handles": [h], "base": [0, 0], "angle": 30 })).expect_err("rotate");
assert_eq!(s.state().unwrap().undo.len(), undo);
s.execute("undo", &json!({})).unwrap();
assert_eq!(corners(&s), original);
}
/// The classic -ARRAY makes separate copies: a polar array that turns its items refuses an OLE
/// frame before the first copy, and one that keeps the items upright carries it.
#[test]
fn classic_polar_array_refuses_a_turned_frame_but_not_an_upright_one() {
let (mut s, h) = session();
let before = corners(&s);
let turned = json!({ "handles": [h.clone()], "type": "polar", "center": [0, 0], "count": 4, "rotate": true });
let e = s.execute("-array", &turned).expect_err("turned items");
assert!(e.to_string().contains("OLE object"), "{e}");
assert_eq!((s.doc().unwrap().model.len(), corners(&s)), (1, before));
let upright = json!({ "handles": [h], "type": "polar", "center": [0, 0], "count": 4, "rotate": false });
s.execute("-array", &upright).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 4);
}
/// The frame is picked by its outline but is not geometry for other commands: a line across it is
/// not cut at its edges by TRIM, and object snap near its corner offers no frame point (review on
/// #2587).
#[test]
fn ole_frame_is_not_a_trim_edge_or_snap_target() {
use cadcraft_engine::snap::{mode, osnap};
let (mut s, _frame) = session();
let ln = s.execute("line", &json!({ "points": [[28, -20.5], [37, -20.5]] })).unwrap();
let ln = ln["handle"].as_str().or_else(|| ln["handles"][0].as_str()).unwrap().to_string();
// No cutting edge is named, so every object but the line itself is one.
let _ = s.execute("trim", &json!({ "handle": ln.clone(), "pick": [32, -20.5] }));
assert_eq!(line_ends(&s, &ln), ((28.0, -20.5), (37.0, -20.5)), "the line is not cut at the frame");
assert_eq!(s.doc().unwrap().model.len(), 2);
let all = mode::END | mode::MID | mode::INT | mode::PER | mode::CEN | mode::QUA | mode::NOD;
let hit = osnap(s.doc().unwrap(), &Space::Model, cadcraft_engine::geom::Vec2::new(30.0, -19.0), 0.5, all, None, false);
assert!(hit.is_none(), "no snap point on the frame: {hit:?}");
}
-69
View File
@@ -1,69 +0,0 @@
//! PLOT chooses the output format from the extension of `path` when no device is named (#2556),
//! like EXPORT does; a device that writes another format is refused.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::cmd::plotters::{PlotterStore, set_plotter_store};
use serde_json::json;
fn session() -> Session {
set_plotter_store(PlotterStore::memory());
set_io(IoHooks {
read: |_, _| Err("no reader in this test".into()),
write: |_, _| Err("no writer in this test".into()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
let mut s = Session::new();
s.cmdline("line 0,0 100,0").unwrap();
s.cmdline("").unwrap();
s
}
fn magic(path: &std::path::Path) -> Vec<u8> {
std::fs::read(path).unwrap().into_iter().take(5).collect()
}
#[test]
fn the_extension_picks_png_svg_or_pdf() {
let mut s = session();
let dir = std::env::temp_dir().join(format!("cadcraft-plot-format-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cases: [(&str, &[u8], &str); 4] =
[("a.png", b"\x89PNG\r", "png"), ("b.svg", b"<svg ", "svg"), ("c.pdf", b"%PDF-", "pdf"), ("D.PNG", b"\x89PNG\r", "png")];
for (name, head, format) in cases {
let out = dir.join(name);
let r = s.execute("plot", &json!({ "path": out.to_string_lossy(), "layout": "Model" })).unwrap();
assert_eq!(r["format"], format, "{name}: the reply names the format");
assert_eq!(magic(&out), head, "{name}: the file holds that format");
}
// No path: PDF data as before. An extension that names no format is refused, like EXPORT.
assert_eq!(s.execute("plot", &json!({ "layout": "Model" })).unwrap()["format"], "pdf");
let other = dir.join("e.plt");
let e = s.execute("plot", &json!({ "path": other.to_string_lossy(), "layout": "Model" })).unwrap_err().to_string();
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
assert!(!other.exists(), "nothing is written under a name that is not a format");
// EXPORTPDF always writes PDF.
let pdf = dir.join("f.png");
s.execute("exportpdf", &json!({ "path": pdf.to_string_lossy(), "layout": "Model" })).unwrap();
assert_eq!(magic(&pdf), b"%PDF-");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_device_that_disagrees_with_the_extension_is_refused() {
let mut s = session();
let dir = std::env::temp_dir().join(format!("cadcraft-plot-format-mismatch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("m.pdf");
let e = s.execute("plot", &json!({ "path": out.to_string_lossy(), "layout": "Model", "device": "CADCraft PNG" })).unwrap_err();
assert!(e.to_string().contains("CADCraft PNG") && e.to_string().contains("pdf"), "{e}");
assert!(!out.exists(), "nothing is written");
// A device that agrees works, and "None" leaves the extension in charge.
let ok = dir.join("m.png");
let r = s.execute("plot", &json!({ "path": ok.to_string_lossy(), "layout": "Model", "device": "CADCraft PNG" })).unwrap();
assert_eq!((r["format"].as_str(), magic(&ok)), (Some("png"), b"\x89PNG\r".to_vec()));
let none = dir.join("n.svg");
s.execute("plot", &json!({ "path": none.to_string_lossy(), "layout": "Model", "device": "None" })).unwrap();
assert_eq!(magic(&none), b"<svg ");
std::fs::remove_dir_all(&dir).ok();
}
@@ -115,67 +115,6 @@ fn solids_whose_geometry_the_data_store_keeps_are_not_reported_from_2013() {
assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 2), ("REGION".to_string(), 1)].into_iter().collect());
}
/// Another program's proxy entity reaches a 2013 or later DWG with the class its group 91 names;
/// before 2013, or when the file has no such class, the DWG leaves it out and says so.
#[test]
fn proxy_entities_are_reported_only_before_2013() {
use cadcraft_engine::cmd::file::{Version, not_saved_as};
use cadcraft_engine::doc::foreign::Foreign;
let proxy = |class: i64| {
EntityKind::Unknown(Unknown {
dxf_type: "ACAD_PROXY_ENTITY".into(),
tags: vec![
RawTag { code: 8, value: "0".into() },
RawTag { code: 90, value: "498".into() },
RawTag { code: 91, value: class.to_string() },
],
})
};
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), proxy(500)).unwrap();
d.add(&Space::Model, Common::default(), unknown("MLINE")).unwrap();
d.foreign = std::sync::Arc::new(Foreign { class_order: vec!["MYAPP_THING".into()], ..Foreign::default() });
for v in [Version::R2013, Version::R2018] {
assert_eq!(not_saved_as(&d, v), [("MLINE".to_string(), 1)].into_iter().collect(), "{v:?}");
}
for v in [Version::R2004, Version::R2010] {
assert_eq!(not_saved_as(&d, v), [("ACAD_PROXY_ENTITY".to_string(), 1), ("MLINE".to_string(), 1)].into_iter().collect(), "{v:?}");
}
// A class number the file's CLASSES don't have, or none at all: the DWG can't name the class,
// leaves the proxy out, and says so from 2013 on too.
d.add(&Space::Model, Common::default(), proxy(501)).unwrap();
d.add(&Space::Model, Common::default(), unknown("ACAD_PROXY_ENTITY")).unwrap();
assert_eq!(not_saved_as(&d, Version::R2018), [("ACAD_PROXY_ENTITY".to_string(), 2), ("MLINE".to_string(), 1)].into_iter().collect());
}
/// Another program's proxy objects are left out of a DWG the same way as its proxy entities, and
/// said so: before 2013 always, from 2013 when the file names no class for them (#2471 review).
#[test]
fn proxy_objects_are_reported_like_proxy_entities() {
use cadcraft_engine::cmd::file::{Version, not_saved_as};
use cadcraft_engine::doc::Handle;
use cadcraft_engine::doc::foreign::{Foreign, ForeignObject};
let object = |h: u64, class: i64| ForeignObject {
handle: Handle(h),
kind: "ACAD_PROXY_OBJECT".into(),
tags: vec![
RawTag { code: 5, value: format!("{h:X}") },
RawTag { code: 90, value: "499".into() },
RawTag { code: 91, value: class.to_string() },
],
};
let mut d = Drawing::new_imperial();
d.foreign = std::sync::Arc::new(Foreign {
class_order: vec!["MYAPP_DATA".into()],
objects: vec![object(0x500, 500), object(0x501, 501)],
..Foreign::default()
});
let lost = |v| not_saved_as(&d, v).get("ACAD_PROXY_OBJECT").copied().unwrap_or(0);
assert_eq!(lost(Version::R2004), 2, "before 2013 every proxy object is left out");
assert_eq!(lost(Version::R2010), 2);
assert_eq!(lost(Version::R2018), 1, "from 2013 only the one whose class the file doesn't name");
}
/// A DXF of 2013 or later can't hold a solid's SAT-text geometry (it lives in the data store
/// there), so the save leaves it out and says so; 2010 and earlier keep it (#2334).
#[test]
+5 -5
View File
@@ -3,10 +3,10 @@
//! empty, `CRAFT_FONTS` is empty and the build is unchanged. It only reads the local checkout
//! (no network). A bad checkout is a warning, or an error with `CRAFT_FONTS_REQUIRED=1`.
//!
//! On wasm32 it embeds only BIZ UDPGothic Regular, the `Arab` and `Geor` faces and the `Thai`
//! Regular faces (about 6.1 MB), to keep the web build within hosting limits (Cloudflare Pages:
//! 25 MiB per file). The web build has no system fonts, so these are its only fallback for
//! Japanese, Arabic-script, Georgian and Thai text.
//! On wasm32 it embeds only BIZ UDPGothic Regular, the `Arab` faces and the `Thai` Regular faces
//! (about 5.9 MB), to keep the web build within hosting limits (Cloudflare Pages: 25 MiB per
//! file). The web build has no system fonts, so these are its only fallback for Japanese,
//! Arabic-script and Thai text.
use std::fmt::Write as _;
use std::path::PathBuf;
@@ -56,7 +56,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
continue;
}
let has = |script: &str| scripts.split(',').any(|s| s.trim() == script);
let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || has("Arab") || has("Geor") || (has("Thai") && *style == "Regular");
let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || has("Arab") || (has("Thai") && *style == "Regular");
if wasm && !web_face {
continue;
}
+2 -3
View File
@@ -1,8 +1,8 @@
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
//!
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, `build.rs` embeds the fonts listed in its
//! `fonts/manifest.txt` (web builds: only BIZ UDPGothic Regular, the Arabic and Georgian faces and
//! the Thai Regular faces). Without it [`CRAFT_FONTS`] is empty and nothing changes. The font table ([`crate::ttf`])
//! `fonts/manifest.txt` (web builds: only BIZ UDPGothic Regular, the Arabic faces and the Thai
//! Regular faces). Without it [`CRAFT_FONTS`] is empty and nothing changes. The font table ([`crate::ttf`])
//! lists the embedded fonts with the installed ones, under [`CraftFont::stem`]. A text style
//! can name one (`BIZ UDPGothic`), and they are fallbacks for characters a text's font lacks.
//! They are copied out of the binary only when first used.
@@ -85,7 +85,6 @@ mod tests {
Some("Arab") => '\u{0628}',
Some("Thai") => '\u{0E01}',
Some("Hang" | "Kore") => '가',
Some("Geor") => 'ა',
_ => 'A',
};
assert!(face.is_some_and(|x| x.has_glyph(sample)), "{} lacks {sample}", f.family);
+2 -18
View File
@@ -41,8 +41,8 @@ use cadcraft_geom::Vec2;
pub use compile::compile;
pub use table::{
FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, installed, register, search_path, set_font_dirs,
set_font_map, set_provider, set_search_path,
FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, register, search_path, set_font_map, set_provider,
set_search_path,
};
pub use text::ShxText;
pub(crate) use text::{shape, width};
@@ -128,9 +128,6 @@ pub struct ShxFont {
data: Vec<u8>,
records: HashMap<u16, Record>,
cache: RwLock<GlyphCache>,
/// Whether a one-byte font holds Hebrew letters at their Windows-1255 / DOS 862 places,
/// worked out on first use ([`ShxFont::hebrew_places`]).
hebrew: std::sync::OnceLock<(bool, bool)>,
}
impl std::fmt::Debug for ShxFont {
@@ -197,19 +194,6 @@ impl ShxFont {
pub fn has_shape(&self, number: u16) -> bool {
number != 0 && self.records.contains_key(&number)
}
/// Whether this one-byte font draws Hebrew letters at their Windows-1255 places (0xE0..0xFA)
/// and at their DOS 862 places (0x80..0x9A), as Israeli SHX fonts do. A Cyrillic font fills
/// the same places with its letters, so it must not also fill 0xC0..0xDF (Windows-1251) or
/// 0xA0..0xAF (DOS 866): Hebrew text never turns into Russian.
pub fn hebrew_places(&self) -> (bool, bool) {
*self.hebrew.get_or_init(|| {
if self.kind != ShxKind::Shapes {
return (false, false);
}
let count = |r: std::ops::RangeInclusive<u16>| r.filter(|c| self.has_shape(*c)).count();
(count(0xE0..=0xFA) >= 20 && count(0xC0..=0xDF) < 16, count(0x80..=0x9A) >= 20 && count(0xA0..=0xAF) < 8)
})
}
/// The shape numbers in the file, sorted (not shape 0).
pub fn shape_numbers(&self) -> Vec<u16> {
let mut v: Vec<u16> = self.records.keys().copied().filter(|n| *n != 0).collect();
-1
View File
@@ -127,7 +127,6 @@ impl Font {
data,
records: self.records,
cache: RwLock::default(),
hebrew: std::sync::OnceLock::new(),
})
}
}
+3 -39
View File
@@ -5,8 +5,8 @@
//! Order: the font map ([`set_font_map`]) renames the file; then fonts registered by the app
//! ([`register`]: the web build's files opened alongside a drawing, tests); then the app's
//! [`FontProvider`]; then, natively, the folders of opened drawings ([`add_drawing_dir`], most
//! recent first), the user's font folders ([`set_font_dirs`], from Options) and the support
//! search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at start). Results, found or not, are cached until one of these changes ([`generation`]
//! recent first) and the support search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at
//! start). Results, found or not, are cached until one of these changes ([`generation`]
//! counts the changes, so a viewer knows to regenerate).
use std::collections::HashMap;
@@ -38,7 +38,6 @@ struct Table {
loaded_bytes: usize,
provider: Option<Arc<dyn FontProvider>>,
drawing_dirs: Vec<PathBuf>,
font_dirs: Vec<PathBuf>,
search_path: Vec<PathBuf>,
map: Vec<(String, String)>,
generation: u64,
@@ -136,15 +135,6 @@ pub fn drawing_dirs() -> Vec<PathBuf> {
table().drawing_dirs.clone()
}
/// Search the user's font folders (and the folders under them that hold SHX files, as the
/// font list finds them) after the drawings' folders. Looks again even when the folders are the
/// same: files may have been added.
pub fn set_font_dirs(dirs: Vec<PathBuf>) {
let mut t = table();
t.font_dirs = dirs.into_iter().filter(|d| !d.as_os_str().is_empty()).take(MAX_DIRS).collect();
t.changed();
}
/// Set the support search path for SHX files: folders separated by `;` (or the platform's
/// path separator). Empty clears it.
pub fn set_search_path(list: &str) {
@@ -206,7 +196,7 @@ pub fn find(name: &str) -> Option<Arc<ShxFont>> {
if let Some(r) = t.resolved.get(&k) {
return r.clone();
}
let dirs: Vec<PathBuf> = t.drawing_dirs.iter().chain(t.font_dirs.iter()).chain(t.search_path.iter()).cloned().collect();
let dirs: Vec<PathBuf> = t.drawing_dirs.iter().chain(t.search_path.iter()).cloned().collect();
(t.provider.clone(), dirs, t.generation)
};
let font = provider
@@ -238,32 +228,6 @@ pub fn find(name: &str) -> Option<Arc<ShxFont>> {
#[cfg(not(target_arch = "wasm32"))]
const MAX_DIR_ENTRIES: usize = 10_000;
/// The SHX fonts in the user's font folders ([`set_font_dirs`]) and the registered ones, by file
/// stem, sorted without repeats (for font lists). Not read: only their names are listed.
pub fn installed() -> Vec<String> {
let (dirs, mut out): (Vec<PathBuf>, Vec<String>) = {
let t = table();
(t.font_dirs.clone(), t.registered.keys().map(|k| k.trim_end_matches(".shx").to_string()).collect())
};
#[cfg(not(target_arch = "wasm32"))]
for d in &dirs {
let Ok(entries) = std::fs::read_dir(d) else { continue };
for e in entries.take(MAX_DIR_ENTRIES).filter_map(Result::ok) {
let p = e.path();
if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("shx"))
&& let Some(stem) = p.file_stem().and_then(|s| s.to_str())
{
out.push(stem.to_string());
}
}
}
#[cfg(target_arch = "wasm32")]
let _ = dirs;
out.sort_by_key(|n| n.to_lowercase());
out.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
out
}
/// The file `name` (a lower-case key) in the first of `dirs` that has it, whatever the case of
/// its name: lower-case, upper-case, else any other case found in the directory (`Simplex.shx`
/// on a case-sensitive file system).
-35
View File
@@ -448,38 +448,3 @@ fn arcs_follow_the_circle() {
assert!(close(g.advance, (dir(135.0 + 180.0) - dir(135.0)) * 5.0));
let _ = PI;
}
/// A one-byte font with Hebrew letters at `codes`, each shape advancing by a different amount
/// (its code's low byte in units), and a font record 6 units high.
fn hebrew_font(codes: impl Iterator<Item = u16>) -> ShxFont {
let mut src = String::from("*0,4,hebrew\n6,2,0,0\n");
for c in codes {
src.push_str(&format!("*0{c:X},3,h\n8,({},0),0\n", c & 0x1F));
}
font(&src)
}
/// Israeli SHX fonts keep Hebrew letters at their Windows-1255 (0xE0..0xFA) or DOS 862
/// (0x80..0x9A) places: Hebrew text draws from them, while a Cyrillic font with letters at the
/// same places is not taken for Hebrew (#2316, rebuilt on #1944).
#[test]
fn hebrew_letters_draw_from_their_one_byte_places() {
let draws = |f: ShxFont, c: char| {
let t = ShxText::new(Some(std::sync::Arc::new(f)), None, "", false);
super::text::width(&t, &c.to_string(), 6.0, 1.0)
};
// Windows-1255: alef at 0xE0 advances 0xE0 & 0x1F = 0 units; bet at 0xE1 one unit.
assert!((draws(hebrew_font(0xE0..=0xFA), 'ב') - 1.0).abs() < 1e-9);
// DOS 862: bet at 0x81 advances one unit.
assert!((draws(hebrew_font(0x80..=0x9A), 'ב') - 1.0).abs() < 1e-9);
assert_eq!(hebrew_font(0x80..=0x9A).hebrew_places(), (false, true));
// The old degree sign at 127 isn't a letter in DOS 862: `%%d` still draws from it
// (127 & 0x1F = 31 units).
assert!((draws(hebrew_font((0x80..=0x9A).chain([127])), '°') - 31.0).abs() < 1e-9);
// A Cyrillic-style font (0xC0..0xFF all drawn) is not read as Hebrew: the letter comes
// from the built-in stroke font instead of a Cyrillic shape.
let cyr = hebrew_font(0xC0..=0xFF);
assert_eq!(cyr.hebrew_places(), (false, false));
let stroke = crate::stroke_advance('ב', 6.0, 1.0);
assert!((draws(cyr, 'ב') - stroke).abs() < 1e-9);
}
-10
View File
@@ -88,11 +88,6 @@ fn candidates(c: char, font: &ShxFont) -> [Option<u16>; 4] {
_ => None,
};
if let Some((s, latin, old)) = special {
// In a DOS 862 Hebrew font 128–129 are letters and 0xB0.. box drawing: shape 256–258,
// and the old degree sign 127 (not a letter there).
if !unicode && font.kind() == ShxKind::Shapes && font.hebrew_places().1 {
return [Some(s), (old == 127).then_some(old), None, None];
}
return if unicode {
[code(u), code(if c == '⌀' { 0x2205 } else { latin }), Some(s), Some(old)]
} else {
@@ -102,11 +97,6 @@ fn candidates(c: char, font: &ShxFont) -> [Option<u16>; 4] {
if unicode || u < 0x80 {
return [code(u), None, None, None];
}
// A Hebrew letter in an Israeli one-byte font: its Windows-1255 or DOS 862 place.
if (0x05D0..=0x05EA).contains(&u) && font.kind() == ShxKind::Shapes {
let (win, dos) = font.hebrew_places();
return [win.then(|| code(u - 0x05D0 + 0xE0)).flatten(), dos.then(|| code(u - 0x05D0 + 0x80)).flatten(), None, None];
}
// A single-byte font: the Windows Latin-1 code of the character (not the old special
// codes 127–129).
let mut buf = [0u8; 4];
-55
View File
@@ -110,61 +110,6 @@ pub(crate) fn glyph(c: char) -> Option<(f64, &'static str)> {
'Ø' | 'ø' | '⌀' => (4.0, "1c0d0h1i3i4h4d3c1c 0c4i"),
// List bullet: a small crossed diamond at mid x-height.
'•' => (2.0, "1d0e1f2e1d 0e2e 1d1f"),
// Original Latin diacritics, composed from our own letter bodies and marks.
'\u{00C0}' => (4.0, concat!("0c2i4c 1f3f", " 1k3j")),
'\u{00C1}' => (4.0, concat!("0c2i4c 1f3f", " 1j3k")),
'\u{00C2}' => (4.0, concat!("0c2i4c 1f3f", " 0j2k4j")),
'\u{00C3}' => (4.0, concat!("0c2i4c 1f3f", " 0j1k3j4k")),
'\u{00C4}' => (4.0, concat!("0c2i4c 1f3f", " 1j1k 3j3k")),
'\u{00C5}' => (4.0, concat!("0c2i4c 1f3f", " 2j1k2l3k2j")),
'\u{00C7}' => (4.0, concat!("4h3i1i0h0d1c3c4d", " 2c3b2a1a")),
'\u{00C8}' => (4.0, concat!("4i0i0c4c 0f3f", " 1k3j")),
'\u{00C9}' => (4.0, concat!("4i0i0c4c 0f3f", " 1j3k")),
'\u{00CA}' => (4.0, concat!("4i0i0c4c 0f3f", " 0j2k4j")),
'\u{00CB}' => (4.0, concat!("4i0i0c4c 0f3f", " 1j1k 3j3k")),
'\u{00CC}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0k2j")),
'\u{00CD}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0j2k")),
'\u{00CE}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0j1k2j")),
'\u{00CF}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0j0k 2j2k")),
'\u{00D1}' => (4.0, concat!("0c0i4c4i", " 0j1k3j4k")),
'\u{00D2}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 1k3j")),
'\u{00D3}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 1j3k")),
'\u{00D4}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 0j2k4j")),
'\u{00D5}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 0j1k3j4k")),
'\u{00D6}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 1j1k 3j3k")),
'\u{00D9}' => (4.0, concat!("0i0d1c3c4d4i", " 1k3j")),
'\u{00DA}' => (4.0, concat!("0i0d1c3c4d4i", " 1j3k")),
'\u{00DB}' => (4.0, concat!("0i0d1c3c4d4i", " 0j2k4j")),
'\u{00DC}' => (4.0, concat!("0i0d1c3c4d4i", " 1j1k 3j3k")),
'\u{00DD}' => (4.0, concat!("0i2f4i 2f2c", " 1j3k")),
'\u{00E0}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 1i3h")),
'\u{00E1}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 1h3i")),
'\u{00E2}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 0h2i4h")),
'\u{00E3}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 0h1i3h4i")),
'\u{00E4}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 1h1i 3h3i")),
'\u{00E5}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 2h1i2j3i2h")),
'\u{00E7}' => (4.0, concat!("4f3g1g0f0d1c3c4d", " 2c3b2a1a")),
'\u{00E8}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 1i3h")),
'\u{00E9}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 1h3i")),
'\u{00EA}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 0h2i4h")),
'\u{00EB}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 1h1i 3h3i")),
'\u{00EC}' => (1.0, concat!("0c0g", " 0i1h")),
'\u{00ED}' => (1.0, concat!("0c0g", " 0h1i")),
'\u{00EE}' => (1.0, concat!("0c0g", " 0h0i1h")),
'\u{00EF}' => (1.0, concat!("0c0g", " 0h0i 1h1i")),
'\u{00F1}' => (4.0, concat!("0c0g 0f1g3g4f4c", " 0h1i3h4i")),
'\u{00F2}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 1i3h")),
'\u{00F3}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 1h3i")),
'\u{00F4}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 0h2i4h")),
'\u{00F5}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 0h1i3h4i")),
'\u{00F6}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 1h1i 3h3i")),
'\u{00F9}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 1i3h")),
'\u{00FA}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 1h3i")),
'\u{00FB}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 0h2i4h")),
'\u{00FC}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 1h1i 3h3i")),
'\u{00FD}' => (4.0, concat!("0g2c 4g1a0a", " 1h3i")),
'\u{00FF}' => (4.0, concat!("0g2c 4g1a0a", " 1h1i 3h3i")),
'\u{0178}' => (4.0, concat!("0i2f4i 2f2c", " 1j1k 3j3k")),
_ => return None,
};
Some(g)
+13 -363
View File
@@ -17,10 +17,6 @@
//! lacks (CJK in an SHX big font, for example), before the automatic system font search.
//!
//! Both start empty; the host sets its starting values ([`set_font_alt`], [`set_fallback_fonts`]).
//!
//! Besides the system font folders, the user's own font folders ([`set_font_dirs`], Options ▸
//! Fonts) are searched first: a font placed there wins over an installed one with the same file
//! name.
use std::collections::HashMap;
use std::path::PathBuf;
@@ -83,16 +79,6 @@ impl Face {
pub fn has_glyph(&self, c: char) -> bool {
self.font().is_some_and(|f| f.charmap().map(c).is_some())
}
/// The family name, as the font gives it (the typographic family, else the family when that
/// is missing or blank).
pub fn family_name(&self) -> Option<String> {
let f = self.font()?;
[skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FAMILY_NAME].into_iter().find_map(|id| {
let s = f.localized_strings(id).next()?.to_string();
Some(s.chars().filter(|c| !c.is_control()).take(80).collect::<String>()).filter(|s| !s.trim().is_empty())
})
}
/// The glyph ids the face's character map gives `chars` (`None` for an unmapped one).
pub(crate) fn glyph_ids(&self, chars: &[char]) -> Vec<Option<u32>> {
let Some(f) = self.font() else { return vec![None; chars.len()] };
@@ -245,28 +231,11 @@ struct Db {
paths: HashMap<String, Source>,
/// Loaded or registered font file bytes, by lower-case file stem.
files: HashMap<String, Arc<Vec<u8>>>,
/// The keys of `files` given by [`register`]: kept over a file of the same stem, and by a
/// rescan.
registered: std::collections::HashSet<String>,
/// Name lookups already answered, found or not.
resolved: HashMap<String, Option<Face>>,
fallback: Fallback,
/// Bytes of font files loaded from disk so far (bounded by `MAX_LOADED_BYTES`).
loaded_bytes: usize,
/// Stems of the fonts found in the user's font folders.
user: std::collections::HashSet<String>,
/// Names read from user font files for lists and family searches, by stem. Reading them
/// does not keep the file's bytes, so it does not use up `MAX_LOADED_BYTES`.
face_names: HashMap<String, Arc<FaceNames>>,
}
/// The names inside one font file.
#[derive(Default)]
struct FaceNames {
/// The first face's family name.
family: Option<String>,
/// Each face's normalized names (family, typographic family, full name).
faces: Vec<Vec<String>>,
}
/// State of the missing-glyph search.
@@ -297,18 +266,10 @@ fn lock() -> MutexGuard<'static, Db> {
let mut g = DB.get_or_init(|| Mutex::new(Db::default())).lock().unwrap_or_else(PoisonError::into_inner);
if !g.scanned {
g.scanned = true;
let user = scan_dirs(&user_dirs(), true);
g.user = user.keys().cloned().collect();
let mut all = scan();
all.extend(user);
g.paths = all.into_iter().map(|(stem, p)| (stem, Source::File(p))).collect();
// Embedded craft-fonts fonts take the place of an installed file with the same stem,
// but not of a font in the user's own folders (which they chose to use).
g.paths = scan().into_iter().map(|(stem, p)| (stem, Source::File(p))).collect();
// Embedded craft-fonts fonts take the place of an installed file with the same stem.
for f in crate::CRAFT_FONTS {
let stem = f.stem();
if !g.user.contains(&stem) {
g.paths.insert(stem, Source::Static(f.bytes));
}
g.paths.insert(f.stem(), Source::Static(f.bytes));
}
}
g
@@ -316,7 +277,7 @@ fn lock() -> MutexGuard<'static, Db> {
/// The platform's system font directories, in search order.
#[cfg(not(target_arch = "wasm32"))]
fn system_font_dirs() -> Vec<PathBuf> {
fn font_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
@@ -343,24 +304,13 @@ fn system_font_dirs() -> Vec<PathBuf> {
dirs
}
/// Every font file under [`system_font_dirs`], by lower-case file stem.
/// Every font file under [`font_dirs`], by lower-case file stem.
#[cfg(not(target_arch = "wasm32"))]
fn scan() -> HashMap<String, PathBuf> {
scan_dirs(&system_font_dirs(), false)
}
/// Font files under `dirs` by lower-case stem; the first file found with a stem wins
/// (`in_order`: a folder listed earlier wins over a later one).
#[cfg(not(target_arch = "wasm32"))]
fn scan_dirs(dirs: &[PathBuf], in_order: bool) -> HashMap<String, PathBuf> {
let mut out = HashMap::new();
let mut stack: Vec<PathBuf> = if in_order { dirs.iter().rev().cloned().collect() } else { dirs.to_vec() };
let mut stack = font_dirs();
let mut seen = 0usize;
let mut visited = std::collections::HashSet::new();
while let Some(d) = stack.pop() {
if !first_visit(&mut visited, &d) {
continue;
}
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
seen += 1;
@@ -368,8 +318,6 @@ fn scan_dirs(dirs: &[PathBuf], in_order: bool) -> HashMap<String, PathBuf> {
return out;
}
let p = e.path();
// Linked folders are followed (font folders often are links); `first_visit` keeps a
// circle of links from reading a folder twice.
if p.is_dir() {
stack.push(p);
continue;
@@ -391,188 +339,6 @@ fn scan() -> HashMap<String, PathBuf> {
HashMap::new()
}
#[cfg(target_arch = "wasm32")]
fn scan_dirs(_dirs: &[PathBuf], _in_order: bool) -> HashMap<String, PathBuf> {
HashMap::new()
}
/// Most user font folders.
pub const MAX_FONT_DIRS: usize = 16;
/// The longest font folder path kept.
const MAX_DIR_CHARS: usize = 1024;
fn dirs_lock() -> MutexGuard<'static, Vec<PathBuf>> {
static DIRS: OnceLock<Mutex<Vec<PathBuf>>> = OnceLock::new();
DIRS.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap_or_else(PoisonError::into_inner)
}
fn user_dirs() -> Vec<PathBuf> {
dirs_lock().clone()
}
/// Counts changes to the set of fonts ([`set_font_dirs`], [`rescan`]): text drawn before a change
/// may have been drawn with another font.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The font set's change count (see [`GENERATION`]).
pub fn generation() -> u64 {
GENERATION.load(std::sync::atomic::Ordering::Relaxed)
}
/// The user's font folders, searched before the system's.
pub fn font_dirs() -> Vec<String> {
user_dirs().iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Clean a font folder list: trimmed, without empty, repeated or over-long entries, at most
/// [`MAX_FONT_DIRS`].
pub fn clean_dirs<S: AsRef<str>>(dirs: &[S]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for d in dirs.iter().take(MAX_FONT_DIRS * 4) {
let d = d.as_ref().trim();
if d.is_empty() || d.chars().count() > MAX_DIR_CHARS || d.chars().any(char::is_control) || out.iter().any(|o| o == d) {
continue;
}
out.push(d.to_string());
if out.len() >= MAX_FONT_DIRS {
break;
}
}
out
}
/// Set the user's font folders (Options ▸ Fonts) and look for fonts again.
pub fn set_font_dirs<S: AsRef<str>>(dirs: &[S]) {
let dirs: Vec<PathBuf> = clean_dirs(dirs).into_iter().map(PathBuf::from).collect();
{
let mut current = dirs_lock();
if *current == dirs {
return;
}
*current = dirs;
}
rescan();
}
/// Forget what was found and look for fonts again (files added to a font folder).
pub fn rescan() {
let mut g = lock();
// Fonts registered by name stay, also where a found file has the same stem; bytes read
// from found files go.
let paths = std::mem::take(&mut g.paths);
let Db { files, registered, .. } = &mut *g;
files.retain(|k, _| registered.contains(k) || !paths.contains_key(k));
g.scanned = false;
g.resolved.clear();
g.fallback = Fallback::default();
g.loaded_bytes = 0;
g.user.clear();
g.face_names.clear();
drop(g);
GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// SHX files in the same folders: the SHX font table looks there too.
crate::shx::set_font_dirs(shx_dirs(&user_dirs()));
}
/// Whether folder `d` is read for the first time (by its real path: a link to a folder already
/// read, or a circle of links, is not read again).
#[cfg(not(target_arch = "wasm32"))]
fn first_visit(visited: &mut std::collections::HashSet<PathBuf>, d: &std::path::Path) -> bool {
visited.insert(std::fs::canonicalize(d).unwrap_or_else(|_| d.to_path_buf()))
}
/// The folders under `dirs` (them included) that hold SHX files, in order, bounded like the
/// font scan.
#[cfg(not(target_arch = "wasm32"))]
fn shx_dirs(dirs: &[PathBuf]) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut stack: Vec<PathBuf> = dirs.iter().rev().cloned().collect();
let mut seen = 0usize;
let mut visited = std::collections::HashSet::new();
while let Some(d) = stack.pop() {
if !first_visit(&mut visited, &d) {
continue;
}
let Ok(rd) = std::fs::read_dir(&d) else { continue };
let mut subs = Vec::new();
let mut has_shx = false;
for e in rd.flatten() {
seen += 1;
if seen > 20_000 {
return out;
}
if e.path().is_dir() {
subs.push(e.path());
} else if e.path().extension().is_some_and(|x| x.eq_ignore_ascii_case("shx")) {
has_shx = true;
}
}
if has_shx && !out.contains(&d) {
out.push(d);
}
stack.extend(subs.into_iter().rev());
}
out
}
#[cfg(target_arch = "wasm32")]
fn shx_dirs(_dirs: &[PathBuf]) -> Vec<PathBuf> {
Vec::new()
}
/// A font file CADCraft can use, for font lists.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FontFile {
/// The name a text style stores: the file name (`arial.ttf`), or the name of a font built
/// into CADCraft.
pub name: String,
/// The family name inside the file, read for fonts in the user's folders.
pub family: Option<String>,
/// Found in one of the user's font folders.
pub user: bool,
}
/// The fonts CADCraft can use: those in the user's folders first (with their family names),
/// then the installed and built-in ones, by name.
pub fn installed() -> Vec<FontFile> {
/// Fonts whose family name is read for the list.
const MAX_NAMED: usize = 200;
let mut g = lock();
let mut list: Vec<(String, String, bool)> = g
.paths
.iter()
.map(|(stem, src)| {
let name = match src {
Source::File(p) => p.file_name().map_or_else(|| stem.clone(), |n| n.to_string_lossy().to_string()),
Source::Static(_) => stem.clone(),
};
(stem.clone(), name, g.user.contains(stem))
})
.collect();
list.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase())));
let mut named = 0usize;
list.into_iter()
.map(|(stem, name, user)| {
let family = if user && named < MAX_NAMED {
named += 1;
g.face_names(&stem).and_then(|n| n.family.clone())
} else {
None
};
FontFile { name, family, user }
})
.collect()
}
/// Whether a text style font is drawn as named: a font CADCraft found, or its own stroke font.
/// Other names (an SHX file, a missing TTF) are drawn with a substitute ([`crate::TextFont::resolve`]).
pub fn available(name: &str) -> bool {
let k = key(name);
// The built-in stroke font is CADCraft's own stand-in for the basic stroke fonts.
let stand_in = matches!(k.trim_end_matches(".shx"), "txt" | "simplex" | "romans");
k.is_empty() || stand_in || k == crate::BUILTIN_FONT.to_ascii_lowercase() || crate::shx::find(name).is_some() || find(name).is_some()
}
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &std::path::Path) -> Option<Vec<u8>> {
/// Font files larger than this are never read.
@@ -597,35 +363,15 @@ impl Db {
if self.loaded_bytes >= MAX_LOADED_BYTES {
return None;
}
let bytes = self.peek(stem)?;
let bytes = Arc::new(match self.paths.get(stem)? {
Source::File(path) => read_file(path)?,
Source::Static(bytes) => bytes.to_vec(),
});
self.loaded_bytes = self.loaded_bytes.saturating_add(bytes.len());
self.files.insert(stem.to_string(), bytes.clone());
Some(bytes)
}
/// Bytes of the font file with this stem, without keeping them or charging the load budget.
fn peek(&self, stem: &str) -> Option<Arc<Vec<u8>>> {
if let Some(b) = self.files.get(stem) {
return Some(b.clone());
}
Some(Arc::new(match self.paths.get(stem)? {
Source::File(path) => read_file(path)?,
Source::Static(bytes) => bytes.to_vec(),
}))
}
/// The names inside the font file with this stem, read once (the bytes are dropped after).
fn face_names(&mut self, stem: &str) -> Option<Arc<FaceNames>> {
if let Some(n) = self.face_names.get(stem) {
return Some(n.clone());
}
let bytes = self.peek(stem)?;
let all = faces(&bytes);
let names = Arc::new(FaceNames { family: all.first().and_then(Face::family_name), faces: all.iter().map(Face::names).collect() });
self.face_names.insert(stem.to_string(), names.clone());
Some(names)
}
fn lookup(&mut self, k: &str) -> Option<Face> {
let candidates = [k.to_string(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))];
for c in &candidates {
@@ -633,28 +379,7 @@ impl Db {
return Some(face);
}
}
self.by_family(k).or_else(|| self.user_family(k))
}
/// A face in the user's font folders whose family or full name is `k`, whatever its file is
/// called (the folders are small; at most 200 files are looked at).
fn user_family(&mut self, k: &str) -> Option<Face> {
let want = norm(k);
if want.is_empty() {
return None;
}
let mut stems: Vec<String> = self.user.iter().cloned().collect();
stems.sort();
for stem in stems.into_iter().take(200) {
let Some(names) = self.face_names(&stem) else { continue };
let Some(i) = names.faces.iter().position(|f| f.iter().any(|n| *n == want || format!("{n}regular") == want)) else { continue };
// Only the file that has the face is kept, charged to the load budget like any other
// (once it is used up, no more faces are loaded). A file that cannot be read now
// (deleted, too large) does not end the search: another file may hold the family.
let Some(bytes) = self.file(&stem) else { continue };
return faces(&bytes).into_iter().nth(i);
}
None
self.by_family(k)
}
/// A face whose family or full name is `k`, among files with a similar stem, then among the
@@ -802,9 +527,7 @@ pub(crate) fn rank_fallback(stems: &[String]) -> Vec<String> {
/// Register font bytes under a name (web builds and tests).
pub fn register(name: &str, bytes: Vec<u8>) {
let mut g = lock();
let k = key(name);
g.files.insert(k.clone(), Arc::new(bytes));
g.registered.insert(k);
g.files.insert(key(name), Arc::new(bytes));
g.resolved.clear();
g.fallback = Fallback::default();
}
@@ -816,7 +539,6 @@ pub(crate) fn register_static(name: &str, bytes: &'static [u8]) {
let mut g = lock();
let k = key(name);
g.files.remove(&k);
g.registered.remove(&k);
g.paths.insert(k, Source::Static(bytes));
g.resolved.clear();
g.fallback = Fallback::default();
@@ -1264,78 +986,6 @@ pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique
mod family_tests {
use super::*;
#[test]
fn font_folders_are_cleaned_and_unknown_shx_names_are_not_available() {
let long = "x".repeat(MAX_DIR_CHARS + 1);
let many: Vec<String> = (0..40).map(|i| format!("/fonts/{i}")).collect();
assert_eq!(clean_dirs(&[" /a ", "", "/a", "/b", long.as_str(), "bad\u{0}dir"]), vec!["/a".to_string(), "/b".to_string()]);
assert_eq!(clean_dirs(&many).len(), MAX_FONT_DIRS);
assert!(!available("gost_cadcraft.shx"));
assert!(available("txt.shx") && available("SIMPLEX.SHX"), "the built-in stroke font stands in for these");
assert!(available(""));
assert!(available(crate::BUILTIN_FONT));
}
/// Reading a user font's names for a list keeps none of its bytes and uses none of the load
/// budget. (Font folders themselves are process-wide: `tests/ttf_font_folders.rs`.)
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn reading_a_user_fonts_names_keeps_no_font_bytes() {
let Some(src) = scan().into_values().find(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("ttf"))) else { return };
let mut db = Db::default();
db.user.insert("face".into());
db.paths.insert("face".into(), Source::File(src));
assert!(db.face_names("face").is_some_and(|n| n.family.is_some()));
assert!(db.files.is_empty() && db.loaded_bytes == 0);
}
/// One unreadable user font file with the family does not stop the search: the next file
/// that has the family is still read.
#[test]
fn an_unreadable_user_font_does_not_end_the_family_search() {
let mut db = Db::default();
let names = || Arc::new(FaceNames { family: Some("Foo".into()), faces: vec![vec!["foo".into()]] });
for stem in ["a", "b"] {
db.user.insert(stem.into());
db.face_names.insert(stem.into(), names());
}
// "a" has no file left on disk; "b" can be read.
db.paths.insert("b".into(), Source::Static(b"not a font"));
let _ = db.user_family("Foo");
assert!(db.files.contains_key("b"), "the second file with the family was tried");
}
/// A face found by family name is loaded within the font-byte budget: once it is used up,
/// none is read past it (#2316 review).
#[test]
fn a_family_search_keeps_to_the_load_budget() {
let mut db = Db::default();
db.user.insert("b".into());
db.face_names.insert("b".into(), Arc::new(FaceNames { family: Some("Foo".into()), faces: vec![vec!["foo".into()]] }));
db.paths.insert("b".into(), Source::Static(b"not a font"));
db.loaded_bytes = MAX_LOADED_BYTES;
assert!(db.user_family("Foo").is_none());
assert!(db.files.is_empty(), "nothing read past the budget");
}
/// Linked font folders are followed, and a circle of links reads each folder once.
#[cfg(unix)]
#[test]
fn linked_font_folders_are_followed_once() {
let root = std::env::temp_dir().join(format!("cadcraft-fontlinks-{}", std::process::id()));
let real = root.join("real");
let _ = std::fs::create_dir_all(&real);
let Ok(()) = std::fs::write(real.join("LinkedFace.ttf"), b"x") else { return };
let _ = std::fs::write(real.join("linked.shx"), b"x");
let _ = std::os::unix::fs::symlink(&real, root.join("link"));
// A link back up: a circle.
let _ = std::os::unix::fs::symlink(&root, real.join("up"));
let found = scan_dirs(&[root.join("link")], true);
assert!(found.contains_key("linkedface"), "{found:?}");
assert_eq!(shx_dirs(std::slice::from_ref(&root)).len(), 1, "the folder with the SHX file, once");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn thai_looks_in_thai_fonts_before_the_general_search() {
// Letters, vowels, tone marks and digits: the general search loads a bounded number of
@@ -1457,7 +1107,7 @@ mod scan_tests {
/// here rather than discovered by a drawing full of missing-glyph boxes.
#[test]
fn the_platforms_system_font_directory_is_searched() {
let dirs = system_font_dirs();
let dirs = font_dirs();
#[cfg(target_os = "macos")]
assert!(dirs.iter().any(|d| d == std::path::Path::new("/System/Library/Fonts")), "{dirs:?}");
#[cfg(target_os = "android")]
-54
View File
@@ -1,54 +0,0 @@
use cadcraft_fonts::{MTextParams, TextFont, char_advance, layout_mtext_with, line_width, shape_stroke};
use cadcraft_geom::Vec2;
#[test]
fn latin_accents_keep_single_strokes_and_the_base_letter_spacing() {
let groups = [
('A', "ÀÁÂÃÄÅ"),
('C', "Ç"),
('E', "ÈÉÊË"),
('I', "ÌÍÎÏ"),
('N', "Ñ"),
('O', "ÒÓÔÕÖ"),
('U', "ÙÚÛÜ"),
('Y', "ÝŸ"),
('a', "àáâãäå"),
('c', "ç"),
('e', "èéêë"),
('i', "ìíîï"),
('n', "ñ"),
('o', "òóôõö"),
('u', "ùúûü"),
('y', "ýÿ"),
];
for (base, accents) in groups {
for c in accents.chars() {
let shaped = shape_stroke(&c.to_string(), 6.0, 0.8, 0.2);
assert!(shaped.glyphs.is_empty(), "{c} must not become a filled system-font outline");
assert!(!shaped.strokes.is_empty());
assert_eq!(char_advance(c), char_advance(base));
assert!((line_width(&c.to_string(), 6.0, 0.8) - shaped.width).abs() < 1e-9);
assert!(shaped.strokes.iter().flatten().all(|p| p.is_finite()));
let next = shape_stroke(&format!("{c}B"), 6.0, 0.8, 0.2);
let plain = shape_stroke(&format!("{base}B"), 6.0, 0.8, 0.2);
assert_eq!(next.width, plain.width, "accent does not shift the next letter");
}
}
for (accent, base) in [('Ç', 'C'), ('Ã', 'A'), ('Á', 'A'), ('Ó', 'O')] {
let plain = shape_stroke(&base.to_string(), 6.0, 1.0, 0.0);
let marked = shape_stroke(&accent.to_string(), 6.0, 1.0, 0.0);
assert!(marked.strokes.starts_with(&plain.strokes), "same letter body for {accent}");
assert!(marked.strokes.len() > plain.strokes.len());
}
}
#[test]
fn portuguese_mtext_wraps_with_consistent_stroke_metrics() {
let text = "A CONCEPÇÃO SERÁ APROVADA APÓS O PROCESSAMENTO DA ESTRUTURA";
let params = MTextParams { width: 32.0, font: TextFont::Stroke, ..MTextParams::new(Vec2::ZERO, 1.0) };
let layout = layout_mtext_with(text, &params);
assert!(layout.lines.len() >= 2);
assert_eq!(layout.lines.join(" "), text);
assert!(layout.pieces.iter().all(|p| p.shaped.glyphs.is_empty()));
assert!(layout.strokes.iter().flatten().all(|p| p.is_finite()));
}
-36
View File
@@ -1,36 +0,0 @@
//! SHX files in the user's font folders (Options ▸ Fonts): found by name, in the folder itself
//! or a folder under it, and gone again when the folder is removed. Process-wide settings, so
//! one test in its own binary. The font is compiled here from shape source written for it.
use cadcraft_fonts::{shx, ttf};
#[test]
fn shx_fonts_in_the_users_font_folders_are_found() {
let bytes = shx::compile("*0,4,folder test\n6,2,0,0\n*65,4,UCA\n1,064,0\n").unwrap();
let dir = std::env::temp_dir().join(format!("cadcraft-shx-folders-{}", std::process::id()));
let sub = dir.join("shx").join("old");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(dir.join("cadcraft-top.shx"), &bytes).unwrap();
std::fs::write(sub.join("cadcraft-deep.shx"), &bytes).unwrap();
assert!(shx::find("cadcraft-top.shx").is_none() && shx::find("cadcraft-deep").is_none());
let g = shx::generation();
ttf::set_font_dirs(&[dir.to_string_lossy().to_string()]);
assert_ne!(shx::generation(), g, "viewers draw the text again");
assert!(shx::find("cadcraft-top.shx").is_some_and(|f| f.has_shape(65)));
assert!(shx::find("CADCRAFT-DEEP").is_some(), "in a folder under the font folder");
assert!(ttf::available("cadcraft-deep.shx"));
// Font lists name them.
let names = shx::installed();
assert!(names.iter().any(|n| n == "cadcraft-top") && names.iter().any(|n| n == "cadcraft-deep"), "{names:?}");
// A file added later is found after Look for Fonts Again.
std::fs::write(dir.join("cadcraft-later.shx"), &bytes).unwrap();
ttf::rescan();
assert!(shx::find("cadcraft-later.shx").is_some());
ttf::set_font_dirs::<&str>(&[]);
assert!(shx::find("cadcraft-top.shx").is_none());
assert!(!shx::installed().iter().any(|n| n.starts_with("cadcraft-")));
let _ = std::fs::remove_dir_all(&dir);
}
-65
View File
@@ -1,65 +0,0 @@
//! TrueType fonts in the user's font folders (Options ▸ Fonts): found by file and family name,
//! listed first, and gone again when the folder is removed. Font folders are process-wide and a
//! change looks for fonts again, so this is one test in its own binary (run beside other tests,
//! it would empty the font table under them).
use std::path::{Path, PathBuf};
use cadcraft_fonts::ttf;
/// A TrueType file from the system's fonts to copy, if there is one (a bounded walk).
fn a_system_ttf() -> Option<PathBuf> {
let mut stack: Vec<PathBuf> =
["C:/Windows/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts", "/usr/share/fonts", "/usr/local/share/fonts"]
.map(PathBuf::from)
.to_vec();
let mut seen = 0;
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
seen += 1;
if seen > 5_000 {
return None;
}
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ttf")) {
return Some(p);
}
}
}
None
}
fn copy_into(src: &Path, dir: &Path) -> bool {
std::fs::create_dir_all(dir).is_ok() && std::fs::copy(src, dir.join("CadcraftTestFace.ttf")).is_ok()
}
#[test]
fn a_font_in_a_user_folder_is_found_listed_and_dropped_with_the_folder() {
let Some(src) = a_system_ttf() else { return };
let dir = std::env::temp_dir().join(format!("cadcraft-fontdir-{}", std::process::id()));
if !copy_into(&src, &dir) {
return;
}
// A font registered by name, with a file of the same stem in the folder: looking for fonts
// again (twice, as the folder comes and goes) keeps the registered bytes.
let Ok(bytes) = std::fs::read(&src) else { return };
let _ = std::fs::copy(&src, dir.join("CadcraftRegistered.ttf"));
ttf::register("CadcraftRegistered.ttf", bytes);
let before = ttf::generation();
ttf::set_font_dirs(&[dir.to_string_lossy().to_string()]);
assert!(ttf::generation() > before);
assert!(ttf::find("CadcraftTestFace.ttf").is_some());
let listed = ttf::installed();
let entry = listed.iter().find(|f| f.name == "CadcraftTestFace.ttf");
assert!(entry.is_some_and(|f| f.user && f.family.is_some()), "{entry:?}");
assert!(listed.first().is_some_and(|f| f.user), "the user's fonts come first");
if let Some(family) = entry.and_then(|f| f.family.clone()) {
assert!(ttf::find(&family).is_some(), "found by its family name: {family}");
}
ttf::set_font_dirs::<&str>(&[]);
assert!(ttf::find("CadcraftTestFace.ttf").is_none());
assert!(ttf::find("CadcraftRegistered.ttf").is_some(), "a registered font outlives a rescan");
let _ = std::fs::remove_dir_all(&dir);
}
+27 -12
View File
@@ -16,7 +16,7 @@ use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::foreign::{Foreign, ForeignExtra, ForeignKey, ForeignObject, class_group, class_index, class_number};
use cadcraft_doc::foreign::{Foreign, ForeignExtra, ForeignKey, ForeignObject};
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, RawTag, Unknown};
use cadcraft_dxf::{Section, Tag};
@@ -452,21 +452,10 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
f.data_store = ds.tags.iter().take(MAX_DATA_STORE_GROUPS).map(raw).collect();
}
if !f.is_empty() {
f.version = file_version(secs);
d.foreign = Arc::new(f);
}
}
/// The `$ACADVER` the file's own HEADER section declares, if it does. Not the drawing's: a new
/// drawing starts as AC1032, and a file without the variable would leave that default there.
fn file_version(secs: &[Section]) -> Option<String> {
let header = secs.iter().find(|s| s.name.eq_ignore_ascii_case("HEADER"))?;
let at = header.tags.iter().position(|t| t.code == 9 && t.str().trim().eq_ignore_ascii_case("$ACADVER"))?;
let value = header.tags.get(at + 1).filter(|t| t.code != 9)?.str();
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn entity_types(drawing: &Drawing) -> HashMap<String, bool> {
let mut types = HashMap::new();
for entity in all_entities(drawing) {
@@ -563,6 +552,32 @@ fn is_proxy(kind: &str) -> bool {
matches!(up(kind).as_str(), "ACAD_PROXY_OBJECT" | "ACAD_PROXY_ENTITY")
}
/// Where a proxy's class number is: its first group 91 outside the `102 {NAME` … `102 }` groups
/// (an application's can hold a 91 of its own) and before any xdata.
fn class_group<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)>) -> Option<usize> {
let mut in_group = false;
for (i, (code, value)) in groups.into_iter().enumerate() {
match code {
1000.. => return None,
102 => in_group = value.as_ref().trim().starts_with('{'),
91 if !in_group => return Some(i),
_ => {}
}
}
None
}
/// A proxy's class number (see [`class_group`]).
fn class_number<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)> + Clone) -> Option<i64> {
let i = class_group(groups.clone())?;
groups.into_iter().nth(i).and_then(|(_, v)| v.as_ref().trim().parse().ok())
}
/// The place in CLASSES a class number names (500 is the first class).
fn class_index(number: Option<i64>) -> Option<usize> {
usize::try_from(number?.checked_sub(500)?).ok()
}
/// Set a proxy's class number (see [`class_group`]).
fn set_class_number(tags: &mut [Tag], n: i64) {
if let Some(t) = class_group(tags.iter().map(|t| (t.code, t.str()))).and_then(|i| tags.get_mut(i)) {
+1 -37
View File
@@ -24,8 +24,7 @@ mod tables;
mod text;
use document::{
apply_sortents, count_skipped, dropped_paper_views, outside_sections, parse_entities, place_blocks, place_entities, read_blocks, read_objects,
setup_layouts,
apply_sortents, count_skipped, outside_sections, parse_entities, place_blocks, place_entities, read_blocks, read_objects, setup_layouts,
};
#[cfg(test)]
use entities::acad_table;
@@ -62,11 +61,6 @@ struct Rx {
block_arrays: HashMap<String, cadcraft_doc::ArrayParams>,
/// LAYER record handle (upper case) → layer name (VIEWPORT group 331/341 frozen layers).
layers: HashMap<String, String>,
/// Owner handle, paper flag and enclosing block (upper case) of each generated sheet viewport
/// (PAPERVIEW) the reader dropped.
paper_views_dropped: Vec<(Option<String>, bool, Option<String>)>,
/// Upper-case name of the BLOCK whose entities are being read, if any.
block: Option<String>,
}
/// Read a DXF file into a drawing.
@@ -132,7 +126,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
let (paper_block_for, first_layout) = setup_layouts(&mut d, &mut layout_objs, &rx);
place_blocks(&mut d, blocks, &mut rx, &paper_block_for, &first_layout);
place_entities(&mut d, entities, &rx, &paper_block_for, &first_layout);
number_unknown_viewport_ids(&mut d, &dropped_paper_views(&rx, &paper_block_for, &first_layout));
apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout);
objs.apply(&mut d, &rx);
crate::dxf_image::resolve(&secs, &mut d);
@@ -151,35 +144,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
Ok(d)
}
/// Viewport ids another program left unknown (ZWCAD writes 69 = -1, read as 0). A layout whose
/// viewports all lack an id gets them numbered in file order, as DWG readers do; when only
/// some lack one and none is 1, the first viewport is the sheet and gets id 1. Either way the
/// sheet keeps id 1, so saving does not add a second sheet viewport ahead of it (#1463). A layout
/// whose generated sheet (PAPERVIEW) the reader just dropped is left alone: none of its viewports
/// is the sheet, and numbering one as 1 would lose the generated sheet on the next save.
fn number_unknown_viewport_ids(d: &mut Drawing, sheet_dropped: &std::collections::HashSet<String>) {
for l in d.layouts.iter_mut().filter(|l| !sheet_dropped.contains(&l.name)) {
let mut vps: Vec<Entity> = l.entities.iter().filter(|e| matches!(&e.kind, EntityKind::Viewport(_))).map(|e| (**e).clone()).collect();
let id = |e: &Entity| if let EntityKind::Viewport(v) = &e.kind { v.id } else { 0 };
if vps.iter().all(|e| id(e) == 0) {
for (i, e) in vps.iter_mut().enumerate() {
if let EntityKind::Viewport(v) = &mut e.kind {
v.id = i as u32 + 1;
}
}
} else if vps.iter().all(|e| id(e) != 1) && vps.first().is_some_and(|e| id(e) == 0) {
if let Some(EntityKind::Viewport(v)) = vps.first_mut().map(|e| &mut e.kind) {
v.id = 1;
}
} else {
continue;
}
for e in vps {
l.entities.replace(e);
}
}
}
/// An entity from the groups of a DXF record of type `kind` (without its `0` group), as the
/// reader makes it, with plain strings as [`crate::entity_groups`] gives them (not the file's
/// `^ ` escapes). AutoLISP's `entmake` and `entmod` are built on it.
+1 -93
View File
@@ -1,4 +1,4 @@
use super::{Color, Common, Lineweight, PolyVertex, Rgb, Tag, Transparency, Vec2, Vec3};
use super::{Color, Common, Lineweight, Rgb, Tag, Transparency, Vec2, Vec3};
/// Value lookup helpers over an entity's tags.
pub(super) struct T<'a>(pub(super) &'a [Tag]);
@@ -112,95 +112,3 @@ pub(super) fn ocs(c: &Common, p: Vec3) -> Vec3 {
pub(super) fn ocs_n(n: Vec3, p: Vec3) -> Vec3 {
if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) }
}
/// Whether an object with OCS points `pts` (x, y and elevation) lies in a plane tilted off the
/// XY plane: drawing its points as plan coordinates (x flipped for a mirrored plane, as the
/// readers do) would put some of them off their place seen from above by more than a
/// billionth of the object's size and distance from the origin. A normal that is the Z axis
/// or the Z axis turned over moves nothing, and noise in one moves points by a negligible
/// amount, so those objects keep their arcs; however small the tilt, an object far enough from
/// the origin to be moved visibly is projected.
pub(super) fn tilted(n: Vec3, pts: impl IntoIterator<Item = Vec3>) -> bool {
if !n.is_finite() || n.len() < 1e-12 {
return false;
}
let m = cadcraft_geom::Mat4::ocs(n);
let flip = if n.z < 0.0 { -1.0 } else { 1.0 };
let (mut moved, mut reach) = (0.0f64, 1.0f64);
for p in pts.into_iter().filter(|p| p.is_finite()) {
let shown = Vec2::new(flip * p.x, p.y);
moved = moved.max((m.apply(p).xy() - shown).len());
reach = reach.max(p.x.abs()).max(p.y.abs()).max(p.z.abs());
}
moved > 1e-9 * reach
}
/// Points along a run of OCS vertices at `elevation`, for [`tilted`]: its vertices and, on its
/// arcs, points between them (an arc can bulge where its ends don't move).
pub(super) fn run_points(vertices: &[PolyVertex], elevation: f64, closed: bool) -> Vec<Vec3> {
let mut pts = Vec::with_capacity(vertices.len());
for (i, v) in vertices.iter().enumerate() {
pts.push(v.p);
let next = if i + 1 < vertices.len() {
vertices.get(i + 1)
} else if closed {
vertices.first()
} else {
None
};
if let Some(w) = next.filter(|_| v.bulge != 0.0) {
// Each arc sampled from its own size, so a small one next to a long edge counts too.
let arc = cadcraft_geom::Polyline { vertices: vec![*v, PolyVertex::new(w.p)], closed: false };
let b = arc.bounds();
let size = (b.max - b.min).len();
pts.extend(arc.tessellate(if size.is_finite() && size > 0.0 { size * 0.05 } else { 1e-6 }));
}
}
pts.into_iter().map(|p| p.to3(elevation)).collect()
}
/// The most vertices one tilted run is flattened to: past it, bulged edges are kept as their
/// chords, so a file full of arcs can't ask for gigabytes (one arc adds at most 4,096 more).
const MAX_TILTED_VERTICES: usize = 1_000_000;
/// A run of OCS vertices at `elevation` in the plane of `n` as seen from above in the WCS. A
/// tilted arc is an elliptical arc there, so each bulged edge is flattened (within a thousandth
/// of the edge's own size, up to [`MAX_TILTED_VERTICES`] in all), its width spread evenly along
/// it; vertices that start straight edges keep their widths. Also gives the mean WCS Z, the
/// elevation the flattened run takes.
pub(super) fn plan_of_tilted(n: Vec3, elevation: f64, vertices: &[PolyVertex], closed: bool) -> (Vec<PolyVertex>, f64) {
let m = cadcraft_geom::Mat4::ocs(n);
let mut local: Vec<PolyVertex> = Vec::with_capacity(vertices.len());
for (i, v) in vertices.iter().enumerate() {
let next = if i + 1 < vertices.len() {
vertices.get(i + 1)
} else if closed {
vertices.first()
} else {
None
};
match next {
Some(w) if v.bulge != 0.0 && local.len() < MAX_TILTED_VERTICES => {
let arc = cadcraft_geom::Polyline { vertices: vec![*v, PolyVertex::new(w.p)], closed: false };
// From the edge's own size, so a long edge elsewhere can't flatten a small arc away.
let b = arc.bounds();
let size = (b.max - b.min).len();
let edge = arc.tessellate(if size.is_finite() && size > 0.0 { size * 1e-3 } else { 1e-6 });
// The edge runs from this vertex to the next one in equal turns of its arc; its
// width changes evenly along it, as a tapered segment's does.
let steps = edge.len().saturating_sub(1).max(1);
let width = |k: usize| v.start_width + (v.end_width - v.start_width) * k as f64 / steps as f64;
local.extend(edge.iter().take(steps).enumerate().map(|(k, p)| PolyVertex {
p: *p,
bulge: 0.0,
start_width: width(k),
end_width: width(k + 1),
}));
}
_ => local.push(PolyVertex { bulge: 0.0, ..*v }),
}
}
let to_plan = |p: Vec2| m.apply(Vec3::new(p.x, p.y, elevation));
let z = if local.is_empty() { elevation } else { local.iter().map(|v| to_plan(v.p).z).sum::<f64>() / local.len() as f64 };
(local.into_iter().map(|v| PolyVertex { p: to_plan(v.p).xy(), ..v }).collect(), z)
}
+8 -34
View File
@@ -17,9 +17,6 @@ pub(super) fn parse_entities(
let owner = crate::dxf_ext::owner(tags);
let paper = t.i(67) == Some(1);
let handle = t.s(5).and_then(|h| Handle::parse_hex(&h));
if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) {
rx.paper_views_dropped.push((owner.clone(), paper, rx.block.clone()));
}
let mut kindent = entity_with_table_budget(kind, tags, table_cells_left);
if let Some((c, _)) = kindent.as_mut()
&& let Some(n) = entity_plot_style(tags, rx)
@@ -114,9 +111,7 @@ pub(super) fn read_blocks(
j += 1;
}
let body = recs.get(i + 1..j).unwrap_or(&[]);
rx.block = Some(name.to_ascii_uppercase());
let ents = parse_entities(body, d, rx, &mut n, table_cells_left);
rx.block = None;
blocks.push((name, base, description, ents, xref));
i = j + 1;
} else {
@@ -256,14 +251,6 @@ pub(super) fn place_blocks(d: &mut Drawing, blocks: Vec<BlockDef>, rx: &mut Rx,
}
}
/// The layout an entity record belongs to: paper space when flagged (67=1) or owned by a
/// `*PAPER_SPACE` block, else `None` for model space.
fn paper_layout(owner: Option<String>, paper: bool, rx: &Rx, paper_block_for: &HashMap<String, String>, first_layout: &str) -> Option<String> {
let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE"));
to_paper.then(|| owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.to_string()))
}
/// Place the ENTITIES section: model space, or paper space when flagged (67=1).
pub(super) fn place_entities(
d: &mut Drawing,
@@ -275,32 +262,19 @@ pub(super) fn place_entities(
let mut layers = LayerSet::new(d);
for (owner, paper, e) in entities {
layers.ensure(d, &e.common.layer);
match paper_layout(owner, paper, rx, paper_block_for, first_layout) {
Some(lname) => {
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) {
l.entities.push(e);
}
let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE"));
if to_paper {
let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.to_string());
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) {
l.entities.push(e);
}
None => d.model.push(e),
} else {
d.model.push(e);
}
}
}
/// Layouts whose generated sheet viewport (PAPERVIEW) the reader dropped: their viewports keep
/// their ids, so the next save generates the sheet again instead of promoting one of them.
pub(super) fn dropped_paper_views(rx: &Rx, paper_block_for: &HashMap<String, String>, first_layout: &str) -> std::collections::HashSet<String> {
rx.paper_views_dropped
.iter()
.filter_map(|(owner, paper, block)| match block {
// In a paper-space block the sheet is that block's layout, where `place_blocks` puts
// the block's other entities, whatever its owner group says.
Some(b) if b.starts_with("*PAPER_SPACE") => Some(paper_block_for.get(b).cloned().unwrap_or_else(|| first_layout.to_string())),
Some(_) => None,
None => paper_layout(owner.clone(), *paper, rx, paper_block_for, first_layout),
})
.collect()
}
pub(super) fn count_skipped(skipped: &mut std::collections::BTreeMap<String, usize>, section: &str, n: usize) {
if n > 0 {
let c = skipped.entry(section.to_string()).or_insert(0);
+8 -44
View File
@@ -1,4 +1,4 @@
use super::common::{T, common, ocs, plan_of_tilted, run_points, tilted};
use super::common::{T, common, ocs};
use super::hatch::hatch;
use super::mtext_dxf;
use super::text::{attdef, attrib_modes, attrib_props, mtext_from, multiline_attrib, split_embedded, text_from};
@@ -47,7 +47,7 @@ fn ellipse(c: &Common, t: &T) -> Ellipse {
}
/// A LWPOLYLINE, or the region CADCraft wrote as one.
fn lwpolyline(tags: &[Tag], c: &mut Common, t: &T) -> Option<EntityKind> {
fn lwpolyline(tags: &[Tag], c: &Common, t: &T) -> Option<EntityKind> {
let mut verts = Vec::new();
let mut cur: Option<PolyVertex> = None;
for tg in tags {
@@ -83,19 +83,13 @@ fn lwpolyline(tags: &[Tag], c: &mut Common, t: &T) -> Option<EntityKind> {
}
verts.extend(cur);
let flags = t.i(70).unwrap_or(0);
let mut elevation = t.fd(38, 0.0);
if tilted(c.extrusion, run_points(&verts, elevation, flags & 1 != 0)) {
// In a tilted plane: drawn as seen from above, at the vertices' mean height, its
// vertices now plan coordinates.
(verts, elevation) = plan_of_tilted(c.extrusion, elevation, &verts, flags & 1 != 0);
c.extrusion = Vec3::Z;
} else if c.extrusion.z < 0.0 {
if c.extrusion.z < 0.0 {
for v in &mut verts {
v.p.x = -v.p.x;
v.bulge = -v.bulge;
}
}
let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation, plinegen: flags & 128 != 0 };
let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation: t.fd(38, 0.0), plinegen: flags & 128 != 0 };
// A region CADCraft wrote as polylines (`dxf_region`).
crate::dxf_region::from_polyline(tags, pl)
}
@@ -129,14 +123,14 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
return None;
}
let t = T(tags);
let mut c = common(&t);
let c = common(&t);
let k = match kind {
"LINE" => EntityKind::Line(Line { a: t.p(10), b: t.p(11) }),
"POINT" => EntityKind::Point(Point { p: t.p(10), angle: t.fd(50, 0.0).to_radians() }),
"CIRCLE" => EntityKind::Circle(Circle { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs() }),
"ARC" => EntityKind::Arc(arc(&c, &t)),
"ELLIPSE" => EntityKind::Ellipse(ellipse(&c, &t)),
"LWPOLYLINE" => lwpolyline(tags, &mut c, &t)?,
"LWPOLYLINE" => lwpolyline(tags, &c, &t)?,
"SPLINE" => EntityKind::Spline(spline(&t)),
"HELIX" => EntityKind::Helix(helix(tags)),
"RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }),
@@ -237,35 +231,7 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
"3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }),
"HATCH" => {
let mut k = hatch(tags, &t)?;
if let EntityKind::Hatch(h) = &mut k
&& tilted(c.extrusion, h.loops.iter().flat_map(|l| run_points(&l.vertices, h.elevation, true)))
{
// In a tilted plane (a terrain face, a slope): the boundary as seen from above,
// at its mean height, and the pattern direction and origin projected the same way.
let n = c.extrusion;
let m = cadcraft_geom::Mat4::ocs(n);
let (mut sum, mut count) = (0.0, 0usize);
for l in &mut h.loops {
let (v, z) = plan_of_tilted(n, h.elevation, &l.vertices, true);
sum += z * v.len() as f64;
count += v.len();
l.vertices = v;
}
// The pattern keeps the spacing and dashes of its plane: only its direction turns.
let plan_angle = |a: f64| {
let dir = m.apply(Vec3::new(a.cos(), a.sin(), 0.0)).xy();
if dir.len() > 1e-9 { cadcraft_geom::norm_angle(dir.y.atan2(dir.x)) } else { a }
};
h.angle = plan_angle(h.angle);
if let Some(g) = h.gradient.as_mut() {
g.angle = plan_angle(g.angle);
}
h.origin = m.apply(Vec3::new(h.origin.x, h.origin.y, h.elevation)).xy();
if count > 0 {
h.elevation = sum / count as f64;
}
c.extrusion = Vec3::Z;
} else if c.extrusion.z < 0.0
if c.extrusion.z < 0.0
&& let EntityKind::Hatch(h) = &mut k
{
// Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too.
@@ -288,9 +254,7 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
// 12/22 is in display coordinates, whose origin is the view target 17/27/37.
view_center: t.view_center(12, 17, twist),
view_height: t.fd(45, 1.0),
// ZWCAD writes -1 (unknown): 0 here, numbered in order by the reader (#1463).
// Group 69 is 16-bit: other out-of-range values are unknown too, never truncated.
id: t.i(69).filter(|i| (1..=32767).contains(i)).unwrap_or(0) as u32,
id: t.i(69).unwrap_or(0) as u32,
locked: t.i(90).unwrap_or(0) & 16384 != 0,
frozen_layers: crate::dxf_ext::read_frozen(tags),
layer_colors: crate::dxf_ext::read_layer_colors(tags),
-3
View File
@@ -252,9 +252,6 @@ pub(super) fn page_setup(t: &T) -> PageSetup {
let rotated = matches!(t.i(73), Some(1 | 3));
if w > 0.0 && h > 0.0 {
p.landscape = rotated != (w > h);
// Kept to be written back, so only a rotation the DXF Reference defines (0-3): any other
// value reads as not rotated and is written so.
p.file_paper = Some((w, h, t.i(73).filter(|r| (0..=3).contains(r)).unwrap_or(0)));
} else if t.i(73).is_some() {
p.landscape = rotated;
}
+13 -30
View File
@@ -199,7 +199,7 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
// The first *ACTIVE viewport holds the current snap/grid settings.
"VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => {
active_vport = true;
vport_record(d, &t, true);
vport_record(d, &t);
for (name, code) in [("SNAPUNIT", 14), ("GRIDUNIT", 15)] {
if let (Some(x), Some(y)) = (t.f(code), t.f(code + 10)) {
let p = Vec2::new(x, y);
@@ -282,13 +282,12 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
None => d.dim_styles.push(st),
}
}
"VPORT" => vport_record(d, &t, false),
"VPORT" => vport_record(d, &t),
_ => {}
}
}
// One *Active viewport is no tiling: its single tile is not a configuration to switch to. Its
// view was kept in the HEADER pair as it was read (see `vport_record`), and the record stays
// only for its view twist (see `cadcraft_engine::DocState`).
// One *Active viewport is no tiling: the view is the editor's (it zooms to the extents),
// kept only for its view twist (see `cadcraft_engine::DocState`).
d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active()) || c.tiles.first().is_some_and(|t| t.twist != 0.0));
}
@@ -323,38 +322,22 @@ fn index_table_handles(recs: &[(String, Vec<Tag>)], rx: &mut Rx) {
const MAX_VPORT_TILES: usize = 64;
/// A VPORT record: one tile of the configuration it names (`*ACTIVE` the one shown). Corners
/// are fractions of the drawing area; unusable tiles are skipped. `first_active`: this is the
/// file's first `*ACTIVE` record, the one whose view is the drawing's saved view.
fn vport_record(d: &mut Drawing, t: &T, first_active: bool) {
/// are fractions of the drawing area; unusable tiles are skipped.
fn vport_record(d: &mut Drawing, t: &T) {
let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default();
// View twist (group 51, degrees).
let twist = t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians()));
// The view centre 12/22 is in display coordinates, whose origin is the view target 17/27/37.
let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17, twist));
// A modern file keeps the view it was saved at in the first `*ACTIVE` record (12/22 centre,
// 40 height) as well as in the HEADER pair, and as with the snap and grid spacing the record
// is the newer of the two and wins (#1602). A record that leaves the centre out has no view
// to give -- `p` reads a missing coordinate as zero -- so the header pair stays.
// From the caller, which knows the record's place: whether a tile was kept for an earlier
// `*ACTIVE` record says nothing when that record's tile was unusable.
let first_active = first_active && name.eq_ignore_ascii_case(VportConfig::ACTIVE);
let centred = t.f(12).is_some() && t.f(22).is_some();
let height = t.fd(40, 0.0);
let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v);
// One record carries two separate things: where the tile sits on the screen (10/11) and the
// view it shows (12/22, 40). They are judged apart, because a record can hold a usable view
// with a tile rectangle that is not usable -- a first `*ACTIVE` like that used to return
// before the view was read, so a file whose header has no view opened on the extent fit
// instead of where it was saved (noted in review).
let view_ok = center.is_finite() && height.is_finite() && height > 0.0;
let tile_ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) && max.x > min.x + 1e-6 && max.y > min.y + 1e-6;
if name.is_empty() {
return;
}
if first_active && centred && view_ok {
d.header.set_saved_view(center, height);
}
if !view_ok || !tile_ok {
let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit)
&& max.x > min.x + 1e-6
&& max.y > min.y + 1e-6
&& center.is_finite()
&& height.is_finite()
&& height > 0.0;
if name.is_empty() || !ok {
return;
}
let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0);
-4
View File
@@ -46,10 +46,6 @@ const HEADER_VARS: &[(&str, i32)] = &[
("INSUNITS", 70),
("MEASUREMENT", 70),
("PSLTSCALE", 70),
// The model view the drawing is saved at, as DXF keeps it in HEADER (the `*ACTIVE` VPORT
// record below repeats it). Written so a drawing opened on its saved view saves it back.
("VIEWCTR", -2),
("VIEWSIZE", 40),
];
/// The current dimension style's variables as header variables (`$DIMTXT`, `$DIMDEC`…), after
+13 -34
View File
@@ -72,11 +72,6 @@ pub(super) fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &C
/// The VPORT table: the `*Active` model space tiles (one viewport on the extents when there is no
/// tiled configuration) and the saved configurations, each tile a record, the current one first.
pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
// The view the drawing is saved at, so that reading a drawing and writing it back keeps it;
// a drawing that stores none is written on its extents. The fallback fits the width as well,
// through the aspect the record is written with: a drawing 100 wide and 10 tall otherwise got
// a view 11 tall, which shows a sliver of it, and a horizontal line got 1e-6 (#1602).
const ASPECT: f64 = 1.6;
// An untiled view's twist: the lone `*Active` tile the editor keeps for it.
let twist = d
.vports
@@ -84,37 +79,21 @@ pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
.find(|c| c.is_active() && c.tiles.len() == 1)
.and_then(|c| c.tiles.first())
.map_or(0.0, |t| cadcraft_geom::norm_angle(t.twist));
let (center, height) = match d.header.saved_view() {
Some(v) => v,
None if ext.is_empty() => (Vec2::new(6.0, 4.5), 9.0),
None if twist == 0.0 => (ext.center(), (ext.height().max(ext.width() / ASPECT) * 1.1).max(1e-6)),
// The extents fitted as the turned view sees them.
None => {
let r = Bounds2::from_points(ext.corners().map(|p| p.rotate(twist)));
let height = r.height().max(r.width() / ASPECT) * 1.1;
(r.center().rotate(-twist), if height.is_finite() { height.max(1e-6) } else { 9.0 })
}
// The extents fitted as the turned view sees them.
let (center, height) = if ext.is_empty() {
(Vec2::new(6.0, 4.5), 9.0)
} else if twist == 0.0 {
(ext.center(), (ext.height() * 1.1).max(1e-6))
} else {
let r = Bounds2::from_points(ext.corners().map(|p| p.rotate(twist)));
let height = r.height() * 1.1;
(r.center().rotate(-twist), if height.is_finite() { height.max(1e-6) } else { 9.0 })
};
let single = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center, height, aspect: ASPECT, twist };
let single = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center, height, aspect: 1.6, twist };
let tiled = d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1);
// The first `*ACTIVE` record is the one a reader takes the view from, so it carries the saved
// view while keeping its own place on the screen and the tiles after it unchanged. Writing
// the stored tiles as they stand put the new view in HEADER and the old one here, and the
// read takes this one, so updating the view of a tiled drawing did not survive a round trip
// (noted in review).
//
// Only where there is a saved view to carry. `center` and `height` are the extents fallback
// when the header holds none, and a tiled drawing can hold none -- `-VPORTS` splits the
// current view into tiles without setting one -- so overriding regardless replaced a tile's
// own good view with that fallback, and the drawing opened on the extents instead of where
// its tile was (noted in review).
let saved = d.header.saved_view().is_some();
let first = tiled.and_then(|c| c.tiles.first()).map(|t| if saved { VportTile { center, height, ..*t } } else { *t });
let mut records: Vec<(&str, &VportTile)> = match (tiled, first.as_ref()) {
(Some(c), Some(first)) => {
std::iter::once((VportConfig::ACTIVE, first)).chain(c.tiles.iter().skip(1).map(|t| (VportConfig::ACTIVE, t))).collect()
}
_ => vec![(VportConfig::ACTIVE, &single)],
let mut records: Vec<(&str, &VportTile)> = match tiled {
Some(c) => c.tiles.iter().map(|t| (VportConfig::ACTIVE, t)).collect(),
None => vec![(VportConfig::ACTIVE, &single)],
};
records.extend(d.vports.iter().filter(|c| !c.is_active() && !c.name.is_empty()).flat_map(|c| c.tiles.iter().map(|t| (c.name.as_str(), t))));
let th = table_head(w, "VPORT", records.len());
+3 -40
View File
@@ -425,14 +425,13 @@ fn layout_obj(w: &mut W, context: LayoutWriteContext<'_>, data: LayoutWriteData<
w.s(2, if device.is_empty() || device.eq_ignore_ascii_case("None") { "none_device" } else { device });
w.s(4, page.paper.replace(' ', "_"));
w.s(6, "");
let (paper_w, paper_h, rotation) = plot_paper(page);
for (c, v) in [
(40, page.margins_mm[0]),
(41, page.margins_mm[1]),
(42, page.margins_mm[2]),
(43, page.margins_mm[3]),
(44, paper_w),
(45, paper_h),
(44, page.width_mm),
(45, page.height_mm),
(46, 0.0),
(47, 0.0),
(48, page.plot_window[0]),
@@ -447,7 +446,7 @@ fn layout_obj(w: &mut W, context: LayoutWriteContext<'_>, data: LayoutWriteData<
w.i(70, plot_flags);
// Plot paper units: 1 millimetres, 0 inches (#1058).
w.i(72, i64::from(cadcraft_render::paper::page_unit_mm(d, page) == 1.0));
w.i(73, rotation);
w.i(73, i64::from(page.landscape));
w.i(74, plot_type);
w.s(7, &page.plot_style_table);
w.i(75, if page.scale_to_fit { 0 } else { 16 });
@@ -1151,42 +1150,6 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, frozen_34
w.t
}
/// Paper size (groups 44, 45) and plot rotation (group 73) for a LAYOUT object. Page setups
/// keep a portrait size and a landscape flag. The values read from the file are written back
/// as they were while they still describe that size and orientation, whatever convention the
/// program that wrote them follows. Otherwise a media whose name makes it wider than tall,
/// such as `ISO_full_bleed_A3_(420.00_x_297.00_MM)`, gets its size in that order and the
/// rotation relative to it, so readers that take the media's size from its name agree with
/// groups 44 and 45 (#1463). Other media are written as before.
fn plot_paper(page: &PageSetup) -> (f64, f64, i64) {
if let Some((w, h, rotation)) = page.file_paper {
let same = |a: f64, b: f64| (a - b).abs() < 1e-6;
let landscape = matches!(rotation, 1 | 3) != (w > h);
if same(w.min(h), page.width_mm) && same(w.max(h), page.height_mm) && landscape == page.landscape {
return (w, h, rotation);
}
}
if media_is_landscape(&page.paper) {
let (short, long) = (page.width_mm.min(page.height_mm), page.width_mm.max(page.height_mm));
(long, short, i64::from(!page.landscape))
} else {
(page.width_mm, page.height_mm, i64::from(page.landscape))
}
}
/// Whether a media name ends in a size wider than tall, like `… (420.00 x 297.00 MM)`.
fn media_is_landscape(paper: &str) -> bool {
let name = paper.replace('_', " ");
let Some(open) = name.rfind('(') else { return false };
let mut sides = name[open + 1..].splitn(2, ['x', 'X', '×']);
let num = |s: Option<&str>| {
let s = s?.trim_start();
let end = s.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(s.len());
s[..end].parse::<f64>().ok()
};
matches!((num(sides.next()), num(sides.next())), (Some(w), Some(h)) if w > h)
}
/// DXF Reference group 91 of CLASS records, the instance count, which came with the 2004 format.
/// From 2004 every CLASS record gets its actual count, counted after `dxf_raw::finish` restores
/// foreign classes and objects (AutoCAD rejects a 2004+ file without it, #635). An R2000 file has
+2 -5
View File
@@ -141,12 +141,9 @@ pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> R
}
/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`, in the DWG worker when
/// there is one. The objects CADCraft keeps without modelling them are still in the groups of the
/// file they came from (`Foreign::version`, not `$ACADVER`, which SAVEAS changes), and the bridge
/// reads them as that version.
/// there is one.
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
let bridge = dxf_write::dwg_bridge(d, version);
cadcraft_dwg::encode_kept_noted(bridge.as_bytes(), version.code(), d.foreign.version.as_deref()).map_err(IoError::Format)
cadcraft_dwg::encode_noted(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
}
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
-670
View File
@@ -579,164 +579,6 @@ fn pdf_hostile_options_and_empty() {
}
}
/// The view a drawing was saved at is the `*ACTIVE` VPORT record's centre and height, repeated in
/// the `$VIEWCTR`/`$VIEWSIZE` header pair. It used to be dropped on read and replaced on write by
/// a fit of the geometry extents, so a drawing with far-off geometry opened hundreds of times too
/// far out, and a conversion replaced wherever the drawing had been left (#1602).
#[test]
fn dxf_keeps_the_view_the_drawing_was_saved_at() {
const SAVED: &str = "\
0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1027\n9\n$VIEWCTR\n10\n50.0\n20\n50.0\n30\n0.0\n9\n$VIEWSIZE\n40\n20.0\n0\nENDSEC\n\
0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nVPORT\n0\nVPORT\n2\n*ACTIVE\n70\n0\n10\n0.0\n20\n0.0\n11\n1.0\n21\n1.0\n\
12\n50.0\n22\n50.0\n40\n20.0\n41\n1.5\n0\nENDTAB\n0\nENDSEC\n\
0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0.0\n20\n0.0\n30\n0.0\n11\n100.0\n21\n60.0\n31\n0.0\n\
0\nLINE\n8\n0\n10\n5000000.0\n20\n5000000.0\n30\n0.0\n11\n5000100.0\n21\n5000060.0\n31\n0.0\n0\nENDSEC\n0\nEOF\n";
// The stored view survives the read, far-away geometry and all: the extents here are
// 5 000 100 x 5 000 060, a fit of which is 275 000 times the height the drawing was saved at.
let d = read_dxf(SAVED.as_bytes()).unwrap();
assert_eq!(d.header.saved_view(), Some((Vec2::new(50.0, 50.0), 20.0)));
assert!(d.extents(&Space::Model).max.x > 5e6, "the far-off line is read too");
// And writing the drawing back keeps it, in both places a reader looks.
let text = write_dxf(&d);
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.header.saved_view(), Some((Vec2::new(50.0, 50.0), 20.0)));
let active = text.replace("\r\n", "\n");
let active = active.split("*Active").nth(1).unwrap_or_default();
for group in ["\n 12\n50", "\n 22\n50", "\n 40\n20"] {
assert!(active.contains(group), "the *ACTIVE record keeps {group:?}: {}", &active[..active.len().min(400)]);
}
// A drawing that stores no view is still written on its extents.
let mut plain = d.clone();
plain.header.vars.remove("VIEWCTR");
plain.header.vars.remove("VIEWSIZE");
assert_eq!(plain.header.saved_view(), None);
let fitted = read_dxf(write_dxf(&plain).as_bytes()).unwrap();
let (center, height) = fitted.header.saved_view().unwrap();
assert!(center.x > 2.4e6 && height > 5e6, "the extents fit: {center:?} {height}");
// That fit covers the width as well: a wide, flat drawing opened on its height alone would
// show a sliver of it, and a drawing with no height at all would have no view.
let mut wide = cadcraft_doc::Drawing::new_metric();
for (a, b) in [([0.0, 0.0], [100.0, 10.0]), ([0.0, 10.0], [100.0, 10.0])] {
let line = cadcraft_doc::Line { a: Vec3::new(a[0], a[1], 0.0), b: Vec3::new(b[0], b[1], 0.0) };
wide.add(&Space::Model, cadcraft_doc::Common::default(), EntityKind::Line(line)).unwrap();
}
let back = read_dxf(write_dxf(&wide).as_bytes()).unwrap();
let (_, height) = back.header.saved_view().unwrap();
assert!(height > 60.0, "a 100 x 10 drawing needs more than its height: {height}");
let mut flat = cadcraft_doc::Drawing::new_metric();
let line = cadcraft_doc::Line { a: Vec3::ZERO, b: Vec3::new(100.0, 0.0, 0.0) };
flat.add(&Space::Model, cadcraft_doc::Common::default(), EntityKind::Line(line)).unwrap();
let back = read_dxf(write_dxf(&flat).as_bytes()).unwrap();
let (_, height) = back.header.saved_view().unwrap();
assert!(height > 60.0, "a horizontal line needs a view with width in it: {height}");
}
/// A `*ACTIVE` record that leaves its view centre out (no 12/22) is not a view: `$VIEWCTR` and
/// `$VIEWSIZE` stay, rather than being replaced by the zero the missing groups read as.
#[test]
fn a_viewport_record_without_a_centre_keeps_the_header_view() {
const NO_CENTRE: &str = "\
0\nSECTION\n2\nHEADER\n9\n$VIEWCTR\n10\n50.0\n20\n50.0\n9\n$VIEWSIZE\n40\n20.0\n0\nENDSEC\n\
0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nVPORT\n0\nVPORT\n2\n*ACTIVE\n70\n0\n10\n0.0\n20\n0.0\n11\n1.0\n21\n1.0\n\
40\n99.0\n0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0.0\n20\n0.0\n11\n9.0\n21\n9.0\n0\nENDSEC\n0\nEOF\n";
let d = read_dxf(NO_CENTRE.as_bytes()).unwrap();
assert_eq!(d.header.saved_view(), Some((Vec2::new(50.0, 50.0), 20.0)), "the header pair is kept");
}
/// The saved view is the first `*ACTIVE` record's even when that record's tile rectangle is
/// unusable: the next `*ACTIVE` record is a second tile, not the first again (#2837 review).
#[test]
fn the_first_active_record_gives_the_view_even_with_an_unusable_tile() {
const TWO: &str = "\
0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nVPORT\n\
0\nVPORT\n2\n*ACTIVE\n70\n0\n10\n0.0\n20\n0.0\n11\n0.0\n21\n0.0\n12\n50.0\n22\n50.0\n40\n20.0\n\
0\nVPORT\n2\n*ACTIVE\n70\n0\n10\n0.0\n20\n0.0\n11\n1.0\n21\n1.0\n12\n900.0\n22\n900.0\n40\n5.0\n\
0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0.0\n20\n0.0\n11\n9.0\n21\n9.0\n0\nENDSEC\n0\nEOF\n";
let d = read_dxf(TWO.as_bytes()).unwrap();
assert_eq!(d.header.saved_view(), Some((Vec2::new(50.0, 50.0), 20.0)), "the first record's view");
}
/// A tiled drawing need not hold a saved view: `-VPORTS` splits the current view into tiles and
/// sets none. The first tile's own view is then the only one there is, and must not be replaced
/// by the extents fallback `vport_table` computes for a drawing with no view (noted in review).
#[test]
fn a_tiled_drawing_with_no_saved_view_keeps_its_first_tiles_view() {
let mut d = sample();
let tile = |min: Vec2, max: Vec2, center: Vec2| VportTile { min, max, center, height: 10.0, aspect: 1.0, twist: 0.0 };
d.vports.push(VportConfig {
name: VportConfig::ACTIVE.to_string(),
tiles: vec![tile(Vec2::ZERO, Vec2::new(0.5, 1.0), Vec2::new(1.0, 1.0)), tile(Vec2::new(0.5, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 2.0))],
});
assert_eq!(d.header.saved_view(), None, "the drawing holds no saved view");
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
let active = back.vports.iter().find(|c| c.is_active()).expect("the tiled configuration is kept");
assert_eq!(active.tiles.len(), 2, "both tiles are written: {:?}", active.tiles);
assert_eq!(active.tiles[0].center, Vec2::new(1.0, 1.0), "the first tile keeps its own view");
assert_eq!(active.tiles[0].height, 10.0, "and its own height");
assert_eq!(active.tiles[1].center, Vec2::new(2.0, 2.0), "the second tile keeps its own view");
// The read takes the header's view from that first record, so it is the tile's view and not
// the extents fit the writer would otherwise have put there.
assert_eq!(back.header.saved_view(), Some((Vec2::new(1.0, 1.0), 10.0)), "read from the first tile");
}
/// Updating the saved view of a drawing that has several model-space tiles survives a round
/// trip. The write put the new view in HEADER but emitted the stored tiles unchanged, and the
/// read takes its view from the first `*ACTIVE` record, so the update was read back as the old
/// view (noted in review of #1602).
#[test]
fn a_saved_view_set_on_a_tiled_drawing_survives_a_round_trip() {
let mut d = sample();
// Two tiles side by side, the first the current one, each with its own old view.
let tile = |min: Vec2, max: Vec2, center: Vec2| VportTile { min, max, center, height: 10.0, aspect: 1.0, twist: 0.0 };
d.vports.push(VportConfig {
name: VportConfig::ACTIVE.to_string(),
tiles: vec![tile(Vec2::ZERO, Vec2::new(0.5, 1.0), Vec2::new(1.0, 1.0)), tile(Vec2::new(0.5, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 2.0))],
});
d.header.set_saved_view(Vec2::new(70.0, 80.0), 30.0);
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
assert_eq!(back.header.saved_view(), Some((Vec2::new(70.0, 80.0), 30.0)), "the view that was set is the one read back");
// The tiled configuration is still there, and the first tile keeps its own half of the screen.
let active = back.vports.iter().find(|c| c.is_active()).expect("the tiled configuration is kept");
assert_eq!(active.tiles.len(), 2, "both tiles are written: {:?}", active.tiles);
assert_eq!(active.tiles[0].max, Vec2::new(0.5, 1.0), "the first tile keeps its rectangle");
assert_eq!(active.tiles[1].center, Vec2::new(2.0, 2.0), "and the second tile keeps its own view");
}
/// A first `*ACTIVE` record can hold a usable view (12/22 centre, 40 height) with a tile
/// rectangle that is not usable. The record used to be dropped whole before the view was read,
/// so a file whose header carries no view opened on the extent fit rather than where it was
/// saved (noted in review of #1602).
#[test]
fn a_viewport_record_with_a_bad_tile_still_gives_its_view() {
// No `$VIEWCTR`/`$VIEWSIZE`, and a tile whose corners are equal (10/11 both 0,0), so the
// rectangle is unusable while the view is not: centre 50,50 and height 20.
const BAD_TILE: &str = "\
0\nSECTION\n2\nHEADER\n0\nENDSEC\n\
0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nVPORT\n0\nVPORT\n2\n*ACTIVE\n70\n0\n10\n0.0\n20\n0.0\n11\n0.0\n21\n0.0\n\
12\n50.0\n22\n50.0\n40\n20.0\n0\nENDTAB\n0\nENDSEC\n\
0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0.0\n20\n0.0\n11\n9.0\n21\n9.0\n0\nENDSEC\n0\nEOF\n";
let d = read_dxf(BAD_TILE.as_bytes()).unwrap();
assert_eq!(d.header.saved_view(), Some((Vec2::new(50.0, 50.0), 20.0)), "the view the record holds is read");
// The unusable rectangle is still refused, so the split did not make the tile permissive.
assert!(d.vports.iter().all(|c| c.tiles.is_empty()), "the tile itself is not kept: {:?}", d.vports);
// And a record with no usable view either leaves the header alone, as before.
const NEITHER: &str = "\
0\nSECTION\n2\nHEADER\n0\nENDSEC\n\
0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nVPORT\n0\nVPORT\n2\n*ACTIVE\n70\n0\n10\n0.0\n20\n0.0\n11\n0.0\n21\n0.0\n\
12\n50.0\n22\n50.0\n40\n0.0\n0\nENDTAB\n0\nENDSEC\n\
0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0.0\n20\n0.0\n11\n9.0\n21\n9.0\n0\nENDSEC\n0\nEOF\n";
let neither = read_dxf(NEITHER.as_bytes()).unwrap();
assert_eq!(neither.header.saved_view(), None, "a zero height is not a view, so the header keeps none");
}
#[test]
fn dxf_roundtrips_page_setup() {
let mut d = sample();
@@ -756,114 +598,6 @@ fn dxf_roundtrips_page_setup() {
assert_eq!(p.paper, a3.name);
}
/// Groups 44, 45 and 73 of a layout's AcDbPlotSettings in DXF text.
fn plot_paper_groups(dxf: &str, layout: &str) -> (f64, f64, i64) {
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let pairs: Vec<(&str, &str)> = lines.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1])).collect();
let name = pairs.windows(2).position(|w| w[0] == ("100", "AcDbLayout") && w[1] == ("1", layout)).unwrap_or_else(|| panic!("no LAYOUT {layout}"));
let start = pairs[..name].iter().rposition(|p| *p == ("100", "AcDbPlotSettings")).unwrap();
let get = |code: &str| pairs[start..name].iter().find(|p| p.0 == code).map(|p| p.1).unwrap();
(get("44").parse().unwrap(), get("45").parse().unwrap(), get("73").parse().unwrap())
}
#[test]
fn landscape_media_keeps_its_size_order_and_rotation() {
// A media defined wider than tall, as ZWCAD and AutoCAD name it (#1463).
let media = "ISO full bleed A3 (420.00 x 297.00 MM)";
for (landscape, rotation) in [(true, 0), (false, 1)] {
let mut d = sample();
{
let p = &mut d.layouts[0].page;
p.paper = media.into();
p.width_mm = 297.0;
p.height_mm = 420.0;
p.landscape = landscape;
}
let dxf = write_dxf(&d);
assert_eq!(plot_paper_groups(&dxf, "Layout1"), (420.0, 297.0, rotation), "DXF, landscape {landscape}");
let r = read_dxf(dxf.as_bytes()).unwrap();
let p = &r.layout("Layout1").unwrap().page;
assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, landscape), "DXF read back");
assert_eq!(p.paper, media);
let dwg = write(&d, "t.dwg").unwrap();
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
assert_eq!(plot_paper_groups(&back, "Layout1"), (420.0, 297.0, rotation), "DWG, landscape {landscape}");
let r = read(&dwg, "t.dwg").unwrap();
let p = &r.layout("Layout1").unwrap().page;
assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, landscape), "DWG read back");
}
}
/// A file's own groups 44, 45 and 73 are written back unchanged, in DXF and DWG, whether they
/// follow ZWCAD's convention (the media's size, no turn) or AutoCAD's (a portrait size and a
/// quarter turn) for a media named wider than tall. Once the page setup changes, they no
/// longer describe it and the size and rotation are derived again.
#[test]
fn a_files_own_paper_groups_are_written_back() {
let set = |dxf: &str, (w, h, r): (f64, f64, i64)| -> String {
let mut lines: Vec<String> = dxf.lines().map(String::from).collect();
let name = (0..lines.len() - 3)
.find(|&i| {
lines[i].trim() == "100" && lines[i + 1].trim() == "AcDbLayout" && lines[i + 2].trim() == "1" && lines[i + 3].trim() == "Layout1"
})
.unwrap();
let start = (0..name).rev().find(|&i| lines[i].trim() == "100" && lines[i + 1].trim() == "AcDbPlotSettings").unwrap();
for i in (start..name).step_by(2) {
match lines[i].trim() {
"44" => lines[i + 1] = w.to_string(),
"45" => lines[i + 1] = h.to_string(),
"73" => lines[i + 1] = r.to_string(),
_ => {}
}
}
lines.join("\n")
};
// (media, groups as the program wrote them, portrait size and landscape CADCraft reads)
let cases = [
("ISO full bleed A3 (420.00 x 297.00 MM)", (420.0, 297.0, 0), (297.0, 420.0, true)),
("ISO A4 (297.00 x 210.00 MM)", (210.0, 297.0, 1), (210.0, 297.0, true)),
("ISO A4 (297.00 x 210.00 MM)", (210.0, 297.0, 0), (210.0, 297.0, false)),
];
for (media, groups, (pw, ph, landscape)) in cases {
let mut d = sample();
d.layouts[0].page.paper = media.into();
let file = set(&write_dxf(&d), groups);
let r = read_dxf(file.as_bytes()).unwrap();
let p = &r.layout("Layout1").unwrap().page;
assert_eq!((p.width_mm, p.height_mm, p.landscape), (pw, ph, landscape), "{media} {groups:?} read");
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DXF");
let dwg = write(&r, "t.dwg").unwrap();
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
assert_eq!(plot_paper_groups(&back, "Layout1"), groups, "{media} {groups:?} DWG");
let r = read(&dwg, "t.dwg").unwrap();
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DWG read and saved again");
}
// Turned in CADCraft after reading: the stored groups no longer apply.
let mut d = sample();
d.layouts[0].page.paper = "ISO full bleed A3 (420.00 x 297.00 MM)".into();
let mut r = read_dxf(set(&write_dxf(&d), (420.0, 297.0, 0)).as_bytes()).unwrap();
r.layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.landscape = false;
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), (420.0, 297.0, 1), "portrait after the change");
// A plot rotation the DXF Reference doesn't define (0-3) reads as not rotated and is written
// back as 0, not as read (#2028 review).
let r = read_dxf(set(&write_dxf(&d), (210.0, 297.0, 99)).as_bytes()).unwrap();
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1").2, 0, "an undefined rotation is not written back");
}
#[test]
fn portrait_media_is_written_as_before() {
let mut d = sample();
{
let p = &mut d.layouts[0].page;
p.paper = "ISO A3 (297.00 x 420.00 MM)".into();
p.width_mm = 297.0;
p.height_mm = 420.0;
p.landscape = true;
}
assert_eq!(plot_paper_groups(&write_dxf(&d), "Layout1"), (297.0, 420.0, 1));
}
// ---------------------------------------------------------------------------------------------
// Round trips of styles, overrides, associativity, constraints and tables.
@@ -933,151 +667,6 @@ fn viewport_frozen_layers_roundtrip() {
assert!(frozen.contains(&(3, Vec::new())), "{frozen:?}");
}
/// `Layout1` with viewports 1 (the sheet), 2 and 3, written to DXF and read back with the id (69)
/// of every viewport `blank` names replaced by -1 (ZWCAD's "unknown"): the ids and widths read.
fn viewport_ids_read_back(blank: impl Fn(&str) -> bool) -> (Drawing, Vec<(u32, f64)>) {
viewport_ids_read_back_as(blank, "-1")
}
/// [`viewport_ids_read_back`], with `value` in place of the id of every viewport `blank` names.
fn viewport_ids_read_back_as(blank: impl Fn(&str) -> bool, value: &str) -> (Drawing, Vec<(u32, f64)>) {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
for (id, w) in [(1, 420.0), (2, 8.0), (3, 4.0)] {
let vp = Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: w,
height: 6.0,
view_center: Vec2::new(5.0, 4.0),
view_height: 6.0,
id,
..Viewport::default()
};
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
}
let text = write_dxf(&d);
let mut lines: Vec<String> = text.lines().map(String::from).collect();
for i in (0..lines.len() - 1).step_by(2) {
if lines[i].trim() == "69" && blank(lines[i + 1].trim()) {
lines[i + 1] = value.into();
}
}
let back = read_dxf(lines.join("\n").as_bytes()).unwrap();
let ids =
back.layouts[0].entities.iter().filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some((v.id, v.width)) } else { None }).collect();
(back, ids)
}
/// ZWCAD writes every VIEWPORT with id (69) -1: they are numbered in file order, the first being
/// the paper-space viewport, so saving adds no second sheet viewport (#1463).
#[test]
fn viewport_ids_unknown_are_numbered_in_order() {
let (back, ids) = viewport_ids_read_back(|_| true);
assert_eq!(ids, [(1, 420.0), (2, 8.0), (3, 4.0)]);
assert!(!write_dxf(&back).contains("PAPERVIEW"), "the layout already has its paper-space viewport");
}
/// A layout that mixes known ids with -1 is left alone: the known ids stay, the unknown one reads
/// as 0 (not numbered), as before #1463.
#[test]
fn viewport_ids_mixed_are_kept() {
let (back, ids) = viewport_ids_read_back(|v| v == "2");
assert_eq!(ids, [(1, 420.0), (0, 8.0), (3, 4.0)]);
assert!(!write_dxf(&back).contains("PAPERVIEW"), "viewport 1 is still the sheet");
}
/// Only the sheet's id is unknown: no viewport has id 1, so the first one, the sheet, gets it
/// and saving adds no second sheet (#1463).
#[test]
fn viewport_ids_unknown_sheet_becomes_one() {
let (back, ids) = viewport_ids_read_back(|v| v == "1");
assert_eq!(ids, [(1, 420.0), (2, 8.0), (3, 4.0)]);
assert!(!write_dxf(&back).contains("PAPERVIEW"), "the first viewport is the sheet");
}
/// Group 69 is a 16-bit code: an id outside 1..=32767 reads as unknown (0), and is never truncated
/// into a real id (4294967297 would become a second id 1, a second paper-space viewport).
#[test]
fn viewport_ids_out_of_range_read_as_unknown() {
for bad in ["4294967297", "65537", "32768"] {
let (_, ids) = viewport_ids_read_back_as(|v| v == "2", bad);
assert_eq!(ids, [(1, 420.0), (0, 8.0), (3, 4.0)], "69 = {bad}");
}
let (_, ids) = viewport_ids_read_back_as(|v| v == "2", "32767");
assert_eq!(ids, [(1, 420.0), (32767, 8.0), (3, 4.0)]);
}
/// A layout whose only viewport has id 0 (entmake, MCP): the writer adds the PAPERVIEW sheet ahead
/// of it and the reader drops that sheet again. The id-0 viewport must not be numbered 1, or the
/// next save takes it as the sheet and the generated sheet is lost (#2107).
#[test]
fn viewport_id_zero_keeps_implicit_sheet_across_saves() {
let mut d = sample();
let vp = Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 4.0),
view_height: 6.0,
id: 0,
..Viewport::default()
};
d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();
let first = write_dxf(&d);
assert!(first.contains("PAPERVIEW"), "the writer adds the sheet");
let back = read_dxf(first.as_bytes()).unwrap();
let ids: Vec<u32> =
back.layouts[0].entities.iter().filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None }).collect();
assert_eq!(ids, [0], "the dropped sheet is not replaced by numbering the id-0 viewport");
let second = write_dxf(&back);
let lines: Vec<&str> = second.lines().map(str::trim).collect();
let sheet = lines.iter().position(|l| *l == "PAPERVIEW").expect("the second save still generates the sheet");
let vp0 = (0..lines.len() - 1).find(|i| lines[*i] == "69" && lines[i + 1] == "0").expect("the id-0 viewport is written");
assert!(sheet < vp0, "the generated sheet comes first");
}
/// The same for a layout written as a `*Paper_SpaceN` block (not the active one) whose PAPERVIEW
/// sheet lost its owner group (330): the sheet still belongs to the block's layout, as
/// `place_blocks` puts the block's other entities there, so that layout is left alone too.
#[test]
fn viewport_id_zero_in_paper_block_keeps_implicit_sheet_without_owner() {
let mut d = sample();
d.layouts.push(Layout::new("Layout2", 2));
let vp = Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 4.0),
view_height: 6.0,
id: 0,
..Viewport::default()
};
d.add(&Space::Paper("Layout2".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();
let first = write_dxf(&d);
let lines: Vec<&str> = first.lines().collect();
let sheet = lines.iter().position(|l| l.trim() == "PAPERVIEW").expect("the writer adds the sheet");
let start = (0..sheet).rev().find(|i| lines[*i].trim() == "0" && lines[i + 1].trim() == "VIEWPORT").unwrap();
let owner = (start..sheet).find(|i| lines[*i].trim() == "330").expect("the sheet has an owner");
assert!(lines[start..sheet].iter().any(|l| l.trim().eq_ignore_ascii_case("*Paper_Space0") || l.trim() == "AcDbViewport"), "sheet record found");
let stripped: String = lines
.iter()
.enumerate()
.filter(|(i, _)| *i != owner && *i != owner + 1)
.map(|(_, l)| {
format!(
"{l}
"
)
})
.collect();
assert!(stripped.contains("*Paper_Space0"), "the second layout is a paper-space block");
let back = read_dxf(stripped.as_bytes()).unwrap();
let l2 = back.layouts.iter().find(|l| l.name == "Layout2").expect("Layout2");
let ids: Vec<u32> = l2.entities.iter().filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None }).collect();
assert_eq!(ids, [0], "the dropped sheet of Layout2 is not replaced by numbering its id-0 viewport");
assert!(write_dxf(&back).contains("PAPERVIEW"), "the second save still generates the sheet");
}
/// `d` as an R2018 DXF (frozen viewport layers in group 331).
fn dxf_2018(d: &Drawing) -> String {
let opts = SaveOptions { version: Some(Version::R2018), binary: false };
@@ -2611,152 +2200,6 @@ fn mirrored_hatch_extrusion_flips_boundary() {
}
}
/// Issue #1886: a HATCH in a tilted plane (a terrain face) is drawn as seen from above, not turned
/// about the origin. With extrusion (0, 0.5, -√3/2) the OCS axes are x = (-1,0,0) and
/// y = (0, √3/2, 1/2): the square (1,0)..(3,2) lies over (-3,0)..(-1,√3), from z 0 up to z 1.
/// Before, the negative Z counted as a plain mirror and only x was flipped.
#[test]
fn tilted_hatch_is_drawn_as_seen_from_above() {
let text = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n2\nSOLID\n70\n1\n71\n0\n91\n1\n\
92\n2\n72\n0\n73\n1\n93\n4\n10\n1\n20\n0\n10\n3\n20\n0\n10\n3\n20\n2\n10\n1\n20\n2\n97\n0\n75\n0\n76\n1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "h.dxf").unwrap();
let b = d.extents(&Space::Model);
for (got, want) in [(b.min.x, -3.0), (b.min.y, 0.0), (b.max.x, -1.0), (b.max.y, 3f64.sqrt())] {
assert!((got - want).abs() < 1e-9, "extents {b:?}");
}
let e = d.model.iter().next().unwrap();
assert_eq!(e.common.extrusion, Vec3::Z, "its points are plan coordinates now");
let EntityKind::Hatch(h) = &e.kind else { panic!("not a hatch") };
assert!((h.elevation - 0.5).abs() < 1e-9, "the mean height of its corners: {}", h.elevation);
}
/// A tilted gradient hatch turns its gradient's direction with its boundary: in that plane OCS +X
/// is plan -X, so a gradient at 0 runs at 180°.
#[test]
fn tilted_hatch_turns_its_gradient() {
let text = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n2\nSOLID\n70\n1\n71\n0\n91\n1\n\
92\n2\n72\n0\n73\n1\n93\n4\n10\n1\n20\n0\n10\n3\n20\n0\n10\n3\n20\n2\n10\n1\n20\n2\n97\n0\n75\n0\n76\n1\n\
450\n1\n451\n0\n452\n0\n453\n2\n460\n0\n461\n0\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "h.dxf").unwrap();
let h = first(&d, |k| if let EntityKind::Hatch(h) = k { Some(h.clone()) } else { None });
let g = h.gradient.expect("a gradient");
assert!((g.angle - std::f64::consts::PI).abs() < 1e-9, "gradient angle {}", g.angle);
}
/// A straight tilted LWPOLYLINE keeps its vertex widths (groups 40/41) on the projected vertices.
#[test]
fn tilted_straight_lwpolyline_keeps_its_widths() {
let text = "0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n2\n70\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n\
10\n1\n20\n0\n40\n0.5\n41\n1\n10\n3\n20\n2\n40\n1\n41\n0\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
let got: Vec<(f64, f64)> = p.vertices.iter().map(|v| (v.start_width, v.end_width)).collect();
assert_eq!(got, vec![(0.5, 1.0), (1.0, 0.0)]);
assert!((p.vertices[1].p.x + 3.0).abs() < 1e-9 && (p.vertices[1].p.y - 3f64.sqrt()).abs() < 1e-9, "{:?}", p.vertices[1].p);
}
/// A tilted LWPOLYLINE mixing straight and bulged edges keeps the widths of its straight edges;
/// only the points of the flattened arc have none.
#[test]
fn tilted_mixed_lwpolyline_keeps_its_straight_widths() {
let text = "0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n3\n70\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n\
10\n0\n20\n0\n40\n0.5\n41\n1\n10\n2\n20\n0\n42\n1\n10\n4\n20\n0\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
let v = &p.vertices;
assert!(v.len() > 4 && v.iter().all(|v| v.bulge == 0.0), "{} vertices", v.len());
assert_eq!((v[0].start_width, v[0].end_width), (0.5, 1.0), "the straight edge keeps its widths");
assert!(v[1..].iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0), "the flattened arc has none");
assert!((v[1].p.x + 2.0).abs() < 1e-9 && (v[v.len() - 1].p.x + 4.0).abs() < 1e-9, "{:?} {:?}", v[1].p, v[v.len() - 1].p);
}
/// A small arc next to a long straight edge stays an arc: its flattening tolerance comes from
/// its own size. The half circle of radius 1 bulges a whole unit off its chord.
#[test]
fn tilted_small_arc_beside_a_long_edge_stays_curved() {
let text = "0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n3\n70\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n\
10\n0\n20\n0\n10\n10000\n20\n0\n42\n1\n10\n10002\n20\n0\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(p.vertices.len() > 8, "{} vertices", p.vertices.len());
// OCS y maps to plan y times √3/2: the arc's far point is about 0.866 off the chord.
let off = p.vertices.iter().map(|v| v.p.y.abs()).fold(0.0, f64::max);
assert!((off - 3f64.sqrt() / 2.0).abs() < 1e-2, "the arc reaches {off} off its chord");
}
/// A tilted tapered arc spreads its width evenly over its flattened segments: from 1 at its
/// start to 3 at its end, each segment starting where the last one ended.
#[test]
fn tilted_tapered_arc_spreads_its_width() {
let text = "0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n2\n70\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n\
10\n0\n20\n0\n40\n1\n41\n3\n42\n1\n10\n2\n20\n0\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
let v = &p.vertices;
assert!(v.len() > 4, "{} vertices", v.len());
let arc = &v[..v.len() - 1];
assert_eq!(arc.first().map(|v| v.start_width), Some(1.0));
assert!(arc.last().is_some_and(|v| (v.end_width - 3.0).abs() < 1e-9), "{:?}", arc.last());
assert!(arc.windows(2).all(|w| (w[0].end_width - w[1].start_width).abs() < 1e-9 && w[0].start_width < w[1].start_width));
}
/// A tilted polyline made of a great many arcs is flattened within a budget: past a million
/// vertices its arcs stay chords, so a hostile file can't ask for gigabytes (#2584 review).
#[test]
fn a_tilted_polyline_of_many_arcs_is_flattened_within_a_budget() {
let n = 50_000;
let mut text = format!("0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n{n}\n70\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n");
for i in 0..n {
text.push_str(&format!("10\n{i}\n20\n0\n42\n1\n"));
}
text.push_str("0\nENDSEC\n0\nEOF\n");
let d = read(text.as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
// Every arc flattened would be millions; the budget, one last arc past it and the chords.
assert!(p.vertices.len() <= 1_000_000 + 4_096 + n, "{} vertices", p.vertices.len());
assert!(p.vertices.len() > n, "the arcs before the budget are still curved");
}
/// Far from the origin even a tilt under a thousandth moves an object visibly, so it is
/// projected: with normal (0, 0.0009, 1) a polyline at elevation 1,000,000 lies about 900 units
/// up from its OCS y. Noise in a plan normal moves nothing visible and keeps the arcs.
#[test]
fn a_slight_tilt_far_away_is_projected_and_noise_is_not() {
let poly = |n: &str, more: &str| {
format!("0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n2\n70\n0\n38\n1000000\n{n}10\n0\n20\n0\n{more}10\n1\n20\n0\n0\nENDSEC\n0\nEOF\n")
};
let d = read(poly("210\n0\n220\n0.0009\n230\n1\n", "").as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
let ny = 0.0009 / (1.0f64 + 0.0009 * 0.0009).sqrt();
assert!((p.vertices[0].p.y - ny * 1.0e6).abs() < 1e-6, "{:?}", p.vertices[0].p);
assert_eq!(d.model.iter().next().unwrap().common.extrusion, Vec3::Z);
let d = read(poly("210\n0\n220\n1e-15\n230\n1\n", "42\n1\n").as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert_eq!(p.vertices.iter().map(|v| v.bulge).collect::<Vec<_>>(), vec![1.0, 0.0], "a plan polyline keeps its arc");
}
/// Issue #1886: a LWPOLYLINE circle (two half-circle bulges) in the same tilted plane is the
/// ellipse it looks like from above, flattened; a plain one keeps its bulges.
#[test]
fn tilted_lwpolyline_is_drawn_as_seen_from_above() {
let circle = |n: &str| {
format!("0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n2\n70\n1\n{n}10\n1\n20\n0\n42\n1\n10\n-1\n20\n0\n42\n1\n0\nENDSEC\n0\nEOF\n")
};
let d = read(circle("210\n0\n220\n0.5\n230\n-0.8660254037844386\n").as_bytes(), "p.dxf").unwrap();
let b = d.extents(&Space::Model);
let ry = 3f64.sqrt() / 2.0;
for (got, want) in [(b.min.x, -1.0), (b.min.y, -ry), (b.max.x, 1.0), (b.max.y, ry)] {
assert!((got - want).abs() < 5e-3, "extents {b:?}");
}
let e = d.model.iter().next().unwrap();
assert_eq!(e.common.extrusion, Vec3::Z);
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(p.vertices.len() > 8 && p.vertices.iter().all(|v| v.bulge == 0.0), "{} vertices", p.vertices.len());
let plain = read(circle("").as_bytes(), "p.dxf").unwrap();
let p = first(&plain, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert_eq!(p.vertices.iter().map(|v| v.bulge).collect::<Vec<_>>(), vec![1.0, 1.0]);
}
/// The model extents of a one-loop solid HATCH whose edge boundary is `edges` (group 72 onwards).
fn hatch_edge_extents(edges: &str) -> Bounds2 {
let n = edges.matches("72\n").count();
@@ -3505,119 +2948,6 @@ fn proxies_keep_their_class_when_classes_are_rewritten() {
assert_eq!(names, ["VISUALSTYLE", "WIDGETX"]);
}
/// The proxies of [`proxy_file`] keep their classes in a DWG too. A proxy names its class by
/// number, 500 plus the class's place in the CLASSES the file lists; acadrust numbers the classes
/// it writes in its own order (its default classes first), so the DXF's numbers named other
/// classes there: a 2018 drawing's piping proxies came out as LARGE_RADIAL_DIMENSION and
/// SECTIONOBJECT, and the ODA File Converter refused the file ("AcDbProxyObject can't be cast to
/// AcDbBlockTableRecord"). A DWG before 2013 leaves them out (acadrust writes no class for them).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn proxies_keep_their_class_in_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
use acadrust::objects::ObjectType;
let d = read(&proxy_file(), "p.dxf").unwrap();
for version in [Version::R2018, Version::R2013, Version::R2010, Version::R2004] {
let dwg = write_with(&d, "p.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let class_of = |id: i32| doc.classes.iter().find(|c| i32::from(c.class_number) == id).map(|c| c.dxf_name.clone());
let mut objects: Vec<Option<String>> = doc
.objects
.values()
.filter_map(|o| match o {
ObjectType::ProxyObject(p) => Some(class_of(p.class_id)),
_ => None,
})
.collect();
objects.sort();
// Before 2013 acadrust writes no class for a proxy, so the DWG leaves the proxies out.
let legacy = version < Version::R2013;
let expected = if legacy { vec![] } else { vec![Some("VISUALSTYLE".to_string()), Some("WIDGETX".to_string())] };
assert_eq!(objects, expected, "{version:?}: proxy objects");
let entities: Vec<Option<String>> = doc
.entities()
.filter_map(|e| match e {
EntityType::Extended(x) => match &x.data {
ExtendedEntityData::Proxy(p) => Some(class_of(p.class_id)),
_ => None,
},
_ => None,
})
.collect();
let expected = if legacy { vec![] } else { vec![Some("WIDGETX".to_string())] };
assert_eq!(entities, expected, "{version:?}: proxy entity");
}
}
/// An ASCII DXF given to the DWG conversion directly, with a proxy whose number names no class of
/// its CLASSES ([`proxy_file`]'s "Stray", 537): kept with that number it would name some other
/// class of the DWG, so the DWG leaves it out, as CADCraft's own save does, and keeps the proxies
/// whose classes it can name.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
use acadrust::objects::ObjectType;
let dwg = cadcraft_dwg::dxf_to_dwg_as(&proxy_file(), "AC1032").unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let class_of = |id: i32| doc.classes.iter().find(|c| i32::from(c.class_number) == id).map(|c| c.dxf_name.clone());
let mut objects: Vec<Option<String>> = doc
.objects
.values()
.filter_map(|o| match o {
ObjectType::ProxyObject(p) => Some(class_of(p.class_id)),
_ => None,
})
.collect();
objects.sort();
assert_eq!(objects, [Some("VISUALSTYLE".to_string()), Some("WIDGETX".to_string())]);
let entities: Vec<Option<String>> = doc
.entities()
.filter_map(|e| match e {
EntityType::Extended(x) => match &x.data {
ExtendedEntityData::Proxy(p) => Some(class_of(p.class_id)),
_ => None,
},
_ => None,
})
.collect();
assert_eq!(entities, [Some("WIDGETX".to_string())]);
}
/// What the DWG writer reports it left out counts the proxy it can't name, so a save's loss
/// report has it with the other records the DWG doesn't keep.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_proxy_left_out_of_the_dwg_is_counted_as_unwritten() {
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&proxy_file(), "AC1032").unwrap();
assert_eq!(unwritten, [("ACAD_PROXY_OBJECT".to_string(), 1)].into_iter().collect());
let d = read(&proxy_file(), "p.dxf").unwrap();
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&binary, "AC1032").unwrap();
// CADCraft's own save already left the stray proxy out; the binary DXF's other three go.
assert_eq!(unwritten, [("ACAD_PROXY_ENTITY".to_string(), 1), ("ACAD_PROXY_OBJECT".to_string(), 2)].into_iter().collect());
}
/// A binary DXF given to the DWG conversion directly: its CLASSES aren't read, so the proxies'
/// numbers can't be mapped to the DWG's classes. Kept as they were, they named other classes
/// (the ODA refuses such a file), so the DWG leaves them out, as a pre-2013 DWG does, and the
/// rest of the drawing is written.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_binary_dxf_leaves_its_proxies_out_of_the_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
use acadrust::objects::ObjectType;
let d = read(&proxy_file(), "p.dxf").unwrap();
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
assert!(binary.starts_with(b"AutoCAD Binary DXF"));
let dwg = cadcraft_dwg::dxf_to_dwg_as(&binary, "AC1032").unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let proxies = doc.objects.values().filter(|o| matches!(o, ObjectType::ProxyObject(_))).count()
+ doc.entities().filter(|e| matches!(e, EntityType::Extended(x) if matches!(x.data, ExtendedEntityData::Proxy(_)))).count();
assert_eq!(proxies, 0, "no proxy with an unmapped class number");
assert!(doc.layers.get("0").is_some(), "the drawing itself is written");
}
/// A name that isn't valid Unicode still picks its format by extension.
#[cfg(unix)]
#[test]
-301
View File
@@ -1,301 +0,0 @@
//! Objects CADCraft keeps without modelling them reach the DWG as the file they came from holds
//! them. The DWG bridge is an R2000 DXF, and acadrust reads some objects differently by version:
//! read as R2000, a 2013 or later file's mental ray render settings came out a class version too
//! high (the ODA File Converter then refuses the whole DWG: "Object improperly read:
//! AcDbMentalRayRenderSettings"), RapidRT render settings from a 2013 file a version too low, and
//! a field lost its flags and cached value. The bridge now reads the kept groups as the version
//! they were read from (`$ACADVER`).
//!
//! The drawing is synthetic: render settings and a field in the named object dictionary, with
//! values chosen to be told apart. The reference is acadrust's own conversion of the same DXF to
//! the same DWG version (an older DWG can't hold everything: a 2004 field has no value flags or
//! format), read back; CADCraft's DWG must hold the same objects. When the DWG is the DXF's own
//! version, that reference is also checked against the DXF as read.
#![cfg(not(target_arch = "wasm32"))]
use std::fmt::Write as _;
use std::io::Cursor;
use acadrust::objects::{ClassObjectData, Field, ObjectType};
use cadcraft_io::{SaveOptions, Version, read, write_with};
/// A minimal DXF of `version` ("AC1032"): a model space MTEXT, and in the named object dictionary
/// mental ray and RapidRT render settings and a field with a non-ASCII cached value.
fn synthetic(version: &str) -> String {
let mut g: Vec<(i32, String)> = Vec::new();
let mut put = |code: i32, value: &str| g.push((code, value.to_string()));
for (c, v) in [(0, "SECTION"), (2, "HEADER"), (9, "$ACADVER"), (1, version), (9, "$HANDSEED"), (5, "FFF"), (0, "ENDSEC")] {
put(c, v);
}
put(0, "SECTION");
put(2, "CLASSES");
for (name, cpp, app) in [
("MENTALRAYRENDERSETTINGS", "AcDbMentalRayRenderSettings", "SCENEOE"),
("RAPIDRTRENDERSETTINGS", "AcDbRapidRTRenderSettings", "ObjectDBX Classes"),
("FIELD", "AcDbField", "ObjectDBX Classes"),
] {
for (c, v) in [(0, "CLASS"), (1, name), (2, cpp), (3, app), (90, "1024"), (91, "1"), (280, "0"), (281, "0")] {
put(c, v);
}
}
put(0, "ENDSEC");
let mtext = [(0, "MTEXT"), (5, "200"), (100, "AcDbEntity"), (8, "0"), (100, "AcDbMText"), (10, "0"), (20, "0"), (30, "0")];
for (c, v) in [(0, "SECTION"), (2, "ENTITIES")].into_iter().chain(mtext) {
put(c, v);
}
for (c, v) in [(40, "2.5"), (41, "0"), (71, "1"), (72, "5"), (1, "synthetic"), (0, "ENDSEC")] {
put(c, v);
}
for (c, v) in [(0, "SECTION"), (2, "OBJECTS"), (0, "DICTIONARY"), (5, "C"), (330, "0"), (100, "AcDbDictionary"), (281, "1")] {
put(c, v);
}
for (c, v) in [(3, "ACAD_RENDER_ACTIVE_SETTINGS"), (350, "300"), (3, "ACAD_RENDER_ACTIVE_RAPIDRT_SETTINGS"), (350, "301")] {
put(c, v);
}
put(3, "SYNTHETIC_FIELD");
put(350, "302");
// The common AcDbRenderSettings groups; 90 is the class version as a 2013+ DXF stores it.
let base = |name: &str| {
let base: Vec<(i32, String)> = [
(100, "AcDbRenderSettings"),
(90, "2"),
(1, name),
(290, "1"),
(290, "0"),
(290, "1"),
(290, "0"),
(1, ""),
(1, "synthetic preset"),
(90, "7"),
]
.into_iter()
.map(|(c, v)| (c, v.to_string()))
.collect();
base
};
g.extend([(0, "MENTALRAYRENDERSETTINGS"), (5, "300"), (330, "C")].map(|(c, v)| (c, v.to_string())));
g.extend(base("SyntheticMR"));
g.push((100, "AcDbMentalRayRenderSettings".into()));
let mental_ray = [
(90, "2"),
(90, "-1"),
(90, "2"),
(70, "1"),
(40, "2.0"),
(40, "2.0"),
(40, "0.125"),
(40, "0.25"),
(40, "0.375"),
(40, "0.5"),
(70, "1"),
(290, "1"),
(290, "1"),
(90, "3"),
(90, "4"),
(90, "6"),
(290, "1"),
(90, "250"),
(290, "1"),
(40, "2.5"),
(90, "5000"),
(90, "3"),
(90, "4"),
(90, "6"),
(290, "1"),
(90, "100"),
(290, "1"),
(290, "0"),
(290, "1"),
(40, "1.5"),
(40, "0.5"),
(40, "1200.0"),
(70, "1"),
(70, "2"),
(40, "5.0"),
(70, "1"),
(70, "1"),
(290, "0"),
(1, "mr note"),
(90, "16"),
(70, "2"),
(90, "2048"),
(290, "1"),
(40, "1.25"),
];
g.extend(mental_ray.map(|(c, v)| (c, v.to_string())));
g.extend([(0, "RAPIDRTRENDERSETTINGS"), (5, "301"), (330, "C")].map(|(c, v)| (c, v.to_string())));
g.extend(base("SyntheticRT"));
g.push((100, "AcDbRapidRTRenderSettings".into()));
let rapid_rt = [(90, "3"), (70, "1"), (90, "4"), (90, "5"), (70, "1"), (70, "2"), (40, "2.5"), (40, "1.5"), (290, "1")];
g.extend(rapid_rt.map(|(c, v)| (c, v.to_string())));
let field = [
(0, "FIELD"),
(5, "302"),
(330, "C"),
(100, "AcDbField"),
(1, "AcVar"),
(2, "\\AcVar Filename \\f \"%fn2\""),
(90, "0"),
(97, "0"),
(91, "63"),
(92, "0"),
(94, "59"),
(95, "2"),
(96, "0"),
(300, ""),
(93, "1"),
(6, "Variable"),
(93, "0"),
(90, "4"),
(1, "Filename"),
(94, "0"),
(300, ""),
(302, ""),
(304, "ACVALUE_END"),
(7, "ACFD_FIELD_VALUE"),
(93, "4"),
(90, "4"),
(1, "C:\\synthetic\\Ação-01.dwg"),
(94, "0"),
(300, "%fn2"),
(302, "Ação-01"),
(304, "ACVALUE_END"),
(301, "Ação-01"),
(98, "7"),
(0, "ENDSEC"),
(0, "EOF"),
];
g.extend(field.map(|(c, v)| (c, v.to_string())));
g.iter().fold(String::new(), |mut s, (c, v)| {
let _ = write!(s, "{c:>3}\r\n{v}\r\n");
s
})
}
/// The render settings and the field a document holds (the drawing has one of each).
struct Kept {
mental_ray: Option<ClassObjectData>,
rapid_rt: Option<ClassObjectData>,
field: Option<Field>,
}
fn kept(doc: &acadrust::CadDocument) -> Kept {
let mut k = Kept { mental_ray: None, rapid_rt: None, field: None };
for o in doc.objects.values() {
match o {
ObjectType::ClassObject(c) if matches!(c.data, ClassObjectData::MentalRayRenderSettings(_)) => k.mental_ray = Some(c.data.clone()),
ObjectType::ClassObject(c) if matches!(c.data, ClassObjectData::RapidRtRenderSettings(_)) => k.rapid_rt = Some(c.data.clone()),
ObjectType::Field(f) => k.field = Some(f.clone()),
_ => {}
}
}
k
}
/// acadrust's version for a CADCraft one.
fn acad(v: Version) -> acadrust::DxfVersion {
acadrust::DxfVersion::from_version_string(v.code())
}
/// The DXF of `version` saved as a `target` DWG holds the render settings and the field that
/// acadrust's own DXF → DWG conversion keeps.
fn kept_objects_survive(version: &str, target: Version) {
let dxf = synthetic(version);
let mut source = acadrust::DxfReader::from_reader(Cursor::new(dxf.clone().into_bytes())).unwrap().read().unwrap();
let as_read = kept(&source);
assert!(as_read.mental_ray.is_some() && as_read.rapid_rt.is_some() && as_read.field.is_some(), "the synthetic DXF reads");
source.version = acad(target);
let direct = acadrust::DwgWriter::write_to_vec(&source).unwrap();
let reference = kept(&acadrust::DwgReader::from_stream(Cursor::new(direct)).read().unwrap());
if target.code() == version {
assert_eq!(reference.mental_ray, as_read.mental_ray, "the reference DWG keeps the mental ray settings");
assert_eq!(reference.rapid_rt, as_read.rapid_rt, "the reference DWG keeps the RapidRT settings");
assert_eq!(reference.field.as_ref().map(|f| &f.value), as_read.field.as_ref().map(|f| &f.value), "the reference DWG keeps the field");
}
let d = read(dxf.as_bytes(), "kept.dxf").unwrap();
let dwg = write_with(&d, "kept.dwg", &SaveOptions { version: Some(target), binary: false }).unwrap();
let written = acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().unwrap();
let written = kept(&written);
// Every difference is listed, so a failure names all the objects that came out wrong.
let mut wrong = Vec::new();
if written.mental_ray != reference.mental_ray {
wrong.push(format!("mental ray render settings: {:?}\n expected {:?}", written.mental_ray, reference.mental_ray));
}
if written.rapid_rt != reference.rapid_rt {
wrong.push(format!("RapidRT render settings: {:?}\n expected {:?}", written.rapid_rt, reference.rapid_rt));
}
let field = |f: &Option<Field>| {
f.as_ref().map(|f| (f.evaluator_id.clone(), f.code.clone(), f.value.clone(), f.value_string.clone(), f.child_values.clone()))
};
if field(&written.field) != field(&reference.field) {
wrong.push(format!("field: {:?}\n expected {:?}", field(&written.field), field(&reference.field)));
}
assert!(wrong.is_empty(), "{version} → {target:?}:\n{}", wrong.join("\n"));
}
/// A drawing saved once as an older DWG has that version as its `$ACADVER` (SAVEAS sets it, so a
/// later QSAVE writes it again), but its kept objects are still in the 2018 file's groups. The
/// next save must still read them as 2018: read as the saved version, the mental ray settings
/// came out a class version too high (the ODA refuses the DWG) and a 2004 field lost its cached
/// value.
#[test]
fn kept_groups_keep_their_version_across_saves() {
let dxf = synthetic("AC1032");
for (first, second) in [(Version::R2004, Version::R2004), (Version::R2010, Version::R2018), (Version::R2018, Version::R2018)] {
let mut source = acadrust::DxfReader::from_reader(Cursor::new(dxf.clone().into_bytes())).unwrap().read().unwrap();
source.version = acad(second);
let reference = kept(&acadrust::DwgReader::from_stream(Cursor::new(acadrust::DwgWriter::write_to_vec(&source).unwrap())).read().unwrap());
let mut d = read(dxf.as_bytes(), "kept.dxf").unwrap();
write_with(&d, "kept.dwg", &SaveOptions { version: Some(first), binary: false }).unwrap();
// What SAVEAS does to the drawing after writing it.
d.header.set_str("ACADVER", first.code());
let dwg = write_with(&d, "kept.dwg", &SaveOptions { version: Some(second), binary: false }).unwrap();
let written = kept(&acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().unwrap());
let field = |k: &Kept| k.field.as_ref().map(|f| (f.value.clone(), f.value_string.clone()));
assert_eq!(written.mental_ray, reference.mental_ray, "{first:?} then {second:?}: mental ray render settings");
assert_eq!(field(&written), field(&reference), "{first:?} then {second:?}: field");
}
}
/// A DXF without `$ACADVER` has no version for its kept groups: the drawing's own `$ACADVER`
/// is then the default a new drawing starts with (AC1032), which says nothing about the file, so
/// the kept groups are read as the bridge's R2000, as before.
#[test]
fn a_file_without_acadver_records_no_version() {
let dxf = synthetic("AC1032").replacen(" 9\r\n$ACADVER\r\n 1\r\nAC1032\r\n", "", 1);
assert!(!dxf.contains("$ACADVER"));
let d = read(dxf.as_bytes(), "kept.dxf").unwrap();
assert!(!d.foreign.objects.is_empty(), "the render settings and the field are kept");
assert_eq!(d.foreign.version, None);
let with = read(synthetic("AC1032").as_bytes(), "kept.dxf").unwrap();
assert_eq!(with.foreign.version.as_deref(), Some("AC1032"));
}
#[test]
fn a_2018_files_kept_objects_reach_a_2018_dwg() {
kept_objects_survive("AC1032", Version::R2018);
}
/// RapidRT render settings are the case whose class version acadrust adjusts only in 2013 files.
#[test]
fn a_2013_files_kept_objects_reach_a_2013_dwg() {
kept_objects_survive("AC1027", Version::R2013);
}
#[test]
fn a_2018_files_kept_objects_reach_a_2004_dwg() {
kept_objects_survive("AC1032", Version::R2004);
}
/// A drawing from an R2000 DXF (no newer groups to read differently) saves as before.
#[test]
fn an_r2000_files_drawing_still_saves_as_dwg() {
let dxf = " 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nLINE\r\n 8\r\n0\r\n 10\r\n0\r\n 20\r\n0\r\n 30\r\n0\r\n 11\r\n10\r\n 21\r\n5\r\n 31\r\n0\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
let d = read(dxf.as_bytes(), "line.dxf").unwrap();
let dwg = write_with(&d, "line.dwg", &SaveOptions::default()).unwrap();
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().unwrap();
assert_eq!(doc.entities().count(), 1);
}
+1 -2
View File
@@ -205,8 +205,7 @@ fn binary_dxf_honours_the_code_page() {
let read = |b: &[u8]| text_of(&cadcraft_io::read_dxf(b).unwrap());
assert_eq!(read(&bin("AC1015", "ANSI_1251", b"\xCF\xF0\xE8\xE2\xE5\xF2")), "Привет");
assert_eq!(read(&bin("AC1018", "ANSI_932", b"\x95\x5C\\U+00E9")), "表é");
// A short Cyrillic string that is also valid UTF-8 (D1 B2) is still read in the code page:
// a pre-R2007 binary file has no way to escape to UTF-8, so its strings are never read as it.
// A short Cyrillic string that is also valid UTF-8 (D1 B2) is still read in the code page.
assert_eq!(read(&bin("AC1015", "ANSI_1251", b"\xD1\xB2")), "СІ");
assert_eq!(read(&bin("AC1021", "ANSI_1251", "Привет".as_bytes())), "Привет");
assert_eq!(read(&bin("AC1015", "ANSI_1361", b"Caf\xE9")), "Café");
+6 -7
View File
@@ -585,12 +585,11 @@ fn a_point_past_the_end_of_a_frame_edge_is_outside_it() {
#[test]
fn a_tilted_polyline_keeps_the_stored_vertex_list() {
// Polyline 34 lies in a plane tilted about the y axis (extrusion (1,0,0)): it is read as seen
// from above (#1886), (20, 1) as (0, 20), so the frame is tested against those points; none is
// inside, so it moves whole. Polyline 36 is tilted by only 5e-10 (at an elevation of 2e9 that
// moves it about a unit, below what reading projects): its vertices stay in its own plane and
// the stored list (vertex 0) is used, as before. Polyline 35 is mirrored (extrusion (0,0,-1)):
// it is read flipped into the block's plane, (-20, 1) as (20, 1), so the frame is tested.
// Polyline 34 lies in a plane tilted about the y axis (extrusion (1,0,0)): its vertices are in
// that plane's coordinates, not the block's, so the frame isn't tested against them and the
// stored list (vertex 0) is used, as before; so is polyline 36's, tilted by only 5e-10 (at an
// elevation of 2e9 that moves it about a unit). Polyline 35 is mirrored (extrusion (0,0,-1)): it
// is read flipped into the block's plane, (-20, 1) as (20, 1), so the frame is tested.
let polyline = |h: &str, [x0, x1]: [i32; 2], [ex, ey, ez]: [f64; 3], elevation: f64| {
format!(
"0 LWPOLYLINE\n5 {h}\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 2\n70 0\n38 {elevation}\n10 {x0}\n20 1\n10 {x1}\n20 1\n210 {ex}\n220 {ey}\n230 {ez}\n"
@@ -611,7 +610,7 @@ fn a_tilted_polyline_keeps_the_stored_vertex_list() {
let (mut d, r) = door_reference(&src);
cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap();
let s = shapes(&d, r);
assert_eq!(s[3], [(-6.0, 20.0), (-6.0, 36.0)]);
assert_eq!(s[3], [(14.0, 1.0), (36.0, 1.0)]);
assert_eq!(s[4], [(20.0, 1.0), (30.0, 1.0)]);
assert_eq!(s[5], [(14.0, 1.0), (36.0, 1.0)]);
}
-2
View File
@@ -49,8 +49,6 @@ mod mtext_line_spacing;
mod multiline_attributes;
#[cfg(not(target_arch = "wasm32"))]
mod ole2frame_roundtrip;
#[cfg(not(target_arch = "wasm32"))]
mod ole_frame;
mod paperview_marker;
mod passthrough_image_roundtrip;
mod pdf_followups;
-96
View File
@@ -1,96 +0,0 @@
//! An OLE2FRAME is kept as an unknown entity; a DWG save carries it with its data and frame
//! position (#2422). A DWG's OLE2FRAME object has no corner fields: the position travels in the
//! start of the OLE data (a 2-byte marker, then the frame's four corners as three little-endian
//! doubles each: upper left, upper right, lower right, lower left), which is how the DXF's
//! groups 10 and 11 come back. That layout is read from the acadrust dependency's decoder.
use cadcraft_doc::EntityKind;
use cadcraft_io::{SaveOptions, Version, read, read_dxf, write_with};
const UL: (f64, f64) = (30.14, -18.99);
const LR: (f64, f64) = (35.27, -22.39);
/// The OLE data: the envelope with the frame's corners.
fn blob() -> Vec<u8> {
let mut b = vec![1u8, 0];
for (x, y) in [UL, (LR.0, UL.1), LR, (UL.0, LR.1)] {
for v in [x, y, 0.0] {
b.extend_from_slice(&v.to_le_bytes());
}
}
b
}
/// A 2000 DXF with one OLE2FRAME: corners, the binary data and the end marker (DXF Reference).
fn dxf() -> String {
let data = blob();
let hex: String = data.iter().map(|b| format!("{b:02X}")).collect();
format!(
"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n\
0\nOLE2FRAME\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbOle2Frame\n70\n2\n3\nOLE Object\n\
10\n{}\n20\n{}\n30\n0\n11\n{}\n21\n{}\n31\n0\n71\n2\n72\n0\n90\n{}\n310\n{hex}\n1\nOLE\n\
0\nENDSEC\n0\nEOF\n",
UL.0,
UL.1,
LR.0,
LR.1,
data.len()
)
}
fn ole(d: &cadcraft_doc::Drawing) -> Option<cadcraft_doc::Unknown> {
d.model.iter().find_map(|e| match &e.kind {
EntityKind::Unknown(u) if u.dxf_type == "OLE2FRAME" => Some(u.clone()),
_ => None,
})
}
#[test]
fn a_dwg_keeps_an_ole_frame_with_its_data_and_position() {
let d = read_dxf(dxf().as_bytes()).unwrap();
assert!(ole(&d).is_some());
let want: String = blob().iter().map(|b| format!("{b:02X}")).collect();
for v in [Version::R2000, Version::R2010, Version::R2018] {
let dwg = write_with(&d, "x.dwg", &SaveOptions { version: Some(v), binary: false }).unwrap();
let back = read(&dwg, "x.dwg").unwrap();
let u = ole(&back).unwrap_or_else(|| panic!("{v:?}: the OLE2FRAME is gone"));
let data: String = u.tags.iter().filter(|t| t.code == 310).map(|t| t.value.to_ascii_uppercase()).collect();
let corner = |code: i32| u.tags.iter().find(|t| t.code == code).and_then(|t| t.value.trim().parse::<f64>().ok());
assert_eq!(data, want, "{v:?}: data");
assert_eq!((corner(10), corner(20), corner(11), corner(21)), (Some(UL.0), Some(UL.1), Some(LR.0), Some(LR.1)), "{v:?}: frame corners");
}
}
/// A frame moved through its corner tags (what MOVE does) keeps the new position in a DWG save
/// and read-back (review on #2587).
#[test]
fn a_moved_ole_frame_keeps_its_position_in_a_dwg() {
let mut d = read_dxf(dxf().as_bytes()).unwrap();
for h in d.model.handles() {
d.model.modify(h, |e| e.kind.transform(&cadcraft_geom::Mat3::translate(cadcraft_geom::Vec2::new(100.0, -50.0))));
}
let dwg = write_with(&d, "x.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
let u = ole(&read(&dwg, "x.dwg").unwrap()).unwrap();
let c = |code: i32| u.tags.iter().find(|t| t.code == code).and_then(|t| t.value.trim().parse::<f64>().ok()).unwrap();
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(near(c(10), UL.0 + 100.0) && near(c(20), UL.1 - 50.0) && near(c(11), LR.0 + 100.0) && near(c(21), LR.1 - 50.0), "{:?}", u.tags);
}
/// After a move, a DXF save and read-back carries the moved corners both in groups 10/11 and in
/// the OLE data header (review on #2587).
#[test]
fn a_moved_ole_frame_keeps_its_data_header_in_a_dxf() {
let mut d = read_dxf(dxf().as_bytes()).unwrap();
for h in d.model.handles() {
d.model.modify(h, |e| e.kind.transform(&cadcraft_geom::Mat3::translate(cadcraft_geom::Vec2::new(100.0, -50.0))));
}
let bytes = write_with(&d, "x.dxf", &SaveOptions { version: None, binary: false }).unwrap();
let u = ole(&read_dxf(&bytes).unwrap()).unwrap();
let hex: String = u.tags.iter().filter(|t| t.code == 310).map(|t| t.value.clone()).collect();
let b: Vec<u8> = (0..hex.len() / 2).map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).unwrap()).collect();
let f = |o: usize| f64::from_le_bytes(b[o..o + 8].try_into().unwrap());
let near = |a: f64, c: f64| (a - c).abs() < 1e-9;
assert!(near(f(2), UL.0 + 100.0) && near(f(10), UL.1 - 50.0) && near(f(2 + 48), LR.0 + 100.0) && near(f(10 + 48), LR.1 - 50.0));
let g = |code: i32| u.tags.iter().find(|t| t.code == code).and_then(|t| t.value.trim().parse::<f64>().ok()).unwrap();
assert!(near(g(10), UL.0 + 100.0) && near(g(21), LR.1 - 50.0));
}
+1 -3
View File
@@ -256,9 +256,7 @@ pub(super) fn assert_layouts(a: &Drawing, b: &Drawing, context: &str) {
for layout in &a.layouts {
let back = b.layouts.iter().find(|l| l.name == layout.name).expect("layout must survive");
assert_eq!(back.tab_order, layout.tab_order, "{context}.{}.tab_order", layout.name);
// The groups 44, 45 and 73 the file stored are provenance, not a setting.
let setting = |p: &cadcraft_doc::PageSetup| cadcraft_doc::PageSetup { file_paper: None, ..p.clone() };
assert_eq!(setting(&back.page), setting(&layout.page), "{context}.{}.page", layout.name);
assert_eq!(back.page, layout.page, "{context}.{}.page", layout.name);
assert_eq!(back.view, layout.view, "{context}.{}.view", layout.name);
compare::entities(a, b, &layout.entities, &back.entities, &format!("{context}.layout[{}]", layout.name));
}
@@ -111,9 +111,7 @@ fn layout_page_setup_and_paper_view_round_trip() {
let r = round_trip(&d);
for (a, b) in d.layouts.iter().zip(&r.layouts) {
assert_eq!(a.name, b.name);
// The groups 44, 45 and 73 the file stored are provenance, not a setting.
let read = cadcraft_doc::PageSetup { file_paper: None, ..b.page.clone() };
assert_eq!(a.page, read, "{}", a.name);
assert_eq!(a.page, b.page, "{}", a.name);
assert_eq!(a.view, b.view, "{}", a.name);
}
assert_eq!(r.layouts.len(), d.layouts.len());
-35
View File
@@ -1,35 +0,0 @@
//! Synthetic DXF STYLE -> TEXT/MTEXT -> display list, without external font fixtures.
use cadcraft_doc::Space;
fn font() -> Vec<u8> {
let mut bytes = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec();
bytes.extend_from_slice(&[4, 0, 0, 0, 8, 0]);
bytes.extend_from_slice(&[b's', 0, 6, 2, 2, 0, 0, 0]);
for (id, program) in
[(32u16, vec![2, 8, 3, 0, 0]), (65, vec![8, 0, 6, 8, 4, 250, 2, 8, 3, 0, 0]), (193, vec![7, 0, 65, 5, 2, 8, 252, 7, 1, 8, 2, 1, 6, 0])]
{
bytes.extend_from_slice(&id.to_le_bytes());
bytes.extend_from_slice(&(program.len() as u16 + 1).to_le_bytes());
bytes.push(0);
bytes.extend(program);
}
bytes
}
#[test]
fn shx_style_and_unicode_escapes_render_as_native_strokes_and_roundtrip() {
cadcraft_fonts::shx::register("cadcraft-io-synthetic.shx", &font()).unwrap();
let dxf = "0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nSTYLE\n0\nSTYLE\n2\nR80\n3\ncadcraft-io-synthetic.shx\n0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nTEXT\n7\nR80\n10\n0\n20\n0\n40\n6\n1\nA\\U+00C1\n0\nMTEXT\n7\nR80\n10\n0\n20\n-20\n40\n6\n41\n18\n1\nA\\U+00C1 A\\U+00C1\n0\nENDSEC\n0\nEOF\n";
let drawing = cadcraft_io::read(dxf.as_bytes(), "synthetic.dxf").unwrap();
let display = cadcraft_render::build(&drawing, &Space::Model, &cadcraft_render::Options::default());
assert!(display.tris.is_empty(), "SHX letters and accents are strokes, not filled TrueType outlines");
assert!(display.verts.iter().any(|p| (p.x - 11.0).abs() < 1e-9), "native advance of 7, not the built-in advance");
assert!(display.verts.iter().any(|p| p.y > 6.0), "accent above the cap height");
assert!(display.verts.iter().any(|p| p.y < -27.0), "MTEXT wraps its second word");
let saved = cadcraft_io::write_dxf(&drawing);
let reopened = cadcraft_io::read(saved.as_bytes(), "synthetic.dxf").unwrap();
let again = cadcraft_render::build(&reopened, &Space::Model, &cadcraft_render::Options::default());
assert_eq!(display.verts, again.verts);
assert!(again.tris.is_empty());
}
-4
View File
@@ -17,7 +17,3 @@ serde_json = { workspace = true }
log = { workspace = true }
# Secure randomness for the per-session control token (MIT OR Apache-2.0).
getrandom = "0.4"
[dev-dependencies]
# Reads the pixels of rendered PNGs in the tests.
tiny-skia = { workspace = true }
+4 -10
View File
@@ -300,7 +300,7 @@ const HEADLESS_METHODS: &[(&str, &str, &str)] = &[
("engine.execute", "{command, params}", "Run any command with JSON parameters. Never opens a dialog."),
("engine.commands", "", "Every command: id, label, menu path, shortcut, aliases, params doc, enabled."),
("drawing.inspect", "{entities?, limit?}", "Drawing summary and entities with handles and geometry."),
("ui.render", "{path?, width?, height?, fit?, window?, background?, plot_style?}", "Render the drawing; replies pngBase64."),
("ui.render", "{path?, width?, height?, fit?}", "Render the drawing; replies pngBase64."),
("app.open", "{path}", "Open a drawing."),
("app.save", "{path?}", "Save the drawing (Save As with a path)."),
("control.methods", "", "This list."),
@@ -400,14 +400,7 @@ impl Backend for Headless {
let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0).clamp(16.0, 8192.0) as u32;
let st = s.state().map_err(|e| e.to_string())?;
let window = render_window(&p);
// `background` and `plot_style` (#803), read as the app reads them.
let look = cadcraft_engine::render_look::RenderLook::from_params(&st.doc, &p)?;
let opts = cadcraft_render::Options {
view: window,
transparency: s.settings.transparency_display,
plot_style_table: look.plot_style_table.clone(),
..Default::default()
};
let opts = cadcraft_render::Options { view: window, transparency: s.settings.transparency_display, ..Default::default() };
let list = cadcraft_render::build(&st.doc, &st.space, &opts);
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(true);
let side = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x as u32);
@@ -420,7 +413,8 @@ impl Backend for Headless {
cadcraft_render::raster::View { center: v.center, scale: f64::from(h) / v.height.max(1e-12), width: w, height: h }
};
let (w, h) = (view.width, view.height);
let png = cadcraft_render::raster::render_png(&list, &view, &look.raster()).ok_or("render failed")?;
let png =
cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()).ok_or("render failed")?;
let mut r =
json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "truncated": list.truncated});
if let Some(path) = str_p("path") {
-132
View File
@@ -259,135 +259,3 @@ fn a_lazy_bridge_starts_without_the_app_and_says_how_to_open_it() {
assert_ne!(r["result"]["isError"], json!(true), "{r}");
app.join().unwrap();
}
/// The pixels of a `render` tool result.
fn render_pixels(s: &mut Server, args: Value) -> tiny_skia::Pixmap {
let img = tool(s, "render", args);
assert_eq!(img["type"], "image", "{img}");
let png = cadcraft_engine::cmd::file::base64_decode(img["data"].as_str().unwrap()).unwrap();
tiny_skia::Pixmap::decode_png(&png).unwrap()
}
/// The RGB of pixel (x, y).
fn rgb(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) {
let c = pm.pixel(x, y).unwrap();
(c.red(), c.green(), c.blue())
}
/// The pixel of the middle column that differs most from the corner: what the line along the
/// middle of the image is drawn with.
fn ink(pm: &tiny_skia::Pixmap) -> (u8, u8, u8) {
let bg = rgb(pm, 0, 0);
let far = |c: &(u8, u8, u8)| bg.0.abs_diff(c.0) as u32 + bg.1.abs_diff(c.1) as u32 + bg.2.abs_diff(c.2) as u32;
(0..pm.height()).map(|y| rgb(pm, pm.width() / 2, y)).max_by_key(far).unwrap()
}
/// A drawing with one line along y = 0: colour 7 (layer 0's colour), or red on a red layer.
fn line_drawing(red: bool) -> Server {
let mut s = Server::new(Box::new(Headless::default()));
if red {
tool(&mut s, "execute", json!({"command": "layer.new", "params": {"name": "Red", "color": "red", "current": true}}));
}
tool(&mut s, "execute", json!({"command": "line", "params": {"points": [[0, 0], [10, 0]]}}));
s
}
/// Around the line, which runs along the middle of pixel row 20 of a 120 x 40 image (not on a
/// pixel edge, so it isn't blended across two rows).
const WINDOW: [[f64; 2]; 2] = [[-1.0, -1.95], [11.0, 2.05]];
/// Guards #803: `render` draws on the background asked for (`white`, `paper`, `#rrggbb`) and a
/// colour-7 line follows it, as on a plot: dark on white, light on the dark canvas, which stays
/// the default.
#[test]
fn render_draws_on_the_background_asked_for() {
let mut s = line_drawing(false);
let dark = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40}));
assert_eq!(rgb(&dark, 0, 0), (33, 40, 48), "the default stays the dark canvas");
let (r, g, b) = ink(&dark);
assert!(r > 200 && g > 200 && b > 200, "colour 7 is light on the dark canvas: {:?}", (r, g, b));
for bg in ["white", "paper", "WHITE"] {
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": bg}));
let (w, h) = (pm.width(), pm.height());
for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] {
assert_eq!(rgb(&pm, x, y), (255, 255, 255), "{bg}: corner ({x}, {y})");
}
let (r, g, b) = ink(&pm);
assert!(r < 128 && g < 128 && b < 128, "{bg}: colour 7 is drawn dark on white: {:?}", (r, g, b));
}
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "#336699"}));
assert_eq!(rgb(&pm, 0, 0), (0x33, 0x66, 0x99));
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "dark"}));
assert_eq!(rgb(&pm, 0, 0), (33, 40, 48));
}
/// Guards #803: `plot_style` draws with a built-in plot style table: `monochrome.ctb` turns a red
/// line black, which without one stays red.
#[test]
fn render_draws_with_the_plot_style_asked_for() {
let mut s = line_drawing(true);
let plain = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "white"}));
let (r, g, b) = ink(&plain);
assert!(r > 200 && g < 100 && b < 100, "red without a plot style: {:?}", (r, g, b));
for table in ["monochrome.ctb", "Monochrome", "monochrome.CTB"] {
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "white", "plot_style": table}));
let (r, g, b) = ink(&pm);
assert!(r < 100 && r == g && g == b, "{table}: red plots black: {:?}", (r, g, b));
}
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "white", "plot_style": "grayscale.ctb"}));
let (r, g, b) = ink(&pm);
assert!(r == g && g == b, "grayscale.ctb draws red grey: {:?}", (r, g, b));
}
/// Guards #803: a plot style alone plots on white, as on paper, so `monochrome.ctb` doesn't draw
/// black on the dark canvas; `background` still wins, and `plot_style: "none"` keeps the canvas
/// and the objects' colours.
#[test]
fn render_with_a_plot_style_plots_on_white() {
for red in [false, true] {
let mut s = line_drawing(red);
for table in ["monochrome.ctb", "grayscale.ctb"] {
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "plot_style": table}));
assert_eq!(rgb(&pm, 0, 0), (255, 255, 255), "{table} without a background plots on white");
let (r, g, b) = ink(&pm);
assert!(r == g && g == b && r < 128, "{table}, red {red}: drawn dark grey or black on white: {:?}", (r, g, b));
}
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "plot_style": "monochrome.ctb", "background": "dark"}));
assert_eq!(rgb(&pm, 0, 0), (33, 40, 48), "an explicit background wins");
for none in ["none", ""] {
let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "plot_style": none}));
assert_eq!(rgb(&pm, 0, 0), (33, 40, 48), "{none:?} keeps the dark canvas");
let (r, g, b) = ink(&pm);
let light = r > 200 && g > 200 && b > 200;
assert!(if red { r > 200 && g < 100 && b < 100 } else { light }, "{none:?}, red {red}: own colour {:?}", (r, g, b));
}
}
}
/// Guards #803: an unknown `background` or `plot_style` is refused with what is accepted, not
/// drawn some other way; a value of the wrong type is invalid params.
#[test]
fn render_refuses_unknown_backgrounds_and_plot_styles() {
let mut s = line_drawing(false);
for (args, wants) in [
(json!({"background": "purple"}), "#rrggbb"),
(json!({"background": "#12345"}), "#rrggbb"),
(json!({"background": "336699"}), "#rrggbb"),
(json!({"background": ""}), "#rrggbb"),
(json!({"plot_style": "nope.ctb"}), "monochrome.ctb"),
(json!({"plot_style": "default.ctx"}), "monochrome.ctb"),
] {
let r = call(&mut s, 1, "tools/call", json!({"name": "render", "arguments": args.clone()}));
assert_eq!(r["result"]["isError"], json!(true), "{args} must be refused, not drawn");
let msg = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(msg.contains(wants), "{args}: {msg}");
}
for args in [json!({"background": 7}), json!({"plot_style": ["monochrome.ctb"]})] {
let r = call(&mut s, 2, "tools/call", json!({"name": "render", "arguments": args.clone()}));
assert_eq!(r["error"]["code"], -32602, "{args} must be invalid params");
}
// The headless control method refuses them too.
let mut h = Headless::default();
assert!(crate::Backend::call(&mut h, "ui.render", json!({"background": "purple"})).is_err());
assert!(crate::Backend::call(&mut h, "ui.render", json!({"plot_style": 3})).is_err());
}
+1 -3
View File
@@ -22,11 +22,9 @@ pub fn tool_definitions(has_ui: bool) -> Value {
"inputSchema": {"type": "object", "properties": {"type": {"type": "string"}, "layer": {"type": "string"}, "selected": {"type": "boolean"},
"window": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2},
"crossing": {"type": "boolean"}, "limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}),
json!({"name": "render", "description": "Render the active drawing to a PNG and return it as an image content block (content[0].type is \"image\", not text): fitted to the extents by default, or just the area `window` [[x0,y0],[x1,y1]] (drawing units), e.g. one sheet or one room of a large drawing. With a window and no size the image is 1600 px wide with the window's shape. `background`: \"dark\" (the canvas; the default without a plot style), \"white\" (the default with one, as a plot prints on paper), \"paper\" or \"#rrggbb\"; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, e.g. \"monochrome.ctb\" (every colour black, as most plots) or \"grayscale.ctb\", which make light colours readable on white; \"none\" (or \"\") draws every object with its own properties, also in a layout that shows its own plot styles (without `plot_style` such a layout draws with its table, as on screen). An unknown background or plot style is refused.",
json!({"name": "render", "description": "Render the active drawing to a PNG and return it as an image: fitted to the extents by default, or just the area `window` [[x0,y0],[x1,y1]] (drawing units), e.g. one sheet or one room of a large drawing. With a window and no size the image is 1600 px wide with the window's shape.",
"inputSchema": {"type": "object", "properties": {"width": {"type": "integer", "minimum": 16}, "height": {"type": "integer", "minimum": 16}, "fit": {"type": "boolean"},
"window": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2},
"background": {"type": "string", "description": "\"dark\" (default without plot_style), \"white\" (default with one), \"paper\" or \"#rrggbb\""},
"plot_style": {"type": "string", "description": "a plot style table: monochrome.ctb, grayscale.ctb, default.ctb, default.stb, monochrome.stb, or one the drawing holds; \"none\" or \"\" for the objects' own properties"},
"path": {"type": "string"}}}}),
json!({"name": "find_text", "description": "Read the drawing's texts: TEXT, MTEXT (formatting removed), block attributes and multileaders, each with its text, insertion point `at`, height, rotation, style, layer and handle. Filter by `contains` (any case), `layer` and/or `window` [[x0,y0],[x1,y1]] around the insertion points; page with offset and limit (default 200). Use it to find room names, sheet titles and notes without reading all the geometry.",
"inputSchema": {"type": "object", "properties": {"contains": {"type": "string"}, "layer": {"type": "string"},
-6
View File
@@ -227,12 +227,6 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
b.polyline(ctx, rgb, lw, l);
}
}
// An OLE object's content is not drawn; its frame, from its two corner points, is.
EntityKind::Unknown(u) if u.is_ole_frame() => {
if let Some(q) = cadcraft_doc::ole_frame(&u.tags) {
b.polyline(ctx, rgb, lw, &q);
}
}
kind => {
for prim in kind.prims() {
match prim {
+1 -6
View File
@@ -64,14 +64,9 @@ pub struct RasterOptions {
pub antialias: bool,
}
impl RasterOptions {
/// The dark canvas a render draws on by default.
pub const DARK_BACKGROUND: Rgb = Rgb(33, 40, 48);
}
impl Default for RasterOptions {
fn default() -> Self {
RasterOptions { background: Self::DARK_BACKGROUND, hairline: 1.0, px_per_mm: 0.0, antialias: true }
RasterOptions { background: Rgb(33, 40, 48), hairline: 1.0, px_per_mm: 0.0, antialias: true }
}
}
-14
View File
@@ -1127,20 +1127,6 @@ fn raster_hatch_pattern_matches_one_by_one() {
}
}
/// An OLE2FRAME draws its frame, the rectangle from its two corner points (#2422), and nothing
/// when a corner is missing.
#[test]
fn ole_frame_draws_its_outline() {
let tag = |code: i32, value: &str| cadcraft_doc::RawTag { code, value: value.into() };
let frame = |tags: Vec<cadcraft_doc::RawTag>| EntityKind::Unknown(cadcraft_doc::Unknown { dxf_type: "OLE2FRAME".into(), tags });
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), frame(vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")])).unwrap();
d.add(&Space::Model, Common::default(), frame(vec![tag(10, "1"), tag(20, "2")])).unwrap();
let l = build(&d, &Space::Model, &Options { tolerance: 0.01, ..Default::default() });
assert_eq!(l.prims.len(), 1, "only the frame with both corners is drawn");
assert_eq!(l.prims[0].len, 5, "a closed rectangle: five points");
}
/// `Options::linetypes` off draws a dashed line, a dashed wide polyline and a linetype on a layer
/// as the same whole line a Continuous one draws: what hit tests look at.
#[test]
-1
View File
@@ -19,7 +19,6 @@ cadcraft-engine = { workspace = true }
cadcraft-fonts = { workspace = true }
craft-window = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true }
# GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature).
egui-wgpu = { workspace = true }
serde = { workspace = true }
-3
View File
@@ -101,9 +101,6 @@ fn same_content(a: &ListKey, b: &ListKey) -> bool {
#[derive(Default)]
pub struct CanvasState {
pub list: Option<DisplayList>,
/// The font set the list was drawn with ([`cadcraft_fonts::ttf::generation`] and
/// [`cadcraft_fonts::shx::generation`]): a new font folder redraws the text.
font_generation: u64,
key: ListKey,
/// The area `list` was built for (hatch patterns exist only inside it); a view leaving it
/// rebuilds the list.
+1 -1
View File
@@ -226,7 +226,7 @@ pub(super) fn draw_drag_gpu(
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(c.key, band, bg.0, bg.1, bg.2, &drag.handles, transparency, super::list::font_generation()).hash(&mut h);
(c.key, band, bg.0, bg.1, bg.2, &drag.handles, transparency).hash(&mut h);
if drag.clipboard {
clipboard.1.hash(&mut h);
}
+1 -17
View File
@@ -63,23 +63,8 @@ pub(super) fn rebuilt_preview<'a>(doc: &cadcraft_doc::Drawing, ents: impl IntoIt
/// A rebuild only the view asks for (a new zoom band, a pan out of the built area) runs in the
/// background while the current list is drawn (see [`rebuild`]); a changed drawing, or no list
/// yet, is built at once.
/// The font set text is drawn with: TrueType fonts (font folders, rescans) and SHX fonts
/// (drawing folders, font folders, the search path) can each change what text looks like. Every
/// cache of drawn geometry keys on it.
pub(super) fn font_generation() -> u64 {
cadcraft_fonts::ttf::generation().wrapping_add(cadcraft_fonts::shx::generation() << 32)
}
pub(super) fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], ctx: &egui::Context) {
let px = 1.0 / xf.scale;
let fonts = font_generation();
if app.canvas.font_generation != fonts {
app.canvas.font_generation = fonts;
app.canvas.list = None;
app.canvas.job = None;
// The model space seen through a layout's active viewport is drawn apart: rebuilt too.
app.canvas.vp.key = None;
}
let Ok(st) = app.session.state() else { return };
// Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a
// view leaves the area the list was built for.
@@ -194,8 +179,7 @@ impl OnlyList {
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, band, &only.handles, format!("{:?}", only.extra), font_generation())
.hash(&mut h);
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, band, &only.handles, format!("{:?}", only.extra)).hash(&mut h);
outline.into_iter().flatten().for_each(|q| (q.x.to_bits(), q.y.to_bits()).hash(&mut h));
h.finish()
};
+1 -2
View File
@@ -286,8 +286,7 @@ impl VpHighlight {
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, super::list::font_generation())
.hash(&mut h);
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles).hash(&mut h);
h.finish()
};
if self.key != Some(key) {
+2 -25
View File
@@ -153,29 +153,9 @@ fn object_commands(app: &CadApp) -> Vec<(&'static str, Option<&'static str>)> {
}
}
/// [`object_commands`] that are registered, as menu items. The Edit Text entry of a single
/// selected text, mtext or attribute definition opens the Edit Text window instead of the
/// TEXTEDIT command line; the double-click keeps its in-place editing (#842), and several
/// selected texts still take the command line.
/// [`object_commands`] that are registered, as menu items.
pub fn object_entries(app: &CadApp) -> Vec<Entry> {
object_commands(app)
.into_iter()
.filter_map(|(id, label)| if id == "textedit" { edit_text_window_entry(app).or_else(|| item(app, id, label)) } else { item(app, id, label) })
.collect()
}
/// The Edit Text entry as the `textedit:<handle>` dialog id, for a single selected text, mtext
/// or attribute definition; `None` otherwise, so the command line entry stands in.
fn edit_text_window_entry(app: &CadApp) -> Option<Entry> {
use cadcraft_engine::doc::EntityKind;
let sel = app.session.selection();
if sel.len() != 1 {
return None;
}
let h = *sel.first()?;
let d = app.session.doc().ok()?;
d.entity(h).filter(|e| matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_)))?;
Some(Entry::Item { label: "Edit Text".into(), id: format!("textedit:{}", h.hex()), shortcut: None, enabled: true })
object_commands(app).into_iter().filter_map(|(id, label)| item(app, id, label)).collect()
}
/// The command menu while a command runs (`None` when none does): Enter and Cancel, Recent Input,
@@ -233,9 +213,6 @@ pub fn choose(app: &mut CadApp, id: &str) {
}
} else if let Some(s) = id.strip_prefix(SNAP) {
choose_snap(app, s);
} else if id.starts_with("textedit:") {
// The Edit Text window of the single selected text object; the id carries the handle.
app.ui.dialog = Some(id.to_string());
} else {
activate(app, id);
}
+5 -81
View File
@@ -15,11 +15,8 @@
//! - `ui.click {x, y, shift?, ctrl?}`, `ui.move {x, y}`: real egui pointer input in screen points
//! - `ui.key {key, cmd?, shift?, alt?}`, `ui.text {text}`: synthetic keyboard input
//! - `ui.set {...UiState fields}`, `ui.resize {width, height}`
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?, window?,
//! background?, plot_style?}`: headless render of the drawing (no window needed; fits the drawing
//! unless `fit` is false or a `window` is given; on the dark canvas unless `background` is
//! `white`, `paper` or `#rrggbb`; `plot_style` names a plot style table, which plots on white
//! unless `background` is given, or is `none`; replies `pngBase64`)
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?}`: headless
//! render of the drawing (no window needed; fits the drawing unless `fit` is false; replies `pngBase64`)
//! - `app.open {path}`, `app.save {path?}`, `app.quit`
//! - `control.methods`: this list, with params and a description; `ping`: replies `"pong"`
@@ -214,7 +211,7 @@ const METHODS: &[(&str, &str, &str)] = &[
("ui.resize", "{width, height}", "Resize the window."),
("ui.focus", "", "Activate the native window."),
("ui.screenshot", "{path?}", "PNG of the window (needs a presented frame)."),
("ui.render", "{path?, width?, height?, fit?, window?, background?, plot_style?}", "Headless render of the drawing; replies pngBase64."),
("ui.render", "{path?, width?, height?, fit?}", "Headless render of the drawing; replies pngBase64."),
("app.open", "{path}", "Open a drawing."),
("app.save", "{path?}", "Save the drawing (Save As with a path)."),
("app.quit", "", "Quit without the unsaved-changes prompt."),
@@ -478,17 +475,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
let Ok(st) = app.session.state() else { return err("no drawing") };
// `window`: just that area, at the size given or matching its shape.
let window = render_window(p);
// `background` and `plot_style` (#803), read as the headless MCP backend reads them.
let look = match cadcraft_engine::render_look::RenderLook::from_params(&st.doc, p) {
Ok(look) => look,
Err(e) => return err(e),
};
let opts = cadcraft_render::Options {
view: window,
transparency: app.session.settings.transparency_display,
plot_style_table: look.plot_style_table.clone(),
..Default::default()
};
let opts = cadcraft_render::Options { view: window, transparency: app.session.settings.transparency_display, ..Default::default() };
let list = cadcraft_render::build(&st.doc, &st.space, &opts);
let v = st.view();
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(true);
@@ -497,7 +484,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
None => render_view(&list.bounds, v.center, v.height, fit, w, h),
};
let (w, h) = (view.width, view.height);
let Some(png) = cadcraft_render::raster::render_png(&list, &view, &look.raster()) else {
let Some(png) = cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()) else {
return err("render failed");
};
#[cfg(not(target_arch = "wasm32"))]
@@ -847,67 +834,4 @@ mod tests {
assert!(render_reply(b"abc", 7, 5, None, true).get("path").is_none());
assert_eq!(render_reply(b"abc", 7, 5, None, true)["truncated"], true);
}
/// The reply to a `ui.render` request in an app showing one line along y = 0: colour 7, or
/// red on a red layer.
fn render_line(red: bool, params: Value) -> Value {
let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default());
app.session.new_drawing(false);
if red {
app.session.execute("layer.new", &json!({"name": "Red", "color": "red", "current": true})).unwrap();
}
app.session.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let (req, _rx) = ControlRequest::new("ui.render", params);
match handle(&mut app, &egui::Context::default(), &req) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => panic!("ui.render must not take a screenshot"),
}
}
/// The RGB of a reply's corner pixel and of its middle column's darkest pixel.
fn corner_and_darkest(reply: &Value) -> ([u8; 3], [u8; 3]) {
let png = cadcraft_engine::cmd::file::base64_decode(reply["result"]["pngBase64"].as_str().unwrap_or_default()).unwrap();
let img = image::load_from_memory(&png).unwrap().to_rgb8();
let corner = img.get_pixel(0, 0).0;
let darkest = (0..img.height()).map(|y| img.get_pixel(img.width() / 2, y).0).min_by_key(|p| p.iter().map(|&c| u32::from(c)).sum::<u32>());
(corner, darkest.unwrap_or_default())
}
/// Guards #803 in the app: `ui.render` takes `background` and `plot_style` as the headless MCP
/// backend does (colour 7 drawn dark on white, a red line black with `monochrome.ctb`, white
/// by default with a plot style), keeps the dark canvas by default and refuses unknown values.
#[test]
fn render_takes_a_background_and_a_plot_style() {
// The line runs along the middle of pixel row 20 of the 120 x 40 image.
let window = json!([[-1.0, -1.95], [11.0, 2.05]]);
let render = |red: bool, mut p: Value| {
p["window"] = window.clone();
p["width"] = json!(120);
p["height"] = json!(40);
let r = render_line(red, p.clone());
assert_eq!(r["ok"], true, "{p}: {}", r["error"]);
corner_and_darkest(&r)
};
assert_eq!(render(false, json!({})).0, [33, 40, 48], "the default stays the dark canvas");
let (corner, ink) = render(false, json!({"background": "white"}));
assert_eq!(corner, [255, 255, 255]);
assert!(ink.iter().all(|&c| c < 128), "colour 7 is drawn dark on white: {ink:?}");
// A red line stays red without a plot style and plots black with monochrome.ctb, on white
// unless `background` says otherwise.
let (_, [r, g, b]) = render(true, json!({"background": "paper"}));
assert!(r > 200 && g < 100 && b < 100, "red without a plot style: {:?}", [r, g, b]);
for (p, bg) in [
(json!({"plot_style": "monochrome.ctb"}), [255, 255, 255]),
(json!({"plot_style": "monochrome.ctb", "background": "#336699"}), [0x33, 0x66, 0x99]),
] {
let (corner, [r, g, b]) = render(true, p.clone());
assert_eq!(corner, bg, "{p}");
assert!(r < 100 && r == g && g == b, "{p}: red plots black: {:?}", [r, g, b]);
}
for p in [json!({"background": "purple"}), json!({"background": 7}), json!({"plot_style": "nope.ctb"})] {
let r = render_line(false, p.clone());
assert_eq!(r["ok"], false, "{p} must be refused");
assert!(r.get("result").is_none(), "{p}");
}
}
}

Some files were not shown because too many files have changed in this diff Show More