mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-12 00:45:16 -06:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a54d694f4f | ||
|
|
d12f592d80 | ||
|
|
567dfd49ed | ||
|
|
8d90dec9ec |
@@ -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
|
||||
@@ -18,12 +18,6 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
@@ -64,7 +58,7 @@ jobs:
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: [git-sources, credits]
|
||||
needs: git-sources
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
@@ -76,7 +70,6 @@ jobs:
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
+50
-78
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -631,7 +631,6 @@ dependencies = [
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
@@ -645,7 +644,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -661,14 +660,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -678,7 +677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -689,7 +688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
@@ -697,7 +696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -706,7 +705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -727,9 +726,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
@@ -740,14 +738,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -769,22 +767,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -798,7 +794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -812,7 +808,6 @@ dependencies = [
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
@@ -825,17 +820,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
@@ -1214,7 +1206,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1237,26 +1229,6 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1541,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"
|
||||
@@ -1622,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"
|
||||
@@ -5993,7 +5932,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
@@ -6226,6 +6165,7 @@ dependencies = [
|
||||
"nix 0.31.3",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6240,6 +6180,18 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
@@ -6295,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"
|
||||
|
||||
+5
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.1"
|
||||
version = "0.17.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -63,15 +63,13 @@ 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"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-27
@@ -2,18 +2,16 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (re-audit against main 41d9b5e95: language table, CI gate, open-PR count; previously: progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
|
||||
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
|
||||
scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
|
||||
|
||||
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
|
||||
Precise drafting still has approximate ellipse and spline intersections and a few stubbed prompt options
|
||||
(object snap tracking, TRIM/EXTEND/OFFSET options and typed snap overrides have landed); dynamic blocks can't be
|
||||
authored and xrefs can't be edited in place (the Block Editor, REFEDIT, dynamic block editing and XCLIP have landed);
|
||||
plots go only to PDF, PNG and SVG files (plot styles and the Plot dialog have landed); and a DWG saved by
|
||||
CADCraft drops what acadrust doesn't model and has never been checked in
|
||||
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
|
||||
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model and has never been checked in
|
||||
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
|
||||
is ≈ 600–930 h away.
|
||||
|
||||
@@ -21,7 +19,7 @@ is ≈ 600–930 h away.
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491 at the 2026-10-10 re-measure, including UI-only commands; not re-measured since; the generated checklist counts engine commands only and now reads 298 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
|
||||
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
|
||||
| Ready for real work | **≈ 38%** | estimated ([target-app-parity.md](docs/target-app-parity.md)) |
|
||||
| Mainstream practitioner (2D drafter) | **≈ 28%** (lower than the full number: file exchange, stability and interaction discounts multiply) | estimated ([target-app-parity.md](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Essentials user | **≈ 46%** | estimated ([target-app-parity.md](docs/target-app-parity.md#essentials-user)) |
|
||||
@@ -82,40 +80,40 @@ Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's own build:
|
||||
|
||||
## Languages
|
||||
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus eighteen catalogs
|
||||
(Ukrainian, Brazilian Portuguese, German, Spanish, Czech, Russian, French, Simplified Chinese, Italian, Korean, Swedish, Arabic, Greek, Hungarian, Turkish, Bosnian, Georgian and Vietnamese). Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus partial
|
||||
Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Status | UI translated |
|
||||
|---|---|---:|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | partial | 1,337 catalog rows |
|
||||
| Spanish | partial | 1,243 catalog rows |
|
||||
| Simplified Chinese | none | 0% |
|
||||
| Spanish | none | 0% |
|
||||
| Hindi | none | 0% |
|
||||
| Arabic | partial | 1,229 catalog rows |
|
||||
| French | partial | 1,004 catalog rows |
|
||||
| Portuguese (Brazilian) | partial | 1,243 catalog rows |
|
||||
| Arabic | none | 0% |
|
||||
| French | none | 0% |
|
||||
| Portuguese | none | 0% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | none | 0% |
|
||||
| Greek | partial | 1,330 catalog rows; independent proofreading pending |
|
||||
| German | partial | 1,031 catalog rows |
|
||||
| Korean | partial | 1,235 catalog rows |
|
||||
| Vietnamese | partial | 1,460 catalog rows (2026-11-07); native review pending |
|
||||
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
|
||||
| German | partial | ≈ 100% of the catalog (989 rows) |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Other languages shipped: Ukrainian (1,357 rows, the reference catalog, #36), Czech (1,215), Russian (1,005), Italian (1,352), Swedish (1,382), Hungarian (1,326) and Turkish (1,330), counted 2026-10-24; engine messages stay English. Every catalog but Ukrainian's has strings added since it landed that still fall back to English: 6 (Italian, Swedish) to 296 (French) per `cargo xtask i18n` ([localization-parity.md](docs/localization-parity.md)).
|
||||
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. Gate and land: CI runs `cargo xtask ci` on every PR since #1987 (fmt on main is fixed); land the 450 open PRs (2026-10-24) (15–25 h).
|
||||
2. Stop losing data on save (DXF done, #528; native MULTILEADER, #459, and the save version, #421, done; through DWG, acadrust still drops some content) (30–45 h).
|
||||
1. Gate and land: fix fmt on main, run `cargo xtask ci` on every PR, land the 47 open PRs (15–25 h).
|
||||
2. Stop losing data on save; native MULTILEADER; save DWG/DXF at a chosen version (30–45 h).
|
||||
3. Real-file DWG/DXF corpus and a black-box check in AutoCAD (10–15 h + owner).
|
||||
4. ~~Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
|
||||
editable dynamic input.~~ Done (#379, #441, #60, #519; `snap/tracking.rs`, `pointmod.rs`, `dyninput.rs`).
|
||||
5. ~~Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER.~~ Done (#397, #408, #547); the stubs left are in gaps F1.
|
||||
6. ~~Autosave, recovery, AUDIT, RECOVER.~~ Done on the desktop (#406, #416); the web build has no autosave (gaps F4).
|
||||
4. Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
|
||||
editable dynamic input (25–35 h).
|
||||
5. Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER (15–25 h).
|
||||
6. Autosave, recovery, AUDIT, RECOVER (10–15 h).
|
||||
7. Exact ellipse/spline geometry and correct polyline offsets (30–50 h).
|
||||
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h): xrefs (#525), groups (#417), the Block Editor (#824, #833, #885), REFEDIT (#818), XCLIP and XCLIPFRAME (#2354) and dynamic block references (#838–#883) landed; left: dynamic-block authoring, editing an xref in place.
|
||||
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h).
|
||||
|
||||
## Progress log
|
||||
|
||||
@@ -138,7 +136,6 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: language table, CI gate and open-PR count re-checked against main 41d9b5e95 |
|
||||
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
|
||||
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -19,10 +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.
|
||||
# The page command API (storytold/craft-libs#413): window.cadcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -35,5 +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"
|
||||
js-sys = "0.3"
|
||||
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"] }
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//! `window.cadcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
|
||||
//! own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is CADCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on drops its [`PendingReply`], which cancels a request the app has
|
||||
//! not started, so the app skips it instead of running it late.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use cadcraft_ui_egui::{ControlRequest, PendingReply};
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "cadcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.cadcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `CadApp::with_control`). An existing `window.cadcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame. Dropping this future (the page's timeout) drops
|
||||
/// `reply`, which cancels the request if the app has not started it.
|
||||
async fn wait(reply: PendingReply) -> Value {
|
||||
loop {
|
||||
if let Some(reply) = reply.try_reply() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
+102
-62
@@ -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());
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
//! 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")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod download;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -30,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
|
||||
@@ -49,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.
|
||||
@@ -98,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
|
||||
@@ -128,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();
|
||||
@@ -149,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
|
||||
@@ -160,10 +157,9 @@ 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 control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = CadApp::new(session, services()).with_control(control);
|
||||
let mut app = CadApp::new(session, services());
|
||||
app.system_languages =
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
|
||||
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
|
||||
@@ -196,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)))
|
||||
|
||||
@@ -55,11 +55,7 @@ muda = { version = "0.21", default-features = false }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
|
||||
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
|
||||
# `rwh_06`: the window handle a file dialog shown as a sheet attaches to (src/sheet.rs).
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/sheet.rs). MIT OR Apache-2.0, already
|
||||
# in Cargo.lock through eframe.
|
||||
pollster = "1"
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -250,18 +250,17 @@ fn parse_line(bytes: &[u8]) -> Line {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Value::Object(mut obj) = msg else {
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(Value::String(method)) = obj.remove("method") else {
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
// Moved out of the parsed object, not cloned: `params` can be most of the line.
|
||||
Line::Request {
|
||||
id: obj.remove("id").unwrap_or(Value::Null),
|
||||
method,
|
||||
params: obj.remove("params").unwrap_or(json!({})),
|
||||
token: obj.remove("token").and_then(|t| if let Value::String(t) = t { Some(t) } else { None }),
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,20 +148,7 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
// A message can carry text from a file (a code page name, a path), so it must not be able to
|
||||
// start what looks like another record: control characters other than tab and line feed are
|
||||
// shown escaped, and every continuation line is indented, so only this function's own
|
||||
// timestamp is ever at the start of a line.
|
||||
let mut safe = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => safe.push_str("\n "),
|
||||
'\t' => safe.push('\t'),
|
||||
c if c.is_control() => safe.extend(c.escape_default()),
|
||||
c => safe.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -454,14 +441,8 @@ mod tests {
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
|
||||
);
|
||||
// A multi-line message keeps its lines, indented so they can't pass for records; the
|
||||
// record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
|
||||
// Control characters in a message (from a file) can't forge a record.
|
||||
let forged = format_line("t", log::Level::Warn, "w", "x", "page\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[0m");
|
||||
assert_eq!(forged.lines().count(), 2);
|
||||
assert!(forged.lines().skip(1).all(|l| l.starts_with(" ")));
|
||||
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'));
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+22
-43
@@ -23,15 +23,13 @@ mod launch_args;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod sheet;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::switch::Switch;
|
||||
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
|
||||
use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
@@ -161,41 +159,28 @@ pub fn install_io() {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| file_dialog(&Pick::Open).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_save: Some(Box::new(|name: &str| file_dialog(&Pick::Save(name.into())).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
// macOS shows the dialogs as sheets (`sheet::start_pick`), set once the window exists.
|
||||
start_pick: None,
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| file_dialog(&Pick::Lisp(dir.into())).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
no_file_system: false,
|
||||
pick_pgp: Some(Box::new(|dir: Option<&str>| {
|
||||
file_dialog(&Pick::Pgp(dir.unwrap_or_default().into())).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types `pick` offers: (label, extensions). Shared by the blocking dialogs and the
|
||||
/// macOS sheets.
|
||||
fn filters(pick: &Pick) -> Vec<(&'static str, Vec<&'static str>)> {
|
||||
match pick {
|
||||
Pick::Open => vec![
|
||||
(i18n::t("Drawings (DWG, DXF)"), vec!["dwg", "dxf", "dwt"]),
|
||||
(i18n::t("SVG drawings (SVG, SVGZ)"), vec!["svg", "svgz"]),
|
||||
(i18n::t("All files"), vec!["*"]),
|
||||
],
|
||||
Pick::Save(name) => filters_for_save(name).iter().map(|(label, ext)| (i18n::t(label), vec![*ext])).collect(),
|
||||
Pick::Lisp(_) => vec![(i18n::t("AutoLISP (LSP)"), vec!["lsp"]), (i18n::t("All files"), vec!["*"])],
|
||||
Pick::Pgp(_) => vec![(i18n::t("Alias files (PGP)"), vec!["pgp"])],
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking dialog for `pick` (Windows, Linux, FreeBSD).
|
||||
fn file_dialog(pick: &Pick) -> rfd::FileDialog {
|
||||
let dialog = filters(pick).iter().fold(rfd::FileDialog::new(), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
match pick {
|
||||
Pick::Save(name) => dialog.set_file_name(name),
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) if !dir.is_empty() => dialog.set_directory(dir),
|
||||
_ => dialog,
|
||||
pick_save: Some(Box::new(|name: &str| {
|
||||
filters_for_save(name)
|
||||
.iter()
|
||||
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext]))
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
no_file_system: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,12 +353,6 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::empty(), services());
|
||||
// File dialogs are sheets on macOS: a modal loop inside the event handler crashes the
|
||||
// app when the dialog is resized (#1527).
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = cc.winit_window().cloned() {
|
||||
app.services.start_pick = Some(sheet::start_pick(window, cc.egui_ctx.clone()));
|
||||
}
|
||||
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
|
||||
if let Some(ui) = restored {
|
||||
app.ui = ui;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! macOS file dialogs as sheets. Shown in line, or from a helper thread that rfd hands to the main
|
||||
//! thread's modal loop, a panel runs AppKit's modal event loop; resizing it sends window events
|
||||
//! back into winit's event handler and winit aborts the app (#1527). A sheet returns at once and
|
||||
//! the window keeps running; a thread of its own waits for the answer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_ui_egui::{Pick, StartPick};
|
||||
|
||||
type Answer = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
|
||||
|
||||
/// `Services::start_pick`: shows `pick` as a sheet on `window`; `ui` is woken when it answers.
|
||||
pub fn start_pick(window: Arc<winit::window::Window>, ui: egui::Context) -> StartPick {
|
||||
Box::new(move |pick, done| {
|
||||
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<Answer>();
|
||||
let ui = ui.clone();
|
||||
// The thread first, so the answer of a sheet once shown always has somewhere to go. If it
|
||||
// cannot start, `done` is dropped unanswered and the app says the dialog could not be
|
||||
// shown.
|
||||
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
|
||||
if let Ok(answer) = sheet_rx.recv() {
|
||||
done(pollster::block_on(answer));
|
||||
ui.request_repaint();
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("file dialog: {e}");
|
||||
return;
|
||||
}
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
let dialog =
|
||||
crate::filters(&pick).iter().fold(rfd::AsyncFileDialog::new().set_parent(&*window), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
let path = |f: rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: Answer = match pick {
|
||||
Pick::Save(name) => {
|
||||
let picked = dialog.set_file_name(name).save_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) => {
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Open => {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
};
|
||||
let _ = sheet_tx.send(answer);
|
||||
})
|
||||
}
|
||||
@@ -37,9 +37,7 @@ display_name = "Tasos Sangiotis"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
sync_github_name = true
|
||||
|
||||
[people.WladmirJunior]
|
||||
real_name = "Wladmir Júnior"
|
||||
@@ -90,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"
|
||||
@@ -115,6 +109,3 @@ real_name = "Andrea Brandi"
|
||||
|
||||
[people.qinshah]
|
||||
sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -232,10 +232,6 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
let mut extra = Vec::new();
|
||||
let mut on = Vec::new();
|
||||
for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) {
|
||||
// Nothing is added once the candidates are full, so the points left need no search.
|
||||
if extra.len() + on.len() + out.len() >= MAX_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) {
|
||||
continue;
|
||||
}
|
||||
@@ -271,22 +267,20 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
// Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line).
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 };
|
||||
let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name()));
|
||||
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && placed.contains(&r.handle)) {
|
||||
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
|
||||
for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) {
|
||||
// Full: nothing more can be added, so the pairs left need no geometry or duplicate search.
|
||||
if out.len() + extra.len() + on.len() >= MAX_CANDIDATES {
|
||||
break 'per;
|
||||
}
|
||||
let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2));
|
||||
let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p));
|
||||
let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p));
|
||||
let ang = ang_between(*b1 - *a1, *b2 - *a2);
|
||||
// The duplicate search scans every candidate, so it comes after the cheap tests.
|
||||
if tee
|
||||
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
|
||||
if allow_per
|
||||
&& tee
|
||||
&& !dup
|
||||
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
|
||||
&& l1.len() > tol
|
||||
&& l2.len() > tol
|
||||
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
|
||||
&& out.len() < MAX_CANDIDATES
|
||||
{
|
||||
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
|
||||
}
|
||||
|
||||
@@ -138,9 +138,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
// The selection as a set, built once: a scan of the list per slot multiplied the two sizes.
|
||||
let selected: HashSet<Handle> = handles.iter().copied().collect();
|
||||
let want = |h: Handle| handles.is_empty() || selected.contains(&h);
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut total = Some(0usize);
|
||||
|
||||
+1
-31
@@ -204,23 +204,6 @@ impl ArrayParams {
|
||||
self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str())
|
||||
}
|
||||
|
||||
/// [`block_for`](Self::block_for) for many items: the replacements are indexed once (the last
|
||||
/// entry for a place wins, as there), so each lookup is O(1) instead of a scan of `replaced`.
|
||||
pub fn block_lookup<'a>(&'a self) -> impl Fn(u32, u32, u32) -> &'a str + 'a {
|
||||
// Only places `items()` generates are indexed, so stray out-of-range records cost nothing.
|
||||
let (cols, rows) = match &self.kind {
|
||||
ArrayKind::Rect(r) => (r.cols, r.rows),
|
||||
ArrayKind::Polar(p) => (p.items, p.rows),
|
||||
};
|
||||
let index: std::collections::HashMap<(u32, u32, u32), &str> = self
|
||||
.replaced
|
||||
.iter()
|
||||
.filter(|r| r.i < cols && r.j < rows && r.level < self.levels)
|
||||
.map(|r| ((r.i, r.j, r.level), r.block.as_str()))
|
||||
.collect();
|
||||
move |i, j, level| index.get(&(i, j, level)).copied().unwrap_or(self.source.as_str())
|
||||
}
|
||||
|
||||
/// The block references that make up the array block: one per item, on layer 0 with ByBlock
|
||||
/// properties so they take the array's. Handles are allocated from `d`. `None` when the
|
||||
/// parameters are not [valid](Self::is_valid).
|
||||
@@ -234,10 +217,9 @@ impl ArrayParams {
|
||||
..Common::default()
|
||||
};
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let block_for = self.block_lookup();
|
||||
for it in items {
|
||||
let ins = Insert {
|
||||
block: block_for(it.i, it.j, it.level).to_string(),
|
||||
block: self.block_for(it.i, it.j, it.level).to_string(),
|
||||
insert: it.insert,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: it.rotation,
|
||||
@@ -319,18 +301,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_lookup_agrees_with_block_for_and_the_last_replacement_wins() {
|
||||
let mut p = rect(3, 2);
|
||||
let r = |i, j, level, b: &str| ReplacedItem { i, j, level, block: b.into() };
|
||||
p.replaced = vec![r(1, 0, 0, "A"), r(1, 0, 0, "B"), r(2, 1, 1, "C")];
|
||||
let look = p.block_lookup();
|
||||
for (i, j, level) in [(0, 0, 0), (1, 0, 0), (2, 1, 1), (2, 1, 0)] {
|
||||
assert_eq!(look(i, j, level), p.block_for(i, j, level));
|
||||
}
|
||||
assert_eq!((look(1, 0, 0), look(0, 0, 0)), ("B", "*U1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_items_go_column_row_level() {
|
||||
let items = rect(3, 2).items().unwrap_or_default();
|
||||
|
||||
@@ -674,10 +674,6 @@ fn apply(
|
||||
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
|
||||
/// outline again (it may have been stretched).
|
||||
fn refill_hatches(b: &mut Block) {
|
||||
// Each loop gets its own copy of the outline, so many small loops on one big outline multiply it;
|
||||
// past this many vertices in all, the remaining loops keep the outline they have.
|
||||
const MAX_REFILLED_VERTICES: usize = 4_000_000;
|
||||
let mut budget = MAX_REFILLED_VERTICES;
|
||||
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
|
||||
.entities
|
||||
.iter()
|
||||
@@ -693,9 +689,7 @@ fn refill_hatches(b: &mut Block) {
|
||||
for l in &mut hh.loops {
|
||||
if let [s] = l.sources.as_slice()
|
||||
&& let Some(vs) = outlines.get(s)
|
||||
&& let Some(left) = budget.checked_sub(vs.len())
|
||||
{
|
||||
budget = left;
|
||||
l.vertices.clone_from(vs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,6 @@ impl ForeignExtra {
|
||||
pub struct Foreign {
|
||||
/// CLASS records (groups after `0 CLASS`) of the kept objects' and entities' types.
|
||||
pub classes: Vec<Vec<RawTag>>,
|
||||
/// Classes retain every source position, including duplicate names.
|
||||
pub complete_class_table: bool,
|
||||
/// Kept objects, in file order.
|
||||
pub objects: Vec<ForeignObject>,
|
||||
/// Entries of the named object dictionary that CADCraft doesn't write itself: (key, object).
|
||||
@@ -77,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()
|
||||
@@ -268,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()
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -486,24 +486,16 @@ impl DimStyle {
|
||||
|
||||
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
|
||||
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
|
||||
/// had unusable values. When several spellings name one field, the last one wins and the
|
||||
/// superseded ones are not validated.
|
||||
/// had unusable values.
|
||||
pub fn apply_fields(&mut self, src: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
|
||||
use serde_json::Value;
|
||||
let mut rejected = Vec::new();
|
||||
let Ok(mut cur) = serde_json::to_value(&*self) else { return src.keys().cloned().collect() };
|
||||
// Spellings of one field (`DIMPOST`, `dimpost`, `textPrefix`…): only the last is applied, so
|
||||
// the style is checked once per field, not once per spelling (each check copies its strings).
|
||||
let last_spelling: std::collections::HashMap<&str, &String> =
|
||||
src.keys().filter_map(|k| DimStyle::field_name(k).filter(|f| *f != "name").map(|f| (f, k))).collect();
|
||||
for (k, v) in src {
|
||||
let Some(field) = DimStyle::field_name(k).filter(|f| *f != "name") else {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
};
|
||||
if last_spelling.get(field) != Some(&k) {
|
||||
continue;
|
||||
}
|
||||
let Some(obj) = cur.as_object_mut() else { break };
|
||||
let Some(old) = obj.get(field).cloned() else {
|
||||
rejected.push(k.clone());
|
||||
@@ -807,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")]
|
||||
@@ -839,7 +824,6 @@ impl Default for PageSetup {
|
||||
show_plot_styles: false,
|
||||
plot_transparency: false,
|
||||
paper_mm: false,
|
||||
file_paper: None,
|
||||
image_origin: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,14 +714,3 @@ fn space_names_its_tab() {
|
||||
assert_eq!(p.layout(), Some("Sheet A"));
|
||||
assert_eq!(p.tab_name(), "Sheet A");
|
||||
}
|
||||
|
||||
/// Several spellings of one DIM field apply once, the last in the map's order, and none is rejected.
|
||||
#[test]
|
||||
fn dim_style_spellings_of_one_field_apply_once() {
|
||||
let mut st = DimStyle::default();
|
||||
let m = serde_json::json!({ "textHeight": 2.5, "TEXTHEIGHT": 3.5, "DIMTXT": 4.5 });
|
||||
let m = m.as_object().unwrap();
|
||||
let want = m.values().next_back().and_then(serde_json::Value::as_f64).unwrap();
|
||||
assert!(st.apply_fields(m).is_empty());
|
||||
assert_eq!(st.text_height, want);
|
||||
}
|
||||
|
||||
+9
-721
@@ -65,9 +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};
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod proxy_transport;
|
||||
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 {
|
||||
@@ -109,13 +107,6 @@ mod native {
|
||||
}
|
||||
let mut notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
notes.extend(
|
||||
guarded(AssertUnwindSafe(|| application_objects(&mut doc))).map_err(|_| "the DWG application object conversion failed".to_string())?,
|
||||
);
|
||||
// After application_objects, so the proxy objects it makes are numbered as the DXF's.
|
||||
notes.extend(
|
||||
guarded(AssertUnwindSafe(|| super::proxy_transport::prepare(&mut doc))).map_err(|_| "the DWG proxy conversion failed".to_string())?,
|
||||
);
|
||||
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
|
||||
if orphans > 0 {
|
||||
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
|
||||
@@ -399,214 +390,6 @@ mod native {
|
||||
std::panic::catch_unwind(f).map_err(|_| ())
|
||||
}
|
||||
|
||||
/// The version a proxy record of a DWG of `version` names its format by (Open Design
|
||||
/// Specification for .dwg, proxy objects): 19 for R13, 21 for R14, 23 for 2000, 25 for 2004,
|
||||
/// 27 for 2007, 29 for 2010, 31 for 2013 and 33 for 2018; `None` for any other version.
|
||||
pub(super) fn proxy_dwg_version(version: acadrust::DxfVersion) -> Option<i32> {
|
||||
Some(match version.version_code() {
|
||||
1012 => 19,
|
||||
1014 => 21,
|
||||
1015 => 23,
|
||||
1018 => 25,
|
||||
1021 => 27,
|
||||
1024 => 29,
|
||||
1027 => 31,
|
||||
1032 => 33,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Objects of another application's class that acadrust keeps only as their DWG record
|
||||
/// (`ObjectType::Unknown`, which has no DXF form): its DXF writer left them out, and the
|
||||
/// references to them went with them (a dimension's extension dictionary entry naming its
|
||||
/// application data, say), with no note. Each is carried as a proxy object of its class instead
|
||||
/// (DXF Reference, ACAD_PROXY_OBJECT), so the DXF keeps it and a DWG written from that DXF has it
|
||||
/// again: its common object data (owner, reactors, extension dictionary) and the streams after
|
||||
/// it (data bits, R2007+ strings, object references), as the Open Design Specification for .dwg
|
||||
/// splits an object record. Objects of the drawing database's own classes are not turned into
|
||||
/// another application's proxies. What is still without a DXF form is named in the notes, so a
|
||||
/// save over the original is not mistaken for a complete one.
|
||||
fn application_objects(doc: &mut acadrust::CadDocument) -> Vec<String> {
|
||||
use acadrust::objects::{ObjectType, ProxyObject};
|
||||
let classes: Vec<acadrust::classes::DxfClass> = doc.classes.iter().cloned().collect();
|
||||
let source = doc.dwg_source_version.unwrap_or(doc.version);
|
||||
if let Some(dwg_version) = proxy_dwg_version(source) {
|
||||
let maintenance = i32::from(doc.maintenance_version);
|
||||
for object in doc.objects.values_mut() {
|
||||
let ObjectType::Unknown { handle, raw_dwg_data: Some(data), raw_dwg_handle_bits, raw_dwg_version: Some(version), type_name, .. } =
|
||||
&*object
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !application_class(&classes, type_name) {
|
||||
continue;
|
||||
}
|
||||
let raw = acadrust::entities::RawRecord { data: data.clone(), handle_bits: *raw_dwg_handle_bits, version: *version };
|
||||
let handle = *handle;
|
||||
// A record that doesn't split stays as it was, and is named below.
|
||||
let Ok(Ok(record)) = std::panic::catch_unwind(AssertUnwindSafe(|| object_record(&raw, handle.value()))) else { continue };
|
||||
let Some(class) = classes.iter().find(|c| c.class_number == record.kind && !c.is_an_entity) else { continue };
|
||||
*object = ObjectType::ProxyObject(ProxyObject {
|
||||
handle,
|
||||
owner: record.owner,
|
||||
reactors: record.reactors,
|
||||
xdictionary_handle: record.xdictionary,
|
||||
proxy_id: 499,
|
||||
class_id: i32::from(class.class_number),
|
||||
dwg_version,
|
||||
maintenance_version: maintenance,
|
||||
version: (maintenance << 16) | dwg_version,
|
||||
payload: record.payload,
|
||||
text_payload: record.text_payload,
|
||||
object_ids: record.object_ids,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut left: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
for object in doc.objects.values() {
|
||||
if let ObjectType::Unknown { type_name, raw_dxf_codes: None, .. } = object {
|
||||
let n = left.entry(type_name.as_str()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if left.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total = left.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
let mut list: Vec<String> = left.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t:.60} × {n}")).collect();
|
||||
if left.len() > NOTE_TYPES {
|
||||
list.push(format!("{} more types", left.len() - NOTE_TYPES));
|
||||
}
|
||||
vec![format!(
|
||||
"DWG: {total} objects of types CADCraft doesn't keep were left out ({}); saving over the original file would lose them",
|
||||
list.join(", ")
|
||||
)]
|
||||
}
|
||||
|
||||
/// Whether `name` is an object class of another application, which only that application
|
||||
/// rebuilds: not one of the drawing database's own classes (`AcDb…`/`ACDB…`/`ACAD_…`), whose
|
||||
/// leftovers the database regenerates itself.
|
||||
fn application_class(classes: &[acadrust::classes::DxfClass], name: &str) -> bool {
|
||||
classes.iter().any(|c| {
|
||||
!c.is_an_entity
|
||||
&& c.dxf_name.eq_ignore_ascii_case(name)
|
||||
&& !c.cpp_class_name.starts_with("AcDb")
|
||||
&& !["ACDB", "ACAD_", "ACAD"].iter().any(|p| c.dxf_name.to_ascii_uppercase().starts_with(p))
|
||||
})
|
||||
}
|
||||
|
||||
/// An application object acadrust kept only as its record: the parts a proxy object carries.
|
||||
struct ObjectRecord {
|
||||
kind: i16,
|
||||
owner: acadrust::types::Handle,
|
||||
reactors: Vec<acadrust::types::Handle>,
|
||||
xdictionary: Option<acadrust::types::Handle>,
|
||||
payload: acadrust::objects::ProxyPayload,
|
||||
text_payload: acadrust::objects::ProxyPayload,
|
||||
object_ids: Vec<acadrust::objects::ProxyObjectReference>,
|
||||
}
|
||||
|
||||
/// Split an object record into its common object data and the uninterpreted streams after it
|
||||
/// (data bits, R2007+ strings and object references), as an application object's proxy holds
|
||||
/// them (Open Design Specification for .dwg, common object data and proxy objects). The record
|
||||
/// is hostile: sizes are checked against it, and acadrust's checked stream reader does the rest.
|
||||
fn object_record(raw: &acadrust::entities::RawRecord, handle: u64) -> Result<ObjectRecord, String> {
|
||||
use acadrust::io::dwg::{
|
||||
DwgVersion, dwg_reference_type::DwgReferenceType, dwg_stream_readers::object_reader::DwgObjectReader,
|
||||
dwg_stream_writers::bit_writer::DwgBitWriter,
|
||||
};
|
||||
use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind};
|
||||
use acadrust::types::{DxfVersion, Handle};
|
||||
if raw.data.len() > 32 << 20 {
|
||||
return Err("object record exceeds the transport limit".into());
|
||||
}
|
||||
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "object has a negative handle stream size")?;
|
||||
let total_bits = raw.data.len().checked_mul(8).ok_or("object record is too large")?;
|
||||
if handle_bits > total_bits {
|
||||
return Err("object handle stream exceeds its record".into());
|
||||
}
|
||||
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| e.to_string())?;
|
||||
let mut frame = DwgBitWriter::new(version, raw.version);
|
||||
frame.write_modular_short(raw.data.len());
|
||||
if raw.version >= DxfVersion::AC1024 {
|
||||
frame.write_modular_char(handle_bits);
|
||||
}
|
||||
frame.write_bytes(&raw.data);
|
||||
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, std::collections::HashMap::new()).map_err(|e| e.to_string())?;
|
||||
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| e.to_string())?;
|
||||
if kind < 500 {
|
||||
return Err("object has no application class".into());
|
||||
}
|
||||
let common = reader.read_common_non_entity_data(&mut streams, kind);
|
||||
streams.check_error().map_err(|e| e.to_string())?;
|
||||
if common.common.handle != handle {
|
||||
return Err("object record names another handle".into());
|
||||
}
|
||||
let read_bits = |count: usize, bit: &mut dyn FnMut() -> bool| -> Result<ProxyPayload, String> {
|
||||
if count > total_bits {
|
||||
return Err("object stream exceeds its record".into());
|
||||
}
|
||||
let n = u32::try_from(count).map_err(|_| "object stream is too large")?;
|
||||
let mut bytes = vec![0u8; count.div_ceil(8)];
|
||||
for index in 0..count {
|
||||
if bit()
|
||||
&& let Some(b) = bytes.get_mut(index / 8)
|
||||
{
|
||||
*b |= 0x80 >> (index % 8);
|
||||
}
|
||||
}
|
||||
Ok(ProxyPayload::from_bits(&bytes, n))
|
||||
};
|
||||
let main = usize::try_from(streams.main_remaining_bits()).map_err(|_| "object body has an invalid size")?;
|
||||
let payload = read_bits(main, &mut || streams.read_bit())?;
|
||||
let text_payload = if raw.version >= DxfVersion::AC1021 {
|
||||
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "object has a negative string stream size")?;
|
||||
read_bits(bits, &mut || streams.read_text_bit())?
|
||||
} else {
|
||||
ProxyPayload::default()
|
||||
};
|
||||
// Every reference to the end of the handle stream, null ones too: an object keeps a null pointer in its
|
||||
// field's place, and the references after it are the next fields' (as acadrust reads a proxy's own). R2010+
|
||||
// records give the stream's exact length; the bits of its last byte past it are padding.
|
||||
let mut object_ids = Vec::new();
|
||||
let mut left = if raw.version >= DxfVersion::AC1024 {
|
||||
streams.get_handle_bits().max(0).min(streams.handle_remaining_bits())
|
||||
} else {
|
||||
streams.handle_remaining_bits()
|
||||
};
|
||||
while left >= 8 {
|
||||
if object_ids.len() >= 100_000 {
|
||||
return Err("object has too many references".into());
|
||||
}
|
||||
let before = streams.handle_remaining_bits();
|
||||
let (target, reference) = streams.read_handle_reference(handle);
|
||||
let used = before.saturating_sub(streams.handle_remaining_bits());
|
||||
if used <= 0 || used > left {
|
||||
break;
|
||||
}
|
||||
left -= used;
|
||||
let kind = match reference {
|
||||
DwgReferenceType::SoftOwnership => ProxyReferenceKind::SoftOwnership,
|
||||
DwgReferenceType::HardOwnership => ProxyReferenceKind::HardOwnership,
|
||||
DwgReferenceType::SoftPointer => ProxyReferenceKind::SoftPointer,
|
||||
DwgReferenceType::HardPointer => ProxyReferenceKind::HardPointer,
|
||||
DwgReferenceType::Undefined => ProxyReferenceKind::Undefined,
|
||||
};
|
||||
object_ids.push(ProxyObjectReference { handle: Handle::from(target), kind });
|
||||
}
|
||||
streams.check_error().map_err(|e| e.to_string())?;
|
||||
Ok(ObjectRecord {
|
||||
kind,
|
||||
owner: Handle::from(common.owner_handle),
|
||||
reactors: common.reactors.into_iter().map(Handle::from).collect(),
|
||||
xdictionary: common.xdictionary_handle.map(Handle::from),
|
||||
payload,
|
||||
text_payload,
|
||||
object_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
|
||||
/// left unmerged. Returns the cells visited.
|
||||
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
|
||||
@@ -869,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"),
|
||||
@@ -882,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,
|
||||
@@ -915,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`.
|
||||
pub(super) 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
|
||||
@@ -1017,150 +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 pairs = super::proxy_transport::text_pairs(dxf)?;
|
||||
let mut names = Vec::new();
|
||||
let (mut section, mut in_classes, mut in_class) = (false, false, false);
|
||||
for pair in pairs {
|
||||
let (code, value) = pair?;
|
||||
match (code, value.as_str()) {
|
||||
(0, "ENDSEC") if in_classes => break,
|
||||
(0, "SECTION") => section = true,
|
||||
(2, name) if section => {
|
||||
in_classes = name == "CLASSES";
|
||||
section = false;
|
||||
}
|
||||
(0, kind) if in_classes => {
|
||||
in_class = kind == "CLASS";
|
||||
// Even an unnamed record keeps its place in the source class table.
|
||||
if in_class {
|
||||
names.push(String::new());
|
||||
}
|
||||
}
|
||||
(1, value) if in_class => {
|
||||
if let Some(last) = names.last_mut() {
|
||||
*last = value.to_string();
|
||||
}
|
||||
in_class = false;
|
||||
}
|
||||
_ => section = 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());
|
||||
}
|
||||
};
|
||||
// Name matching cannot choose between different source classes with the same name.
|
||||
// Count those proxies as unwritten rather than silently changing their class identity.
|
||||
let mut unique = std::collections::HashMap::new();
|
||||
for name in classes {
|
||||
unique.entry(name.to_uppercase()).and_modify(|value| *value = false).or_insert(true);
|
||||
}
|
||||
let number = |id: i32| -> Option<i32> {
|
||||
let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?;
|
||||
if unique.get(&name.to_uppercase()) != Some(&true) {
|
||||
return None;
|
||||
}
|
||||
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).
|
||||
@@ -1172,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
|
||||
@@ -1185,20 +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, source) = guarded(AssertUnwindSafe(|| (dxf_class_names(&data), super::proxy_transport::source_classes(&data))))
|
||||
.map_err(|_| "the DXF proxy class conversion failed".to_string())?;
|
||||
let data = dxf.to_vec();
|
||||
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
|
||||
.map_err(|e| format!("DXF: {e}"))?;
|
||||
@@ -1213,25 +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");
|
||||
// From 2013 the DWG takes the source classes as they are, duplicates and metadata
|
||||
// included; otherwise (before 2013, a binary DXF, classes that can't be written back)
|
||||
// proxies are matched to classes by name or left out.
|
||||
let restored = match source {
|
||||
Some(source) if doc.version >= acadrust::DxfVersion::AC1027 => super::proxy_transport::restore_classes(&mut doc, source),
|
||||
_ => None,
|
||||
};
|
||||
let proxies = match restored {
|
||||
Some((objects, entities)) => left_out(objects, entities),
|
||||
None => 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);
|
||||
@@ -1396,132 +917,10 @@ mod native {
|
||||
assert_eq!(guarded(|| 3), Ok(3));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod application_object_tests {
|
||||
use super::*;
|
||||
use acadrust::CadDocument;
|
||||
use acadrust::classes::DxfClass;
|
||||
use acadrust::objects::{ObjectType, ProxyObjectReference, ProxyPayload, ProxyReferenceKind, RegisteredClassObject};
|
||||
use acadrust::types::{DxfVersion, Handle};
|
||||
|
||||
/// An object of another application's class that acadrust keeps only as its record (a
|
||||
/// vertical product's dimension data, say) is carried as that class's proxy object, with its
|
||||
/// data bits, references, owner and extension dictionary, through DXF and back to DWG, twice.
|
||||
#[test]
|
||||
fn undecoded_application_object_is_kept_as_its_proxy() {
|
||||
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut source = CadDocument::new();
|
||||
source.version = version;
|
||||
source.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
|
||||
let handle = Handle::from(0x2Bu64);
|
||||
let target = Handle::from(0x2Cu64);
|
||||
let payload = ProxyPayload::from_bits(&[0x5A, 0x3C, 0xC0], 18);
|
||||
// A null pointer between two references: it keeps its place, the one after it is not lost.
|
||||
let references = vec![
|
||||
ProxyObjectReference { handle: target, kind: ProxyReferenceKind::HardPointer },
|
||||
ProxyObjectReference { handle: Handle::from(0u64), kind: ProxyReferenceKind::SoftPointer },
|
||||
ProxyObjectReference { handle: Handle::from(0x0Cu64), kind: ProxyReferenceKind::HardPointer },
|
||||
];
|
||||
source.objects.insert(
|
||||
handle,
|
||||
ObjectType::RegisteredClass(RegisteredClassObject {
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
dxf_name: "TESTCONTAINER".into(),
|
||||
cpp_class_name: "xyzDbTestContainer".into(),
|
||||
payload: payload.clone(),
|
||||
object_ids: references.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let mut dwg = acadrust::DwgWriter::write_to_vec(&source).unwrap();
|
||||
// acadrust has no model of it: read back, it is only its record.
|
||||
let decoded = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
|
||||
assert!(matches!(decoded.objects.get(&handle), Some(ObjectType::Unknown { .. })), "{:?}", decoded.objects.get(&handle));
|
||||
for generation in 0..2 {
|
||||
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
|
||||
assert!(notes.is_empty(), "{version:?} {generation}: {notes:?}");
|
||||
// The DXF has the object, by its handle, as a proxy record.
|
||||
let text = String::from_utf8_lossy(&dxf);
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let record = lines.windows(4).any(|w| w == ["0", "ACAD_PROXY_OBJECT", "5", "2B"]);
|
||||
assert!(record, "{version:?} {generation}: no proxy record for the object");
|
||||
dwg = dxf_to_dwg_as(&dxf, "AC1032").unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
|
||||
let Some(ObjectType::ProxyObject(proxy)) = doc.objects.get(&handle) else { panic!("{version:?} {generation}: proxy object") };
|
||||
assert_eq!(proxy.payload.bit_count, payload.bit_count);
|
||||
assert_eq!(proxy.payload.data(), payload.data());
|
||||
assert_eq!(proxy.object_ids, references);
|
||||
let class = doc.classes.iter().find(|c| i32::from(c.class_number) == proxy.class_id).unwrap();
|
||||
assert_eq!(class.dxf_name, "TESTCONTAINER");
|
||||
assert!(!class.is_an_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The database's own classes are not turned into another application's proxies; what is
|
||||
/// left without a DXF form is said, not dropped silently.
|
||||
#[test]
|
||||
fn database_objects_without_a_form_are_reported() {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.add_or_update(DxfClass::new("BLOCKPROPERTIESTABLE", "AcDbBlockPropertiesTable"));
|
||||
let handle = Handle::from(0x40u64);
|
||||
doc.objects.insert(
|
||||
handle,
|
||||
ObjectType::Unknown {
|
||||
type_name: "BLOCKPROPERTIESTABLE".into(),
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
raw_dxf_codes: None,
|
||||
raw_dwg_data: Some(vec![0; 8]),
|
||||
raw_dwg_handle_bits: 0,
|
||||
raw_dwg_version: Some(DxfVersion::AC1032),
|
||||
},
|
||||
);
|
||||
let notes = application_objects(&mut doc);
|
||||
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
"DWG: 1 objects of types CADCraft doesn't keep were left out (BLOCKPROPERTIESTABLE × 1); saving over the original file would lose them"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A record that doesn't split stays as it was and is named, never guessed at.
|
||||
#[test]
|
||||
fn invalid_object_records_are_rejected() {
|
||||
for handle_bits in [-1, 1000] {
|
||||
let raw = acadrust::entities::RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
|
||||
assert!(object_record(&raw, 42).is_err());
|
||||
}
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
|
||||
let handle = Handle::from(0x41u64);
|
||||
doc.objects.insert(
|
||||
handle,
|
||||
ObjectType::Unknown {
|
||||
type_name: "TESTCONTAINER".into(),
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
raw_dxf_codes: None,
|
||||
raw_dwg_data: Some(vec![0xFF; 4]),
|
||||
raw_dwg_handle_bits: 1000,
|
||||
raw_dwg_version: Some(DxfVersion::AC1032),
|
||||
},
|
||||
);
|
||||
let notes = application_objects(&mut doc);
|
||||
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
|
||||
assert!(notes.iter().any(|n| n.contains("(TESTCONTAINER × 1)")), "{notes:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
@@ -1576,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> {
|
||||
@@ -1608,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 {
|
||||
@@ -1901,109 +1285,13 @@ 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
|
||||
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
|
||||
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
|
||||
/// from DXF is written whole, with the byte AutoCAD writes (0).
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
|
||||
use acadrust::objects::*;
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
//! Another application's entities and their classes through the DWG bridge: on reading a DWG,
|
||||
//! registered entities acadrust has no model for become proxy entities of their class; on writing
|
||||
//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use acadrust::CadDocument;
|
||||
use acadrust::classes::{DxfClass, ProxyFlags};
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData};
|
||||
use acadrust::objects::{ObjectType, ProxyPayload};
|
||||
|
||||
fn class_number(index: usize) -> Result<i16, String> {
|
||||
index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into())
|
||||
}
|
||||
|
||||
/// After a DWG read: each registered entity of another application's class that acadrust keeps
|
||||
/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class
|
||||
/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object
|
||||
/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's
|
||||
/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is
|
||||
/// missing or not unique) is left out and named in the notes; one with decoded properties stays as
|
||||
/// read, also named, as its opaque data has no DXF form beside them.
|
||||
pub(super) fn prepare(doc: &mut CadDocument) -> Vec<String> {
|
||||
let classes: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
let mut numbers = HashMap::new();
|
||||
let mut identities = HashMap::new();
|
||||
for (index, class) in classes.iter().enumerate() {
|
||||
let number = class_number(index).ok();
|
||||
numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from));
|
||||
let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity);
|
||||
identities.entry(identity).and_modify(|v| *v = None).or_insert(number);
|
||||
}
|
||||
let identity = |dxf: &str, cpp: &str, entity: bool| {
|
||||
identities
|
||||
.get(&(dxf, cpp, entity))
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(i32::from)
|
||||
.ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})"))
|
||||
};
|
||||
let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() };
|
||||
let maintenance = i32::from(doc.maintenance_version);
|
||||
let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}"));
|
||||
// Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity.
|
||||
let handles: Vec<_> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(_) => Some(e.common.handle),
|
||||
ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut notes = Vec::new();
|
||||
let mut entity_count = 0usize;
|
||||
let mut object_count = 0usize;
|
||||
let mut semantic = 0usize;
|
||||
for handle in handles {
|
||||
// Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties.
|
||||
let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<Option<Option<ExtendedEntityData>>, String> {
|
||||
let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) };
|
||||
let data = match &entity.data {
|
||||
ExtendedEntityData::Proxy(data) => {
|
||||
let mut proxy = data.clone();
|
||||
proxy.class_id = remap(proxy.class_id)?;
|
||||
ExtendedEntityData::Proxy(proxy)
|
||||
}
|
||||
ExtendedEntityData::RegisteredClass(data) => {
|
||||
if !data.properties.is_empty() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?;
|
||||
let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default();
|
||||
ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: String::new(),
|
||||
version: (maintenance << 16) | dwg_version,
|
||||
dwg_version,
|
||||
maintenance_version: maintenance,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()),
|
||||
payload: data.payload.clone(),
|
||||
text_payload,
|
||||
object_ids: data.object_ids.clone(),
|
||||
})
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(Some(data)))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("application record conversion failed".into()));
|
||||
match converted {
|
||||
Ok(Some(Some(data))) => {
|
||||
if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) {
|
||||
entity.data = data;
|
||||
}
|
||||
}
|
||||
Ok(Some(None)) => semantic = semantic.saturating_add(1),
|
||||
Ok(None) => {}
|
||||
Err(why) => {
|
||||
doc.remove_entity(handle);
|
||||
entity_count = entity_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "entity", handle, &why);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.objects.retain(|handle, object| {
|
||||
if let ObjectType::ProxyObject(proxy) = object {
|
||||
match remap(proxy.class_id) {
|
||||
Ok(id) => proxy.class_id = id,
|
||||
Err(why) => {
|
||||
object_count = object_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "object", *handle, &why);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if entity_count > 0 || object_count > 0 {
|
||||
notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})"));
|
||||
}
|
||||
if semantic > 0 {
|
||||
notes.push(format!(
|
||||
"DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it"
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
fn skipped_note(notes: &mut Vec<String>, kind: &str, handle: acadrust::types::Handle, why: &str) {
|
||||
if notes.len() < 6 {
|
||||
let why: String = why.chars().take(256).collect();
|
||||
notes.push(format!("DWG: proxy {kind} {handle} left out: {why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoder's registered-class representation omits the separate R2007+ string stream.
|
||||
fn registered_text(raw: &acadrust::entities::RawRecord) -> Result<ProxyPayload, String> {
|
||||
use acadrust::io::dwg::DwgVersion;
|
||||
use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader;
|
||||
use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter;
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
if raw.version < DxfVersion::AC1021 {
|
||||
return Ok(ProxyPayload::default());
|
||||
}
|
||||
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?;
|
||||
let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?;
|
||||
if handle_bits > total_bits {
|
||||
return Err("DWG proxy text: handle stream exceeds record".into());
|
||||
}
|
||||
// Frame the verbatim record for the dependency's checked object reader.
|
||||
let mut frame = DwgBitWriter::new(version, raw.version);
|
||||
frame.write_modular_short(raw.data.len());
|
||||
if raw.version >= DxfVersion::AC1024 {
|
||||
frame.write_modular_char(handle_bits);
|
||||
}
|
||||
frame.write_bytes(&raw.data);
|
||||
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
reader.read_common_entity_data(&mut streams, kind);
|
||||
if kind == 498 {
|
||||
// An existing registered-class envelope is a proxy and carries its class name first.
|
||||
streams.read_variable_text();
|
||||
} else if kind < 500 {
|
||||
return Err("DWG proxy text: registered entity has no application class".into());
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?;
|
||||
if bits > total_bits {
|
||||
return Err("DWG proxy text: string stream exceeds record".into());
|
||||
}
|
||||
let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?;
|
||||
let mut bytes = vec![0; bits.div_ceil(8)];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
for bit in 0..8.min(bits.saturating_sub(index * 8)) {
|
||||
if streams.read_text_bit() {
|
||||
*byte |= 0x80 >> bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
Ok(ProxyPayload::from_bits(&bytes, count))
|
||||
}
|
||||
|
||||
/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed
|
||||
/// pair yields `None`, so both class scans fall back rather than guessing its identity.
|
||||
pub(super) fn text_pairs(dxf: &[u8]) -> Option<impl Iterator<Item = Option<(i32, String)>> + '_> {
|
||||
use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page};
|
||||
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let version =
|
||||
super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string);
|
||||
let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021);
|
||||
let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) {
|
||||
super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line));
|
||||
Some(std::iter::from_fn(move || {
|
||||
let code = lines.next()?;
|
||||
let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) };
|
||||
let pair = || {
|
||||
let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?;
|
||||
let text = if let Some(encoding) = encoding {
|
||||
encoding.decode(value).0.into_owned()
|
||||
} else if unicode {
|
||||
String::from_utf8_lossy(value).into_owned()
|
||||
} else {
|
||||
// The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared.
|
||||
std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string)
|
||||
};
|
||||
let text = decode_legacy_escapes(&text);
|
||||
// DXF's escaped control characters are decoded after CIF/MIF, as in the bridge.
|
||||
let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^");
|
||||
Some((code, text))
|
||||
};
|
||||
Some(pair())
|
||||
}))
|
||||
}
|
||||
|
||||
/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF
|
||||
/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (CADCraft's own
|
||||
/// bridge DXF is ASCII), and for one with a record that can't be written back as it is: no names,
|
||||
/// flags or instance count out of range, more classes than the numbers allow. Then
|
||||
/// `proxy_classes` matches the classes by name instead.
|
||||
pub(super) fn source_classes(dxf: &[u8]) -> Option<Vec<DxfClass>> {
|
||||
let pairs = text_pairs(dxf)?;
|
||||
let mut records: Vec<Vec<(i32, String)>> = Vec::new();
|
||||
let (mut section, mut in_classes) = (false, false);
|
||||
for pair in pairs {
|
||||
let (code, value) = pair?;
|
||||
match (code, value.as_str()) {
|
||||
(0, "ENDSEC") if in_classes => break,
|
||||
(0, "SECTION") => section = true,
|
||||
(2, name) if section => {
|
||||
in_classes = name == "CLASSES";
|
||||
section = false;
|
||||
}
|
||||
(0, kind) if in_classes => {
|
||||
if kind != "CLASS" {
|
||||
return None;
|
||||
}
|
||||
records.push(Vec::new());
|
||||
}
|
||||
(code, value) if in_classes => records.last_mut()?.push((code, value.to_string())),
|
||||
_ => section = false,
|
||||
}
|
||||
}
|
||||
records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect()
|
||||
}
|
||||
|
||||
/// One CLASS record's groups as the class `number`, or `None` when it can't be written back.
|
||||
fn source_class(groups: &[(i32, String)], number: i16) -> Option<DxfClass> {
|
||||
let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::<i64>().ok());
|
||||
let mut class = DxfClass::new(text(1), text(2));
|
||||
if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
class.application_name = text(3);
|
||||
class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?);
|
||||
class.instance_count = i32::try_from(integer(91)?).ok()?;
|
||||
class.was_zombie = integer(280)? != 0;
|
||||
class.is_an_entity = integer(281)? != 0;
|
||||
class.item_class_id = if class.is_an_entity { 498 } else { 499 };
|
||||
class.class_number = number;
|
||||
Some(class)
|
||||
}
|
||||
|
||||
/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in
|
||||
/// their places, with their metadata and duplicate names, so each proxy's DXF class number names
|
||||
/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number
|
||||
/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and
|
||||
/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing
|
||||
/// changed, when the classes would be more than the class numbers allow.
|
||||
pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec<DxfClass>) -> Option<(usize, usize)> {
|
||||
let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len());
|
||||
let mut any = false;
|
||||
let mut entities = Vec::new();
|
||||
for entity in doc.entities() {
|
||||
if let EntityType::Extended(e) = entity
|
||||
&& let ExtendedEntityData::Proxy(p) = &e.data
|
||||
{
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
entities.push(e.common.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut objects = Vec::new();
|
||||
for (handle, object) in &doc.objects {
|
||||
if let ObjectType::ProxyObject(p) = object {
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
objects.push(*handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Some((0, 0));
|
||||
}
|
||||
let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect();
|
||||
let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect();
|
||||
if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) {
|
||||
class_number(last).ok()?;
|
||||
}
|
||||
for handle in &entities {
|
||||
doc.remove_entity(*handle);
|
||||
}
|
||||
for handle in &objects {
|
||||
doc.objects.remove(handle);
|
||||
}
|
||||
doc.classes.clear();
|
||||
for (index, mut class) in classes.into_iter().chain(defaults).enumerate() {
|
||||
class.class_number = class_number(index).ok()?;
|
||||
doc.classes.push_preserving(class);
|
||||
}
|
||||
Some((objects.len(), entities.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
fn proxy_doc() -> CadDocument {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.version = DxfVersion::AC1032;
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id: 501,
|
||||
dxf_subclass: String::new(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
text_payload: ProxyPayload::default(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// An ASCII DXF of `groups`, `(code, value)` per pair of lines.
|
||||
fn dxf(groups: &[(i32, &str)]) -> Vec<u8> {
|
||||
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_keep_duplicates_and_metadata_before_defaults() {
|
||||
let bytes = dxf(&[
|
||||
(0, "SECTION"),
|
||||
(2, "HEADER"),
|
||||
(9, "$DIMSTYLE"),
|
||||
(2, "CLASSES"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "CLASSES"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "First"),
|
||||
(3, "Первый"),
|
||||
(90, "1025"),
|
||||
(91, "7"),
|
||||
(280, "1"),
|
||||
(281, "1"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "Second"),
|
||||
(3, "Второй"),
|
||||
(90, "128"),
|
||||
(281, "1"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
]);
|
||||
let classes = source_classes(&bytes).unwrap();
|
||||
assert_eq!(classes.len(), 2);
|
||||
assert_eq!(classes[0].application_name, "Первый");
|
||||
assert_eq!(classes[0].proxy_flags.0, 1025);
|
||||
assert_eq!(classes[0].instance_count, 7);
|
||||
assert!(classes[0].was_zombie);
|
||||
assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501));
|
||||
assert_eq!(classes[1].cpp_class_name, "Second");
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().take(2).cloned().collect::<Vec<_>>(), classes);
|
||||
// A binary DXF, and a CLASS record that can't be written back as it is, give no classes.
|
||||
assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none());
|
||||
for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] {
|
||||
let mut class = vec![(1, "A"), (2, "A")];
|
||||
class.retain(|(c, _)| *c != code);
|
||||
class.push((code, value));
|
||||
let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")];
|
||||
groups.extend(class);
|
||||
groups.extend([(0, "ENDSEC"), (0, "EOF")]);
|
||||
assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() {
|
||||
let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
let text = format!("0\nSECTION\n2\nCLASSES\n{body}");
|
||||
let classes = source_classes(text.as_bytes()).unwrap();
|
||||
assert_eq!(classes[0].dxf_name, "Объект");
|
||||
assert_eq!(classes[0].application_name, "Café🙂\\U+0041");
|
||||
|
||||
let prefix =
|
||||
b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n";
|
||||
let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat();
|
||||
assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример");
|
||||
// R2007+ text is UTF-8 even with a legacy code-page declaration.
|
||||
let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_of_missing_classes_are_left_out() {
|
||||
for id in [-1, 499, 501, i32::MAX] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = id;
|
||||
}
|
||||
let mut class = DxfClass::new_entity("ENTITY", "Entity");
|
||||
class.class_number = 500;
|
||||
assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}");
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
}
|
||||
// Class 501 is there: the proxy keeps it, in its place.
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0)));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501)));
|
||||
// Without proxies the classes stay the decoder's.
|
||||
let mut doc = CadDocument::new();
|
||||
let before: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().cloned().collect::<Vec<_>>(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() {
|
||||
use acadrust::entities::{Line, RawRecord};
|
||||
use acadrust::objects::ProxyObject;
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
for count in [1, 10] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = i32::MAX;
|
||||
}
|
||||
let template = doc.entities().next().unwrap().clone();
|
||||
for _ in 1..count {
|
||||
let mut entity = template.clone();
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
doc.add_entity(entity).unwrap();
|
||||
}
|
||||
let object = Handle::new(u64::MAX - 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() }));
|
||||
let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap();
|
||||
let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
let notes = prepare(&mut doc);
|
||||
assert_eq!(notes.len(), (count + 1).min(6) + 1);
|
||||
assert!(notes.iter().all(|n| n.starts_with("DWG:")));
|
||||
assert!(notes[0].contains(&format!("entities: {count}, objects: 1")));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") };
|
||||
assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0));
|
||||
assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0));
|
||||
assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn opaque_registered_class_ambiguity_is_not_guessed() {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM".into(),
|
||||
cpp_class_name: "Custom".into(),
|
||||
properties: vec![],
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous")));
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
assert!(class_number(usize::MAX).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() {
|
||||
use acadrust::entities::RawRecord;
|
||||
use acadrust::objects::{SemanticProperty, SemanticPropertyValue};
|
||||
for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] {
|
||||
let mut doc = CadDocument::new();
|
||||
let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
let handle = doc
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "SEMANTIC".into(),
|
||||
cpp_class_name: "Semantic".into(),
|
||||
properties: vec![SemanticProperty {
|
||||
subclass: "Semantic".into(),
|
||||
code: 1,
|
||||
value: SemanticPropertyValue::Text("value".into()),
|
||||
}],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
if payload.bit_count == 0 {
|
||||
assert!(prepare(&mut doc).is_empty());
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
} else {
|
||||
// Kept as read, and named: its opaque data has no DXF form beside the properties.
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties")));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod string_stream_tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn registered_transport_recovers_separate_non_byte_aligned_strings() {
|
||||
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut source = CadDocument::new();
|
||||
source.version = version;
|
||||
source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText"));
|
||||
let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number);
|
||||
let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19);
|
||||
let payload = ProxyPayload::from_bits(&[0x80], 1);
|
||||
source
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: "CustomText".into(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: payload.clone(),
|
||||
text_payload: text.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap();
|
||||
let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let entity = decoded.entities().next().unwrap();
|
||||
let handle = entity.common().handle;
|
||||
let raw = entity.common().raw_record.clone().unwrap();
|
||||
assert_eq!(registered_text(&raw).unwrap().data(), text.data());
|
||||
assert_eq!(registered_text(&raw).unwrap().bit_count, 19);
|
||||
let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() };
|
||||
entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM_TEXT".into(),
|
||||
cpp_class_name: "CustomText".into(),
|
||||
properties: vec![],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
});
|
||||
entity.common.raw_record = Some(raw);
|
||||
assert!(prepare(&mut decoded).is_empty());
|
||||
for _ in 0..2 {
|
||||
let mut dxf = Vec::new();
|
||||
acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap();
|
||||
let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap();
|
||||
decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let p = decoded
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(p) => Some(p),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(p.payload.bit_count, 1);
|
||||
assert_eq!(p.payload.data(), payload.data());
|
||||
assert_eq!(p.text_payload.bit_count, 19);
|
||||
assert_eq!(p.text_payload.data(), text.data());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_string_stream_boundaries_return_errors() {
|
||||
for handle_bits in [-1, 1000] {
|
||||
let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
|
||||
assert!(registered_text(&raw).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¬es))),
|
||||
None => encode_here(dxf, version, kept),
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversion in this process.
|
||||
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
match kept {
|
||||
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
+21
-119
@@ -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();
|
||||
@@ -250,7 +235,7 @@ fn escape_at(chars: &[char], i: usize) -> Option<u32> {
|
||||
|
||||
/// The character of a `\M+nXXXX` escape starting at `chars[i]`, if there is one: `n` names a
|
||||
/// double-byte code page and `XXXX` is the character's two bytes in hex.
|
||||
pub fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'M') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
@@ -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() });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -178,34 +178,14 @@ fn create(s: &mut Session, objs: &[Handle], base: Vec2, p: ArrayParams, assoc: b
|
||||
Ok(json!({ "created": n }))
|
||||
}
|
||||
|
||||
/// The most objects EXPLODE of one associative array makes from its source objects (ten times the
|
||||
/// default MAXARRAY).
|
||||
const MAX_EXPLODED_OBJECTS: usize = 1_000_000;
|
||||
|
||||
/// EXPLODE of an associative array: every item becomes the objects of its block (the source
|
||||
/// objects, or a replacement block's reference), in world coordinates. `None` for other objects.
|
||||
///
|
||||
/// An array that would explode into more than [`MAX_EXPLODED_OBJECTS`] objects (items times the
|
||||
/// objects of the source block, which a file sets freely) is not expanded here: `None`, so EXPLODE
|
||||
/// takes the array block's own items.
|
||||
pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return None };
|
||||
let p = d.array_params(e)?;
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
// Count first: a hostile array must be refused before any entity is cloned.
|
||||
let total = blk
|
||||
.entities
|
||||
.iter()
|
||||
.map(|it| match &it.kind {
|
||||
EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&p.source) => d.block(&i.block).map_or(1, |b| b.entities.len()),
|
||||
_ => 1,
|
||||
})
|
||||
.fold(0usize, usize::saturating_add);
|
||||
if total > MAX_EXPLODED_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(total);
|
||||
let mut out = Vec::new();
|
||||
for item in blk.entities.iter() {
|
||||
let item = placed(e, item, &m);
|
||||
let source = match &item.kind {
|
||||
@@ -213,9 +193,7 @@ pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
_ => None,
|
||||
};
|
||||
match source {
|
||||
Some((im, b)) => {
|
||||
out.extend(b.entities.iter().map(|se| placed(&item, se, &im)));
|
||||
}
|
||||
Some((im, b)) => out.extend(b.entities.iter().map(|se| placed(&item, se, &im))),
|
||||
None => out.push(item),
|
||||
}
|
||||
}
|
||||
@@ -270,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)
|
||||
}
|
||||
|
||||
@@ -188,9 +188,8 @@ fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)
|
||||
let mut best: Option<((u32, u32, u32), f64)> = None;
|
||||
// Each block's own extents, measured once.
|
||||
let mut extents: HashMap<String, (Vec2, Bounds2)> = HashMap::new();
|
||||
let block_for = p.block_lookup();
|
||||
for it in p.items()? {
|
||||
let name = block_for(it.i, it.j, it.level);
|
||||
let name = p.block_for(it.i, it.j, it.level);
|
||||
let (base, local) = match extents.get(name) {
|
||||
Some(x) => *x,
|
||||
None => {
|
||||
|
||||
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! block name and values, or one row per reference with its handle, layer, position and
|
||||
//! rotation — returned, written to a CSV file and/or inserted as a table. Tags match in any case.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Insert};
|
||||
use cadcraft_doc::{EntityKind, Insert};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -88,73 +88,20 @@ fn matches(spec: &str, text: &str) -> bool {
|
||||
spec.split(',').any(|p| wildcard(p.trim(), text))
|
||||
}
|
||||
|
||||
/// The constant attribute values of a block definition whose tags are selected for output.
|
||||
fn constants(b: &cadcraft_doc::Block, selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = Vec::new();
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& selected.iter().any(|tag| tag == &a.tag.to_ascii_uppercase())
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The attribute values of `ins` that `o.tags` selects: its own, then the constant ones of its
|
||||
/// block (`consts`, read once per block). Values no column shows are not copied.
|
||||
fn values(ins: &Insert, consts: &[(String, String)], selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins
|
||||
.attribs
|
||||
.iter()
|
||||
.map(|a| (a.tag.to_ascii_uppercase(), a))
|
||||
.filter(|(t, _)| selected.contains(t))
|
||||
.map(|(t, a)| (t, a.text.value.clone()))
|
||||
.collect();
|
||||
for (t, val) in consts {
|
||||
if !v.iter().any(|(k, _)| k.eq_ignore_ascii_case(t)) {
|
||||
v.push((t.clone(), val.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Select the output columns before copying any attribute text. A block definition can contain
|
||||
/// many large constants that do not survive the table's 190-column limit.
|
||||
fn selected_tags(ents: &[std::sync::Arc<cadcraft_doc::Entity>], d: &Drawing, o: &Options) -> Vec<String> {
|
||||
let mut selected = Vec::new();
|
||||
let mut add = |tag: &str| {
|
||||
let tag = tag.to_ascii_uppercase();
|
||||
if selected.len() < MAX_TAGS && matches(&o.tags, &tag) && !selected.contains(&tag) {
|
||||
selected.push(tag);
|
||||
}
|
||||
};
|
||||
let mut scanned = std::collections::HashSet::new();
|
||||
for entity in ents {
|
||||
let EntityKind::Insert(ins) = &entity.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
if anonymous || !matches(&o.blocks, &ins.block) {
|
||||
continue;
|
||||
}
|
||||
for attribute in &ins.attribs {
|
||||
add(&attribute.tag);
|
||||
}
|
||||
// A block's constants are read once, however many inserts reference it.
|
||||
if scanned.insert(ins.block.as_str())
|
||||
&& let Some(block) = d.block(&ins.block)
|
||||
{
|
||||
for entity in block.entities.iter() {
|
||||
if let EntityKind::AttDef(attribute) = &entity.kind
|
||||
&& attribute.constant
|
||||
{
|
||||
add(&attribute.tag);
|
||||
}
|
||||
/// The attribute values of `ins`: its own, then the constant ones of its block.
|
||||
fn values(d: &cadcraft_doc::Drawing, ins: &Insert) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.to_ascii_uppercase(), a.text.value.clone())).collect();
|
||||
if let Some(b) = d.block(&ins.block) {
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
v
|
||||
}
|
||||
|
||||
/// The block references to extract from: `handles` when given, else model space and the layouts.
|
||||
@@ -165,9 +112,7 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
} else {
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
|
||||
};
|
||||
let selected = selected_tags(&ents, d, o);
|
||||
let mut out = Vec::new();
|
||||
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
|
||||
for e in &ents {
|
||||
let EntityKind::Insert(ins) = &e.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
@@ -176,17 +121,12 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
}
|
||||
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
|
||||
out.push(Found {
|
||||
block,
|
||||
handle: e.handle.hex(),
|
||||
layer: e.common.layer.clone(),
|
||||
at: ins.insert.xy(),
|
||||
rotation: ins.rotation.to_degrees(),
|
||||
block: block.clone(),
|
||||
values: values(
|
||||
ins,
|
||||
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
|
||||
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
|
||||
&selected,
|
||||
),
|
||||
values: values(d, ins),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -85,8 +85,7 @@ fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -453,32 +453,14 @@ impl Chain {
|
||||
}
|
||||
/// Point and unit tangent at arc length `d` from the start (clamped).
|
||||
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
|
||||
self.at_length_in(&self.end_lengths(), d)
|
||||
}
|
||||
/// The arc length from the chain's start to the end of each segment, for [`Self::at_length_in`].
|
||||
pub fn end_lengths(&self) -> Vec<f64> {
|
||||
self.segs
|
||||
.iter()
|
||||
.scan(0.0, |acc, s| {
|
||||
*acc += s.len();
|
||||
Some(*acc)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// [`Self::at_length`] with the segment end lengths computed once (`ends` from
|
||||
/// [`Self::end_lengths`]), so many samples along one chain each cost a binary search.
|
||||
pub fn at_length_in(&self, ends: &[f64], d: f64) -> Option<(Vec2, Vec2)> {
|
||||
let first = ends.partition_point(|e| d > e + 1e-12);
|
||||
// A segment of no length can't hold the point; the next one that has length does.
|
||||
// `ends` never decreases, so the first segment past `first` with length is found by search
|
||||
// (a run of zero-length segments can be long) instead of a scan.
|
||||
let base = first.checked_sub(1).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let i = first + ends.get(first..).unwrap_or_default().partition_point(|e| *e <= base);
|
||||
let i = (i < self.segs.len()).then_some(i);
|
||||
if let Some(s) = i.and_then(|i| self.segs.get(i)) {
|
||||
let start = i.and_then(|i| i.checked_sub(1)).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let t = ((d - start) / s.len()).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
let mut acc = 0.0;
|
||||
for s in &self.segs {
|
||||
let l = s.len();
|
||||
if d <= acc + l + 1e-12 && l > 0.0 {
|
||||
let t = ((d - acc) / l).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
|
||||
}
|
||||
@@ -658,34 +640,6 @@ pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The indexed lookup gives the point and tangent a scan from the start gives, also past the
|
||||
/// ends and over a segment of no length.
|
||||
#[test]
|
||||
fn at_length_in_matches_a_scan() {
|
||||
let l = |a: (f64, f64), b: (f64, f64)| Segment::Line(Line::new(Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)));
|
||||
let c = Chain {
|
||||
segs: vec![l((0.0, 0.0), (0.0, 0.0)), l((0.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 3.0))],
|
||||
closed: false,
|
||||
};
|
||||
let ends = c.end_lengths();
|
||||
for d in [-1.0, 0.0, 1.5, 4.0, 4.0 + 1e-13, 5.0, 7.0, 9.0] {
|
||||
// The scan this replaced.
|
||||
let mut acc = 0.0;
|
||||
let mut want = c.segs.last().map(|s| (s.end(), s.tangent(1.0)));
|
||||
for s in &c.segs {
|
||||
let len = s.len();
|
||||
if d <= acc + len + 1e-12 && len > 0.0 {
|
||||
let t = ((d - acc) / len).clamp(0.0, 1.0);
|
||||
want = Some((s.at(t), s.tangent(t)));
|
||||
break;
|
||||
}
|
||||
acc += len;
|
||||
}
|
||||
assert_eq!(c.at_length_in(&ends, d), want, "d = {d}");
|
||||
assert_eq!(c.at_length(d), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_builders() {
|
||||
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
|
||||
|
||||
@@ -36,18 +36,16 @@ fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
|
||||
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let range = if c.closed { 0..n } else { 1..n };
|
||||
let ends = c.end_lengths();
|
||||
range.filter_map(|i| c.at_length_in(&ends, l * i as f64 / n as f64)).collect()
|
||||
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let ends = c.end_lengths();
|
||||
let mut out = Vec::new();
|
||||
let mut k = 1usize;
|
||||
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
|
||||
let d = k as f64 * step;
|
||||
out.extend(c.at_length_in(&ends, d));
|
||||
out.extend(c.at_length(d));
|
||||
k += 1;
|
||||
}
|
||||
out
|
||||
|
||||
@@ -58,9 +58,8 @@ pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, re
|
||||
closed: true,
|
||||
};
|
||||
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
|
||||
let ends = chain.end_lengths();
|
||||
for j in 0..k {
|
||||
out.extend(chain.at_length_in(&ends, perim * j as f64 / k as f64).map(|x| x.0));
|
||||
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
|
||||
}
|
||||
}
|
||||
let b = if reverse { -0.5 } else { 0.5 };
|
||||
|
||||
@@ -204,8 +204,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("import", "paths are not available on the web; pass `data`"))
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_co
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
pub use pgp::{MAX_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
|
||||
pub use pgp::{MAX_ALIASES, alias_name, command_id};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
/// Largest `.scr` script accepted from a local path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{BlockBoundsCache, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds_cached};
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
|
||||
fn read_path(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
|
||||
@@ -375,20 +375,16 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let Diff { added, removed, changed, common, truncated } = diff(&cur, &other, &space);
|
||||
let mut boxes = Vec::new();
|
||||
// One block-bounds cache for each drawing, so a block many differing entities refer to is
|
||||
// measured once, not once per entity.
|
||||
let (mut cur_cache, mut other_cache) = (BlockBoundsCache::default(), BlockBoundsCache::default());
|
||||
fn bound<'a>(boxes: &mut Vec<Bounds2>, d: &'a Drawing, h: Handle, cache: &mut BlockBoundsCache<'a>) {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds_cached(d, e, 0, cache)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite())
|
||||
{
|
||||
let mut bound = |d: &Drawing, h: Handle| {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds(d, e, 0)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
|
||||
boxes.push(b);
|
||||
}
|
||||
}
|
||||
added.iter().for_each(|h| bound(&mut boxes, &cur, *h, &mut cur_cache));
|
||||
removed.iter().for_each(|h| bound(&mut boxes, &other, *h, &mut other_cache));
|
||||
};
|
||||
added.iter().for_each(|h| bound(&cur, *h));
|
||||
removed.iter().for_each(|h| bound(&other, *h));
|
||||
for (a, b, _, _) in &changed {
|
||||
bound(&mut boxes, &cur, *a, &mut cur_cache);
|
||||
bound(&mut boxes, &other, *b, &mut other_cache);
|
||||
bound(&cur, *a);
|
||||
bound(&other, *b);
|
||||
}
|
||||
let regions = regions(&boxes);
|
||||
let ch: Vec<Value> = changed
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
//! The user's command alias file (the publicly documented `.pgp` format: `ALIAS, *COMMAND` lines,
|
||||
//! `;` comments): ALIASEDIT and REINIT.
|
||||
//!
|
||||
//! A loaded file replaces the user aliases, which add to or override the built-in ones for every
|
||||
//! drawing; command names themselves always win. Lines that define external commands
|
||||
//! A loaded file adds aliases or overrides the built-in ones for the rest of the session, for
|
||||
//! every drawing; command names themselves always win. Lines that define external commands
|
||||
//! (programs the file would have the application start) are never run: they are skipped with a
|
||||
//! note. CADCraft ships no alias file; the aliases built into the command catalog stay the
|
||||
//! defaults.
|
||||
//!
|
||||
//! The aliases are user settings, never part of a drawing: [`alias_profile`] gives them (and the
|
||||
//! file REINIT reads) to the host's user profile, and [`restore_aliases`] brings them back on the
|
||||
//! next start. The desktop and web hosts keep that profile; the headless CLI has no profile, so
|
||||
//! aliases set in a script last for that session only.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
@@ -25,21 +20,10 @@ use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
|
||||
pub const MAX_ALIASES: usize = 10_000;
|
||||
/// Longest alias name.
|
||||
const MAX_ALIAS_CHARS: usize = 64;
|
||||
/// Longest command id (the catalog's longest is `texttofront.dimensions`; a test keeps it true).
|
||||
const MAX_COMMAND_ID_CHARS: usize = 32;
|
||||
/// Longest line read.
|
||||
const MAX_LINE_CHARS: usize = 4096;
|
||||
/// Most warnings reported.
|
||||
const MAX_WARNINGS: usize = 100;
|
||||
/// Longest alias file path kept in the user profile.
|
||||
const MAX_ALIAS_FILE_CHARS: usize = 1024;
|
||||
|
||||
/// The largest alias section [`alias_profile`] can produce: [`MAX_ALIASES`] entries, each a
|
||||
/// [`MAX_ALIAS_CHARS`]-character name (up to four UTF-8 bytes per character, and JSON adds its
|
||||
/// quotes) mapped to a command id, plus the alias file path and the section's keys. A host that
|
||||
/// keeps the section in its user profile allows at least this much, so a full set always comes
|
||||
/// back after a restart.
|
||||
pub const MAX_ALIAS_PROFILE_BYTES: usize = MAX_ALIASES * (MAX_ALIAS_CHARS * 4 + MAX_COMMAND_ID_CHARS + 6) + MAX_ALIAS_FILE_CHARS * 4 + 64;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Aliases {
|
||||
@@ -59,49 +43,6 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
store().map.get(name).cloned()
|
||||
}
|
||||
|
||||
/// The user aliases and the file REINIT reads, as the host's user profile keeps them:
|
||||
/// `{"aliases": {alias: command}, "file": path or null}`. Not drawing data.
|
||||
pub fn alias_profile() -> Value {
|
||||
let st = store();
|
||||
// A path [`restore_aliases`] would refuse is left out, so it can't grow the profile either.
|
||||
json!({ "aliases": st.map, "file": st.file.as_deref().filter(|f| keepable_path(f)) })
|
||||
}
|
||||
|
||||
/// Whether the profile keeps an alias file path: short enough, and no control characters.
|
||||
fn keepable_path(f: &str) -> bool {
|
||||
f.chars().count() <= MAX_ALIAS_FILE_CHARS && !f.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Restore aliases saved by [`alias_profile`], replacing the user aliases and the file.
|
||||
///
|
||||
/// The saved set is untrusted (a hand-edited or older profile): entries that are not aliases of
|
||||
/// real commands are skipped, command names are never aliased, at most [`MAX_ALIASES`] are kept,
|
||||
/// and a malformed section changes nothing.
|
||||
pub fn restore_aliases(saved: &Value) {
|
||||
let Some(obj) = saved.as_object() else { return };
|
||||
let file = match obj.get("file") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) if keepable_path(f) => Some(f.clone()),
|
||||
Some(Value::String(_)) => None,
|
||||
// A file entry that is not a path makes the section malformed: keep the aliases as they are.
|
||||
Some(_) => return,
|
||||
};
|
||||
let Some(map) = obj.get("aliases").and_then(Value::as_object) else { return };
|
||||
let mut aliases = BTreeMap::new();
|
||||
// Entries that are skipped don't use up the cap.
|
||||
for (alias, target) in map {
|
||||
if aliases.len() >= MAX_ALIASES {
|
||||
break;
|
||||
}
|
||||
let Ok(name) = alias_name(alias) else { continue };
|
||||
let Some(id) = target.as_str().and_then(command_id) else { continue };
|
||||
aliases.insert(name, id);
|
||||
}
|
||||
let mut st = store();
|
||||
st.map = aliases;
|
||||
st.file = file;
|
||||
}
|
||||
|
||||
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
|
||||
pub fn command_id(target: &str) -> Option<String> {
|
||||
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
|
||||
@@ -260,25 +201,3 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("reinit", "Reinitialize", run_reinit).params("{file? | text?} (default: the alias file loaded last)").enabled(always).noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_COMMAND_ID_CHARS;
|
||||
|
||||
/// [`super::MAX_ALIAS_PROFILE_BYTES`] bounds a saved alias's command id by the longest id in
|
||||
/// the catalog: keep that true as commands are added.
|
||||
#[test]
|
||||
fn every_command_id_fits_the_saved_alias_bound() {
|
||||
for spec in crate::cmd::command_specs() {
|
||||
assert!(spec.id.chars().count() <= MAX_COMMAND_ID_CHARS, "`{}` exceeds the alias profile bound", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// An alias file path the profile couldn't restore isn't saved in it.
|
||||
#[test]
|
||||
fn only_paths_the_profile_restores_are_saved() {
|
||||
assert!(super::keepable_path("C:\\support\\mine.pgp"));
|
||||
assert!(!super::keepable_path(&"x".repeat(super::MAX_ALIAS_FILE_CHARS + 1)));
|
||||
assert!(!super::keepable_path("mine\n.pgp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -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 })),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -303,9 +303,11 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,21 +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)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Each Copy clones every selected object: the copies together stay within MAXARRAY, as an array's do.
|
||||
// Erased handles are skipped by `apply`, so only live objects count (none: nothing is copied).
|
||||
let live = counts(s, &objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, ops.iter().filter(|o| matches!(o, Op::Copy(_))).count() as u64, live)?;
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for op in ops {
|
||||
copies.extend(apply(s, &objs, &mut base, op)?);
|
||||
@@ -156,12 +141,6 @@ pub(super) struct MocoroM {
|
||||
|
||||
impl MocoroM {
|
||||
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
|
||||
if matches!(op, Op::Copy(_)) {
|
||||
let live = counts(s, &self.objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, 1, live)?;
|
||||
}
|
||||
}
|
||||
let before = s.state()?.doc.clone();
|
||||
let base = self.base;
|
||||
apply(s, &self.objs, &mut self.base, op)?;
|
||||
|
||||
@@ -34,26 +34,15 @@ pub(crate) fn arraypath(
|
||||
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
|
||||
};
|
||||
crate::cmd::array::check_size(s, n as u64, objs.len())?;
|
||||
// Segment end lengths once: each sample is then a binary search, not a scan from the start.
|
||||
let ends = c.end_lengths();
|
||||
let Some((p0, t0)) = c.at_length_in(&ends, 0.0) else { return Ok(Vec::new()) };
|
||||
let mut mats = Vec::new();
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
for i in 1..n {
|
||||
let Some((p, t)) = c.at_length_in(&ends, step * i as f64) else { continue };
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ fn text_of(k: &EntityKind) -> Option<String> {
|
||||
EntityKind::Text(t) => Some(t.value.clone()),
|
||||
EntityKind::AttDef(a) => Some(a.text.value.clone()),
|
||||
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
|
||||
// A multileader's text (one with a block instead has none, #1081).
|
||||
EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.replace("\\P", "\n")),
|
||||
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
|
||||
_ => None,
|
||||
}
|
||||
@@ -27,16 +25,11 @@ pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
|
||||
EntityKind::Text(t) => t.value = text.to_string(),
|
||||
EntityKind::AttDef(a) => a.text.value = text.to_string(),
|
||||
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
|
||||
EntityKind::MLeader(m) if m.text.is_some() => {
|
||||
if let Some(t) = &mut m.text {
|
||||
t.contents = text.replace('\n', "\\P");
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
d.text = if text == "<>" { String::new() } else { text.to_string() };
|
||||
d.block = None;
|
||||
}
|
||||
_ => return Err(other("Select a text, mtext, multileader, attribute definition or dimension.")),
|
||||
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
|
||||
}
|
||||
set_kind(s, h, k)
|
||||
}
|
||||
@@ -60,18 +53,12 @@ pub(super) struct TextEditM {
|
||||
single: bool,
|
||||
/// Asking for the edit mode.
|
||||
mode: bool,
|
||||
/// The MTEXT being edited, for the in-place editor (#842).
|
||||
mtext: Option<cadcraft_doc::MText>,
|
||||
}
|
||||
|
||||
impl Interactive for TextEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TEXTEDIT"
|
||||
}
|
||||
/// An MTEXT asking for its new text opens in the in-place editor with its contents.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
self.h.as_ref().and(self.mtext.clone())
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.mode {
|
||||
let current = if self.single { "Single" } else { "Multiple" };
|
||||
@@ -96,24 +83,7 @@ impl Interactive for TextEditM {
|
||||
match (&self.h, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
// Refused when picked, not after the new text has been typed.
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let kind = curves::entity(s, h)?.kind;
|
||||
let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, multileader, attribute definition or dimension."))?;
|
||||
self.mtext = match kind {
|
||||
EntityKind::MText(m) => Some(m),
|
||||
// A multileader's text opens in the same editor (#1081).
|
||||
EntityKind::MLeader(m) => m.text,
|
||||
_ => None,
|
||||
};
|
||||
// The editor would drop part of a text this long, and saving would lose it: the
|
||||
// command line asks for it instead.
|
||||
if self.mtext.as_ref().is_some_and(|m| !crate::mtext_edit::Formatted::fits(&m.contents)) {
|
||||
self.mtext = None;
|
||||
s.echo("This text is too long for the editor; enter the new text at the command line.");
|
||||
}
|
||||
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
|
||||
self.h = Some((h, old));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -123,12 +93,10 @@ impl Interactive for TextEditM {
|
||||
set_text(s, h, &t)?;
|
||||
self.edits.push((h, before));
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(Some(_), Input::Enter) => {
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(None, Input::Enter) => Ok(Step::Done),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(
|
||||
@@ -402,17 +334,6 @@ fn apply_kind_properties(
|
||||
t.rotation = x.to_radians();
|
||||
}
|
||||
}
|
||||
// A multileader's text: its contents (#1081) and height.
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &mut m.text {
|
||||
if let Some(v) = str_param(params, "text") {
|
||||
t.contents = v.replace('\n', "\\P");
|
||||
}
|
||||
if let Some(x) = num("height").filter(|x| *x > 0.0) {
|
||||
t.height = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
if let Some(x) = num("width").filter(|x| *x >= 0.0) {
|
||||
pl.const_width = x;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -175,14 +175,8 @@ pub fn compare(prop: &Value, op: Op, value: &Value) -> bool {
|
||||
if ptxt.eq_ignore_ascii_case(vtxt.trim()) {
|
||||
return true;
|
||||
}
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. No colour spelling is long, so a
|
||||
// long text (a layer name from a file) isn't parsed, copied and split per entity.
|
||||
const MAX_COLOR_TEXT: usize = 64;
|
||||
let (pc, vc) = (ptxt.trim(), vtxt.trim());
|
||||
if pc.len() > MAX_COLOR_TEXT || vc.len() > MAX_COLOR_TEXT {
|
||||
return false;
|
||||
}
|
||||
match (Color::parse(pc), Color::parse(vc)) {
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`.
|
||||
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
@@ -406,26 +400,3 @@ mod tests {
|
||||
assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod long_text_tests {
|
||||
use super::*;
|
||||
|
||||
/// A long string property is compared as text only, and a colour name still equals its index.
|
||||
#[test]
|
||||
fn long_values_skip_colour_parsing() {
|
||||
let long = Value::String("x".repeat(100_000));
|
||||
assert!(compare(&long, Op::Eq, &long));
|
||||
assert!(!compare(&long, Op::Eq, &Value::String("red".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &Value::String("1".into())));
|
||||
}
|
||||
|
||||
/// Padding is trimmed before the length limit, so a padded colour still matches.
|
||||
#[test]
|
||||
fn padded_colours_still_match() {
|
||||
let padded = |c: &str| Value::String(format!("{}{c}{}", " ".repeat(100), " ".repeat(100)));
|
||||
assert!(compare(&padded("red"), Op::Eq, &Value::String("1".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &padded("1")));
|
||||
assert!(!compare(&padded("red"), Op::Ne, &padded("1")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Largest linked file read (bytes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_LINK_BYTES: usize = 8 << 20;
|
||||
const MAX_LINK_BYTES: u64 = 8 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -238,9 +238,11 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
|
||||
// that misreports its length can't be buffered to EOF.
|
||||
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
if meta.len() > MAX_LINK_BYTES {
|
||||
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
@@ -368,16 +370,6 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A linked path naming an endless device is refused instead of buffered to EOF.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn link_to_endless_device_is_rejected() {
|
||||
let dev = std::path::Path::new("/dev/zero");
|
||||
if dev.exists() {
|
||||
assert!(read_link("datalink", dev).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_parsing() {
|
||||
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -123,12 +123,6 @@ pub struct LispState {
|
||||
acad_loaded: bool,
|
||||
/// Memory caps (lowered by tests).
|
||||
pub(crate) limits: Limits,
|
||||
/// Interpreter steps all synchronous callbacks have run, nested ones included. A callback's
|
||||
/// allowance counts what ran since it started, so the callbacks it calls spend its steps.
|
||||
pub(crate) callback_used: u64,
|
||||
/// The `callback_used` value the callback running now (and so every one it calls) must not
|
||||
/// pass: the lowest of its own and its callers' allowances.
|
||||
pub(crate) callback_ceiling: Option<u64>,
|
||||
}
|
||||
|
||||
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
|
||||
@@ -421,12 +415,9 @@ impl Session {
|
||||
|
||||
/// Move printed text to the command history: complete lines, and with `all` the rest too.
|
||||
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
|
||||
// One pass over the buffer, dropping the printed lines once at the end: draining each line
|
||||
// from the front shifted the rest every time, quadratic in newline-dense output.
|
||||
let mut done = 0;
|
||||
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
|
||||
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
|
||||
done += i + 1;
|
||||
while let Some(i) = env.out.find('\n') {
|
||||
let line: String = env.out.drain(..=i).collect();
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_string();
|
||||
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
|
||||
// leaving an empty one; after LISP's own line it does print an empty line.
|
||||
if line.is_empty() && env.printed_at != Some(self.log.len()) {
|
||||
@@ -436,7 +427,6 @@ impl Session {
|
||||
self.echo(line);
|
||||
env.printed_at = Some(self.log.len());
|
||||
}
|
||||
env.out.drain(..done);
|
||||
if all && !env.out.is_empty() {
|
||||
let line = std::mem::take(&mut env.out);
|
||||
self.echo(line);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::args::{R, arity, int, list_arg, string};
|
||||
use super::{LispError, OsStr, Value, charge_bytes, check_string_len, read, to_text};
|
||||
use super::{LispError, OsStr, Value, check_string_len, read, to_text};
|
||||
|
||||
// ---------------- strings ----------------
|
||||
|
||||
@@ -114,13 +114,7 @@ pub(super) fn read_fn(a: &[Value]) -> R {
|
||||
arity(a, 0, 1)?;
|
||||
match a.first() {
|
||||
None | Some(Value::Nil) => Ok(Value::Nil),
|
||||
Some(v) => {
|
||||
// The strings in the list it makes are new, each as long as its text at most: the
|
||||
// text is charged, though a list result isn't inspected by `charge_result`.
|
||||
let text = string(v)?;
|
||||
charge_bytes(text.len())?;
|
||||
read::read_one(&text)
|
||||
}
|
||||
Some(v) => read::read_one(&string(v)?),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,12 +179,7 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
|
||||
Value::Nil => serde_json::Value::Null,
|
||||
Value::Int(i) => serde_json::json!(i),
|
||||
Value::Real(r) => serde_json::json!(r),
|
||||
Value::Str(s) => {
|
||||
// A string is copied whole each time it occurs: it spends the allowance by its length
|
||||
// (one unit per 16 bytes, so about 32 MB in all), shared or not.
|
||||
*budget = budget.checked_sub(s.len() as u64 / 16)?;
|
||||
serde_json::json!(&**s)
|
||||
}
|
||||
Value::Str(s) => serde_json::json!(&**s),
|
||||
Value::Cons(_) => {
|
||||
let mut arr = Vec::new();
|
||||
for x in v.iter() {
|
||||
@@ -301,16 +296,12 @@ fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The most string bytes an entity list may hold (counted per occurrence, shared or not).
|
||||
const MAX_GROUP_TEXT: usize = 32 << 20;
|
||||
|
||||
/// An association list back to DXF groups (points split into x, y, z groups). The type
|
||||
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
|
||||
/// left out: they aren't the entity's to set.
|
||||
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
let mut kind = None;
|
||||
let mut tags = Vec::new();
|
||||
let mut text_left = MAX_GROUP_TEXT;
|
||||
for item in list.iter() {
|
||||
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
|
||||
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
|
||||
@@ -332,11 +323,7 @@ fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
_ => tags.push(match &v {
|
||||
Value::Int(i) => Tag::i(code, i64::from(*i)),
|
||||
Value::Real(r) => Tag::f(code, *r),
|
||||
Value::Str(s) => {
|
||||
// Each occurrence of a shared string becomes its own copy in the tag.
|
||||
text_left = text_left.checked_sub(s.len()).ok_or_else(|| LispError::new("out of memory: the entity list holds too much text"))?;
|
||||
Tag::s(code, &**s)
|
||||
}
|
||||
Value::Str(s) => Tag::s(code, &**s),
|
||||
Value::Ename(h) => Tag::s(code, h.hex()),
|
||||
other => return Err(LispError::bad_arg("DXF group value", other)),
|
||||
}),
|
||||
@@ -1138,27 +1125,3 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
/// One 64 KB string occurring many times is charged by its length each time.
|
||||
#[test]
|
||||
fn shared_strings_spend_the_conversion_budgets() {
|
||||
let big = Value::str(&"x".repeat(64 << 10));
|
||||
// 600 x 64 KB is over the 32 MiB an entity list may hold; 100 x 64 KB is not.
|
||||
let list = |n: usize| {
|
||||
let mut items = vec![Value::cons(Value::Int(0), Value::str("TEXT"))];
|
||||
items.extend(std::iter::repeat_n(Value::cons(Value::Int(1), big.clone()), n));
|
||||
Value::list(items)
|
||||
};
|
||||
assert!(list_to_groups(&list(600)).is_err());
|
||||
assert!(list_to_groups(&list(100)).is_ok());
|
||||
// 1,000 x 64 KB is 4,096,000 units of the 2,000,000 SETVAR allows; 100 x 64 KB is not.
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big.clone(); 1_000]), 0, &mut budget).is_none());
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big; 100]), 0, &mut budget).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +201,6 @@ impl Machine {
|
||||
if n > budget {
|
||||
return Outcome::Wait(Need::Yield);
|
||||
}
|
||||
// Charge a callback's step as it runs, so callbacks it calls see what it already spent.
|
||||
if ctx.depth > 0 {
|
||||
ctx.s.lisp.callback_used = ctx.s.lisp.callback_used.saturating_add(1);
|
||||
}
|
||||
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
|
||||
State::Eval(expr) => self.eval(ctx, expr),
|
||||
State::Call(f, args) => self.call(ctx, f, args),
|
||||
@@ -650,44 +646,29 @@ impl Ctx<'_> {
|
||||
super::value::charge_result(&v)?;
|
||||
return Ok(v);
|
||||
}
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let start = self.s.lisp.callback_used;
|
||||
// This callback may run until `callback_used` reaches `ceiling`: `cap` steps from now, or
|
||||
// sooner where a caller's allowance ends, so callbacks nested in callbacks share one
|
||||
// allowance instead of each taking a fresh one (an exponential tree otherwise).
|
||||
let outer = self.s.lisp.callback_ceiling;
|
||||
let ceiling = start.saturating_add(cap).min(outer.unwrap_or(u64::MAX));
|
||||
self.s.lisp.callback_ceiling = Some(ceiling);
|
||||
let r = self.drive_callback(f, args, ceiling);
|
||||
self.s.lisp.callback_ceiling = outer;
|
||||
r
|
||||
}
|
||||
|
||||
/// Run callback `f` in slices until it finishes or `callback_used` reaches `ceiling`.
|
||||
fn drive_callback(&mut self, f: &Value, args: Vec<Value>, ceiling: u64) -> Result<Value, LispError> {
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let mut m = Machine::calling(f.clone(), args);
|
||||
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
||||
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
|
||||
// (the step budget ran out) is retried until the ceiling, so a constant-memory infinite
|
||||
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
|
||||
// loop inside a comparator or predicate terminates with an error rather than hanging the
|
||||
// thread and bypassing the blocking execution cap (CWE-400). The steps of callbacks this
|
||||
// one calls are counted in `callback_used` too.
|
||||
// thread and bypassing the blocking execution cap (CWE-400).
|
||||
let mut spent: u64 = 0;
|
||||
loop {
|
||||
let remaining = ceiling.saturating_sub(sub.s.lisp.callback_used);
|
||||
let remaining = cap.saturating_sub(spent);
|
||||
if remaining == 0 {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
let slice = remaining.min(SYNC_SLICE);
|
||||
let outcome = m.run(&mut sub, slice);
|
||||
match outcome {
|
||||
// The steps were charged as they ran; finishing past the shared allowance is a failure too.
|
||||
Outcome::Done(_) if sub.s.lisp.callback_used > ceiling => {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
match m.run(&mut sub, slice) {
|
||||
Outcome::Done(v) => return Ok(v),
|
||||
Outcome::Error(e) => return Err(e),
|
||||
Outcome::Wait(Need::Yield) => {}
|
||||
Outcome::Wait(Need::Yield) => {
|
||||
spent = spent.saturating_add(slice);
|
||||
if spent >= cap {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
}
|
||||
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,35 +286,6 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
|
||||
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
|
||||
}
|
||||
|
||||
/// Callbacks that call callbacks share one allowance: a branching routine can't multiply it.
|
||||
#[test]
|
||||
fn nested_callbacks_share_one_step_allowance() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000_000;
|
||||
// 4^14 leaf calls if every callback took a fresh allowance; the whole tree gets one.
|
||||
let started = std::time::Instant::now();
|
||||
let e = eval(&mut s, "(defun f (n) (if (> n 0) (vl-every '(lambda (x) (f (1- n)) t) '(1 2 3 4)) t)) (f 14)").unwrap_err();
|
||||
assert!(e.contains("callback stopped after 1000000 steps"), "{e}");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(60));
|
||||
// The next run starts afresh.
|
||||
assert_eq!(ok(&mut s, "(f 1)"), "T");
|
||||
}
|
||||
|
||||
/// An outer callback's own steps count against the allowance before the callbacks it calls run.
|
||||
#[test]
|
||||
fn an_outer_callbacks_steps_are_charged_before_a_nested_one() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000;
|
||||
ok(&mut s, "(setq inner-steps 0)");
|
||||
// The outer callback spends ~600 steps, then a nested one tries to spend ~600 more.
|
||||
let r = eval(
|
||||
&mut s,
|
||||
"(vl-remove-if '(lambda (x) (repeat 150 (setq a 1)) (vl-remove-if '(lambda (y) (repeat 150 (setq inner-steps (1+ inner-steps)))) '(1)) nil) '(1))",
|
||||
);
|
||||
assert_eq!(r, Err("callback stopped after 1000 steps".into()));
|
||||
assert!(ok(&mut s, "inner-steps").parse::<i64>().unwrap() < 150);
|
||||
}
|
||||
|
||||
/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget.
|
||||
#[test]
|
||||
fn setvar_rejects_an_oversized_shared_structure() {
|
||||
@@ -336,16 +307,6 @@ fn printing() {
|
||||
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
|
||||
}
|
||||
|
||||
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
|
||||
#[test]
|
||||
fn printing_many_lines_is_linear() {
|
||||
let mut s = session();
|
||||
let started = std::time::Instant::now();
|
||||
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_draws_and_one_routine_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
@@ -1016,9 +977,6 @@ fn strings_are_capped() {
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// READ makes new strings inside the list it returns: they count, though the result is a list.
|
||||
let e = eval(&mut s, "(setq a \"(\\\"0123456789\\\")\") (repeat 100000 (setq b (read a)))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// The budget is per run: the next one starts afresh.
|
||||
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
|
||||
}
|
||||
|
||||
@@ -346,11 +346,7 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
||||
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
|
||||
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
|
||||
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
|
||||
equal_within(a, b, fuzz, EQUAL_BUDGET)
|
||||
}
|
||||
|
||||
/// [`equal`] with its work allowance (node visits plus string bytes / 64) given.
|
||||
fn equal_within(a: &Value, b: &Value, fuzz: f64, mut budget: u64) -> bool {
|
||||
let mut budget: u64 = EQUAL_BUDGET;
|
||||
equal_depth(a, b, fuzz, 0, &mut budget)
|
||||
}
|
||||
|
||||
@@ -377,19 +373,7 @@ fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64)
|
||||
a = na;
|
||||
b = nb;
|
||||
}
|
||||
_ => {
|
||||
// Comparing two strings costs their length, not one visit: a shared list of long
|
||||
// equal strings must spend the allowance by the bytes it compares (one unit per 64).
|
||||
if let (Value::Str(x), Value::Str(y)) = (&a, &b) {
|
||||
let cost = (x.len().min(y.len()) / 64) as u64;
|
||||
if cost >= *budget {
|
||||
*budget = 0;
|
||||
return false;
|
||||
}
|
||||
*budget -= cost;
|
||||
}
|
||||
return atom_equal(&a, &b, fuzz);
|
||||
}
|
||||
_ => return atom_equal(&a, &b, fuzz),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,20 +540,6 @@ fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `equal` charges the bytes of the strings it compares: a list of long equal strings fits a
|
||||
/// big allowance but not a small one, though it has few nodes.
|
||||
#[test]
|
||||
fn equal_spends_its_allowance_on_string_bytes() {
|
||||
let long = Value::str(&"x".repeat(6_400));
|
||||
let other = Value::str(&"x".repeat(6_400));
|
||||
let list = |v: &Value| Value::list(vec![v.clone(); 100]);
|
||||
let (a, b) = (list(&long), list(&other));
|
||||
// 100 strings x 100 units each, plus about 100 node visits.
|
||||
assert!(equal_within(&a, &b, 0.0, 20_000));
|
||||
assert!(!equal_within(&a, &b, 0.0, 5_000));
|
||||
assert!(equal(&a, &b, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reals_print_with_six_significant_digits() {
|
||||
assert_eq!(real_text(1.0), "1.0");
|
||||
|
||||
+11
-112
@@ -33,9 +33,6 @@ pub struct CharFmt {
|
||||
pub font: Option<String>,
|
||||
/// Absolute character height set inline (`\H`); `None`: the text's.
|
||||
pub height: Option<f64>,
|
||||
/// The inline height was set relative to the text's own (`\H2x;`): this multiplier, written
|
||||
/// back as it was so the part keeps scaling with the text's height.
|
||||
pub height_rel: Option<f64>,
|
||||
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
|
||||
pub color: Option<MTextColor>,
|
||||
}
|
||||
@@ -102,60 +99,26 @@ fn arg_end(chars: &[char], from: usize) -> usize {
|
||||
impl Formatted {
|
||||
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
|
||||
pub fn from_contents(contents: &str) -> Formatted {
|
||||
Self::from_contents_with_height(contents, None)
|
||||
}
|
||||
|
||||
/// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no
|
||||
/// inline height before it scales that one instead of being dropped.
|
||||
pub fn from_contents_with_height(contents: &str, height: Option<f64>) -> Formatted {
|
||||
Self::parse(contents, height).0
|
||||
}
|
||||
|
||||
/// Whether the editor holds all of `contents`: past [`MAX_CHARS`] characters, [`MAX_PARAS`]
|
||||
/// paragraphs or [`MAX_ATOMS`] kept codes the rest would be dropped, and saving would lose it.
|
||||
pub fn fits(contents: &str) -> bool {
|
||||
Self::parse(contents, None).1
|
||||
}
|
||||
|
||||
/// Whether `edited`, what the editor gives back for `original` (a text `height` high), is the
|
||||
/// text as it was: the same, or what opening it and pressing OK without an edit gives (a
|
||||
/// non-canonical `{\fArial|b1;first}` comes back written another way).
|
||||
pub fn leaves_unchanged(original: &str, height: Option<f64>, edited: &str) -> bool {
|
||||
edited == original || edited == Self::from_contents_with_height(original, height).to_contents()
|
||||
}
|
||||
|
||||
/// The parse, and whether it took in all of `contents`.
|
||||
fn parse(contents: &str, height: Option<f64>) -> (Formatted, bool) {
|
||||
let mut f = Formatted::default();
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut stack: Vec<CharFmt> = Vec::new();
|
||||
let mut cur = CharFmt::default();
|
||||
let mut props = ParaProps::default();
|
||||
f.paras = vec![props.clone()];
|
||||
// Cleared when a paragraph, a kept code or a nesting level doesn't fit.
|
||||
let mut whole = true;
|
||||
// Groups opened past the 64 levels kept: their closing braces pop nothing.
|
||||
let mut unkept = 0usize;
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
// Braces after the last character the editor holds add no text.
|
||||
if f.fmt.len() >= MAX_CHARS && !matches!(c, '{' | '}' | '\r') {
|
||||
return (f, false);
|
||||
if f.fmt.len() >= MAX_CHARS {
|
||||
break;
|
||||
}
|
||||
match c {
|
||||
'{' => {
|
||||
if stack.len() < 64 {
|
||||
stack.push(cur.clone());
|
||||
} else {
|
||||
unkept = unkept.saturating_add(1);
|
||||
whole = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'}' => {
|
||||
if unkept > 0 {
|
||||
unkept -= 1;
|
||||
} else if let Some(p) = stack.pop() {
|
||||
if let Some(p) = stack.pop() {
|
||||
cur = p;
|
||||
}
|
||||
i += 1;
|
||||
@@ -165,8 +128,6 @@ impl Formatted {
|
||||
if f.paras.len() < MAX_PARAS {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
} else {
|
||||
whole = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -179,7 +140,6 @@ impl Formatted {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
}
|
||||
Some('P') => whole = false,
|
||||
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
|
||||
Some('L') => cur.underline = true,
|
||||
Some('l') => cur.underline = false,
|
||||
@@ -191,7 +151,7 @@ impl Formatted {
|
||||
i = end + 1;
|
||||
match k {
|
||||
'f' | 'F' => apply_font(&mut cur, &a),
|
||||
'H' => apply_height(&mut cur, &a, height),
|
||||
'H' => cur.height = parse_height(&a, cur.height),
|
||||
'C' => {
|
||||
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
|
||||
}
|
||||
@@ -217,7 +177,6 @@ impl Formatted {
|
||||
code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter());
|
||||
i = end + 1;
|
||||
}
|
||||
whole &= f.atoms.len() < MAX_ATOMS;
|
||||
f.push_atom(code, &cur);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +187,7 @@ impl Formatted {
|
||||
}
|
||||
}
|
||||
}
|
||||
(f, whole)
|
||||
f
|
||||
}
|
||||
|
||||
fn push_char(&mut self, c: char, fmt: &CharFmt) {
|
||||
@@ -486,9 +445,7 @@ impl Formatted {
|
||||
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
|
||||
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
|
||||
}
|
||||
if let Some(m) = f.height_rel {
|
||||
s.push_str(&format!("\\H{}x;", trim(m)));
|
||||
} else if let Some(h) = f.height {
|
||||
if let Some(h) = f.height {
|
||||
s.push_str(&format!("\\H{};", trim(h)));
|
||||
}
|
||||
match f.color {
|
||||
@@ -505,32 +462,12 @@ fn trim(v: f64) -> String {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// A `\H` code on `cur`, for a text `base` high. A relative height over the text's own (no
|
||||
/// inline height before it, or a relative one) stays relative; over an inline absolute height it
|
||||
/// becomes absolute.
|
||||
fn apply_height(cur: &mut CharFmt, a: &str, base: Option<f64>) {
|
||||
let valid = |h: f64| (h.is_finite() && h > 0.0 && h < 1e6).then_some(h);
|
||||
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
|
||||
let a = a.trim();
|
||||
let Some(r) = a.strip_suffix(['x', 'X']) else {
|
||||
if let Some(h) = a.parse::<f64>().ok().and_then(valid) {
|
||||
cur.height = Some(h);
|
||||
cur.height_rel = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(m) = r.trim().parse::<f64>().ok().and_then(valid) else { return };
|
||||
match (cur.height, cur.height_rel) {
|
||||
(_, Some(r0)) => {
|
||||
if let Some(m) = valid(r0 * m) {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
(Some(h), None) => cur.height = valid(h * m).or(cur.height),
|
||||
match a.strip_suffix(['x', 'X']) {
|
||||
// A relative height needs the current one; without it the code is dropped.
|
||||
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
|
||||
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,22 +493,6 @@ fn apply_font(cur: &mut CharFmt, a: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_relative_height_is_written_back_relative() {
|
||||
let f = Formatted::from_contents_with_height("{\\H2x;big} small {\\H3;abs}", Some(5.0));
|
||||
assert_eq!(f.fmt_at(0).height, Some(10.0), "shown at twice the text's height");
|
||||
let out = f.to_contents();
|
||||
assert!(out.contains("\\H2x;big"), "{out}");
|
||||
assert!(out.contains("\\H3;abs"), "an absolute height stays absolute: {out}");
|
||||
// Opened again for a taller text, the part scales with it.
|
||||
assert_eq!(Formatted::from_contents_with_height(&out, Some(8.0)).fmt_at(0).height, Some(16.0));
|
||||
// Relative over relative multiplies; over an absolute one it is absolute.
|
||||
let n = Formatted::from_contents_with_height("{\\H2x;a{\\H1.5x;b}}{\\H4;c{\\H2x;d}}", Some(1.0)).to_contents();
|
||||
assert!(n.contains("\\H3x;b") && n.contains("\\H8;d"), "{n}");
|
||||
// With no height known it is still kept.
|
||||
assert!(Formatted::from_contents("{\\H2x;x}").to_contents().contains("\\H2x;x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_formats_and_atoms() {
|
||||
for src in [
|
||||
@@ -642,26 +563,4 @@ mod tests {
|
||||
let _ = f.fmt_at(usize::MAX);
|
||||
let _ = Formatted::from_contents(&"{".repeat(100_000));
|
||||
}
|
||||
|
||||
/// A text with more paragraphs or kept codes than the editor holds doesn't fit, so TEXTEDIT
|
||||
/// leaves it to the command line instead of dropping the rest on save.
|
||||
#[test]
|
||||
fn too_many_paragraphs_or_codes_do_not_fit() {
|
||||
assert!(Formatted::fits("one\\Ptwo\\A1;three"));
|
||||
assert!(!Formatted::fits(&"a\\P".repeat(MAX_PARAS)));
|
||||
assert!(!Formatted::fits(&"a\n".repeat(MAX_PARAS)));
|
||||
assert!(!Formatted::fits(&"\\A1;".repeat(MAX_ATOMS + 1)));
|
||||
assert!(Formatted::fits(&"\\A1;".repeat(MAX_ATOMS)));
|
||||
// A full formatted run's closing brace adds no text: it still fits.
|
||||
assert!(Formatted::fits(&format!("{{\\L{}}}", "x".repeat(MAX_CHARS))));
|
||||
assert!(!Formatted::fits(&format!("{{\\L{}}}y", "x".repeat(MAX_CHARS))));
|
||||
// OK without an edit leaves a text alone, also one the editor writes another way.
|
||||
let odd = "{\\fArial|b1;first}";
|
||||
assert!(Formatted::leaves_unchanged(odd, Some(2.5), &Formatted::from_contents_with_height(odd, Some(2.5)).to_contents()));
|
||||
assert!(Formatted::leaves_unchanged(odd, Some(2.5), odd));
|
||||
assert!(!Formatted::leaves_unchanged(odd, Some(2.5), "second"));
|
||||
// Nesting past the 64 levels the editor keeps doesn't fit.
|
||||
assert!(Formatted::fits(&format!("{}a{}", "{".repeat(64), "}".repeat(64))));
|
||||
assert!(!Formatted::fits(&format!("{}a{}", "{".repeat(65), "}".repeat(65))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,9 +286,8 @@ pub trait Interactive: Send {
|
||||
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
|
||||
None
|
||||
}
|
||||
/// The multiline text being typed (MTEXT, once its box is known, with empty contents) or edited
|
||||
/// (TEXTEDIT on an MTEXT or a multileader's text, with its contents): where the UI places its
|
||||
/// in-place editor and with which height, width, justification and starting text.
|
||||
/// The multiline text being typed (MTEXT, once its box is known) with empty contents: where
|
||||
/// the UI places its in-place editor and with which height, width and justification.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -75,57 +75,6 @@ fn reinit_reads_the_alias_file_again() {
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_alias_profile_restores_without_overriding_commands() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
s.execute("aliasedit", &json!({ "set": { "zzp": "qsave", "zzcmd": "line" } })).unwrap();
|
||||
let saved = cadcraft_engine::cmd::file::alias_profile();
|
||||
assert_eq!(saved["aliases"]["zzp"], json!("qsave"));
|
||||
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert_eq!(resolve_alias("zzp"), "zzp");
|
||||
cadcraft_engine::cmd::file::restore_aliases(&saved);
|
||||
assert_eq!(resolve_alias("zzp"), "qsave");
|
||||
assert_eq!(resolve_alias("zzcmd"), "line");
|
||||
assert_eq!(resolve_alias("line"), "line", "command names win");
|
||||
assert_eq!(resolve_alias("c"), "circle", "built-in aliases still resolve");
|
||||
|
||||
// Hostile saved entries are skipped, and an oversized set is capped without panicking.
|
||||
let mut hostile = serde_json::Map::new();
|
||||
for i in 0..12_000 {
|
||||
hostile.insert(format!("ZZ{i}"), json!("line"));
|
||||
}
|
||||
hostile.insert("LINE".into(), json!("circle"));
|
||||
hostile.insert("ZZLONG".into(), json!("x".repeat(5000)));
|
||||
hostile.insert("ZZNUM".into(), json!(3));
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": hostile, "file": "mine.pgp" }));
|
||||
assert_eq!(resolve_alias("line"), "line", "a restored alias never overrides a command name");
|
||||
assert_eq!(resolve_alias("zzlong"), "zzlong");
|
||||
assert_eq!(resolve_alias("zzn"), "zzn");
|
||||
let listing = s.execute("aliasedit", &json!({})).unwrap();
|
||||
let restored = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
|
||||
assert!(restored <= cadcraft_engine::cmd::file::MAX_ALIASES, "the restored set is capped, not {restored}");
|
||||
assert!(restored > 9_000, "the valid entries restore, not just {restored}");
|
||||
assert_eq!(listing["file"], json!("mine.pgp"));
|
||||
|
||||
// Skipped entries don't use up the cap: invalid ones sorted first leave a full valid set.
|
||||
let mut first_bad = serde_json::Map::new();
|
||||
for i in 0..cadcraft_engine::cmd::file::MAX_ALIASES {
|
||||
first_bad.insert(format!("A{i:05}"), json!("nosuchcommand"));
|
||||
first_bad.insert(format!("B{i:05}"), json!("line"));
|
||||
}
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": first_bad, "file": null }));
|
||||
let listing = s.execute("aliasedit", &json!({})).unwrap();
|
||||
assert_eq!(listing["aliases"].as_object().map_or(0, serde_json::Map::len), cadcraft_engine::cmd::file::MAX_ALIASES);
|
||||
// A malformed section is skipped without changing what ALIASEDIT holds.
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": { "zzchange": "circle" }, "file": 7 }));
|
||||
assert_eq!(resolve_alias("zzchange"), "zzchange", "a malformed section restores nothing");
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert_eq!(resolve_alias("zz0"), "zz0", "clear still empties the restored set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_alias_files_never_panic() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -86,7 +85,6 @@ mod minsert;
|
||||
mod mirror_degenerate_line;
|
||||
mod mleader_content;
|
||||
mod mleader_remember;
|
||||
mod mleader_text_edit;
|
||||
mod mline;
|
||||
mod mocoro;
|
||||
mod modify_curves;
|
||||
@@ -114,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,124 +0,0 @@
|
||||
//! #1081: a multileader's text can be edited: TEXTEDIT (what a double-click starts) takes it and
|
||||
//! hands it to the in-place editor, and the Properties palette's Contents field changes it.
|
||||
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::json;
|
||||
|
||||
fn text_of(s: &Session) -> String {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None })
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textedit_and_properties_change_a_multileaders_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
|
||||
// TEXTEDIT on the text: the current contents are the default, and the in-place editor gets
|
||||
// the multileader's MTEXT.
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Enter new text");
|
||||
assert_eq!(p.default.as_deref(), Some("example text"));
|
||||
assert_eq!(s.editing_mtext().map(|m| m.contents), Some("example text".to_string()));
|
||||
s.input(Input::Text("edited\nsecond line".into())).unwrap();
|
||||
assert_eq!(text_of(&s), "edited\\Psecond line");
|
||||
s.cancel();
|
||||
|
||||
// The Properties palette's Contents field.
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
|
||||
assert_eq!(text_of(&s), "from properties");
|
||||
}
|
||||
|
||||
/// An edited multileader text is undone in one step and is what a DXF save keeps.
|
||||
#[test]
|
||||
fn an_edited_multileader_text_undoes_and_survives_a_dxf_save() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let at = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
|
||||
.unwrap();
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
s.input(Input::Text("edited\nsecond line".into())).unwrap();
|
||||
s.cancel();
|
||||
// 2007 DXF: older versions keep a multileader as a LEADER and an MTEXT.
|
||||
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2007), binary: false };
|
||||
let reopened = cadcraft_io::read_dxf(&cadcraft_io::write_with(s.doc().unwrap(), "x.dxf", &opts).unwrap()).unwrap();
|
||||
let saved =
|
||||
reopened.model.iter().find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None });
|
||||
assert_eq!(saved.as_deref(), Some("edited\\Psecond line"));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(text_of(&s), "example text");
|
||||
}
|
||||
|
||||
/// A multileader on a locked layer is refused when it is picked, before the editor opens, and
|
||||
/// Properties leaves it alone.
|
||||
#[test]
|
||||
fn a_multileader_on_a_locked_layer_is_refused_when_picked() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("The object is on a locked layer."));
|
||||
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Select an annotation object");
|
||||
s.cancel();
|
||||
let r = s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert_eq!(text_of(&s), "example text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_too_long_for_the_editor_is_asked_for_at_the_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
let long = format!("example text {}", "x".repeat(cadcraft_engine::mtext_edit::MAX_CHARS));
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": long})).unwrap();
|
||||
assert_eq!(text_of(&s), long);
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("This text is too long for the editor; enter the new text at the command line."));
|
||||
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Enter new text");
|
||||
// Enter keeps it whole; a typed answer still replaces it, as for any other text.
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(text_of(&s), long, "the text is not shortened");
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
s.input(Input::Text("short".into())).unwrap();
|
||||
assert_eq!(text_of(&s), "short");
|
||||
s.cancel();
|
||||
}
|
||||
@@ -233,17 +233,3 @@ fn options_on_objects_that_are_gone_record_nothing() {
|
||||
assert!(lines(&s).is_empty());
|
||||
assert_eq!(undo_len(&s), undo, "no empty undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_form_copies_stay_within_maxarray() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// 5,000 Copy options of 30 objects would be 150,000 copies, over the default MAXARRAY of 100,000.
|
||||
let others: Vec<String> = (0..29).map(|i| line(&mut s, [0.0, f64::from(i + 1)], [10.0, f64::from(i + 1)])).collect();
|
||||
let mut hs = vec![l];
|
||||
hs.extend(others);
|
||||
let ops: Vec<Value> = (0..5_000).map(|i| json!({"copy": [0, i]})).collect();
|
||||
let e = s.execute("mocoro", &json!({"handles": hs, "base": [0, 0], "ops": ops})).unwrap_err();
|
||||
assert!(e.to_string().contains("MAXARRAY"), "{e}");
|
||||
assert_eq!(lines(&s).len(), 30, "nothing was copied");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -10,7 +10,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -33,21 +33,13 @@ pub(crate) fn is_mark(c: char) -> bool {
|
||||
}
|
||||
|
||||
/// Base direction of a paragraph from its first strong character (rules P2/P3): `Some(true)`
|
||||
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character. As rule
|
||||
/// P2 requires, characters between an isolate initiator and its matching PDI (or the end of the
|
||||
/// paragraph, when unmatched) are skipped.
|
||||
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character.
|
||||
pub fn base_rtl(s: &str) -> Option<bool> {
|
||||
let mut isolate_depth: usize = 0;
|
||||
for c in s.chars() {
|
||||
match bidi_class(c) {
|
||||
BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => isolate_depth = isolate_depth.saturating_add(1),
|
||||
BidiClass::PDI => isolate_depth = isolate_depth.saturating_sub(1),
|
||||
BidiClass::L if isolate_depth == 0 => return Some(false),
|
||||
BidiClass::R | BidiClass::AL if isolate_depth == 0 => return Some(true),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
s.chars().find_map(|c| match bidi_class(c) {
|
||||
BidiClass::L => Some(false),
|
||||
BidiClass::R | BidiClass::AL => Some(true),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn para_level(base: Option<bool>) -> Option<Level> {
|
||||
@@ -357,24 +349,14 @@ pub fn presentation_forms(chars: &[char], keep: impl Fn(char) -> bool) -> Vec<(c
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let form = forms.get(i).copied().flatten();
|
||||
// Combining marks (harakat) between lam and alef are transparent for joining, so they
|
||||
// don't block the ligature; they stay after it, in logical order.
|
||||
let alef_at =
|
||||
if c == '\u{0644}' { chars.get(i + 1..).and_then(|rest| rest.iter().position(|ch| !is_mark(*ch))).map(|p| i + 1 + p) } else { None };
|
||||
if c == '\u{0644}'
|
||||
&& let Some(f) = form
|
||||
&& let Some(j) = alef_at
|
||||
&& let Some(&a) = chars.get(j)
|
||||
&& let Some(&a) = chars.get(i + 1)
|
||||
&& let Some(lig) = lam_alef(a, matches!(f, Form::Medial | Form::Final))
|
||||
&& keep(lig)
|
||||
{
|
||||
out.push((lig, i));
|
||||
for k in i + 1..j {
|
||||
if let Some(&m) = chars.get(k) {
|
||||
out.push((m, k));
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let shaped = form.and_then(|f| presentation_form(c, f)).filter(|p| keep(*p)).unwrap_or(c);
|
||||
@@ -460,48 +442,6 @@ mod tests {
|
||||
assert!(visual_runs(&[], None).is_empty() && char_levels("", None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_direction_skips_isolate_contents() {
|
||||
// RLI שלום PDI abc: the Hebrew sits inside the isolate, so the first strong character is "a".
|
||||
assert_eq!(base_rtl("\u{2067}שלום\u{2069} abc"), Some(false));
|
||||
assert_eq!(base_rtl("\u{2066}abc\u{2069} שלום"), Some(true));
|
||||
assert_eq!(base_rtl("\u{2068}שלום\u{2069}abc"), Some(false));
|
||||
// Nested isolates: only the outermost PDI closes the outer one.
|
||||
assert_eq!(base_rtl("\u{2066}a\u{2067}ב\u{2069}a\u{2069}ב"), Some(true));
|
||||
assert_eq!(base_rtl("\u{2067}\u{2066}a\u{2069}ב\u{2069}a"), Some(false));
|
||||
// An unmatched initiator isolates the rest of the paragraph.
|
||||
assert_eq!(base_rtl("\u{2067}שלום"), None);
|
||||
assert_eq!(base_rtl("\u{2067}שלום\u{2069}"), None);
|
||||
// A stray PDI does not close anything and does not underflow.
|
||||
assert_eq!(base_rtl("\u{2069}\u{2069}שלום"), Some(true));
|
||||
// No strong character outside the isolates.
|
||||
assert_eq!(base_rtl("12 \u{2066}abc\u{2069} 34"), None);
|
||||
// Without isolates nothing changes.
|
||||
assert_eq!(base_rtl(" 123 سلام abc"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harakat_between_lam_and_alef_keep_the_ligature() {
|
||||
let run = |s: &str| -> Vec<(char, usize)> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
presentation_forms(&chars, |_| true)
|
||||
};
|
||||
// Lam + fatha + alef: the ligature, then the fatha (logical order kept).
|
||||
assert_eq!(run("ل\u{064E}ا"), vec![('\u{FEFB}', 0), ('\u{064E}', 1)]);
|
||||
// Word-medial: س + lam + fatha + shadda + alef gives the final ligature, marks after it.
|
||||
assert_eq!(run("سل\u{064E}\u{0651}ا"), vec![('\u{FEB3}', 0), ('\u{FEFC}', 1), ('\u{064E}', 2), ('\u{0651}', 3)]);
|
||||
// Plain lam + alef is unchanged.
|
||||
assert_eq!(run("لا"), vec![('\u{FEFB}', 0)]);
|
||||
// Mark after the alef is not between them: unchanged behaviour.
|
||||
assert_eq!(run("لا\u{064E}"), vec![('\u{FEFB}', 0), ('\u{064E}', 2)]);
|
||||
// Lam + mark + a non-alef letter: no ligature, the lam joins forward as before.
|
||||
assert_eq!(run("ل\u{064E}م"), vec![('\u{FEDF}', 0), ('\u{064E}', 1), ('\u{FEE2}', 2)]);
|
||||
// Lam + mark at the end: no alef, nothing breaks.
|
||||
assert_eq!(run("ل\u{064E}"), vec![('\u{FEDD}', 0), ('\u{064E}', 1)]);
|
||||
// A non-mark between lam and alef blocks the ligature.
|
||||
assert_eq!(run("ل-ا").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
for s in [
|
||||
|
||||
@@ -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);
|
||||
|
||||
+10
-215
@@ -9,8 +9,6 @@ pub(crate) mod para;
|
||||
/// Strip MTEXT formatting codes to plain text with `\n` paragraph breaks.
|
||||
/// Stacked fractions `\S1/2;` become `1/2`; `\~` is a space; braces are dropped.
|
||||
pub fn plain_mtext(s: &str) -> String {
|
||||
let s = unescape_codes(s);
|
||||
let s = s.as_ref();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -65,7 +63,7 @@ pub fn plain_mtext(s: &str) -> String {
|
||||
i += 7;
|
||||
}
|
||||
// Codes with an argument terminated by ';' (\f, \F, \H, \W, \Q, \T, \A, \C, \c, \p).
|
||||
_ if takes_arg(code) => {
|
||||
_ => {
|
||||
let mut j = i + 2;
|
||||
while let Some(&ch) = chars.get(j) {
|
||||
if ch == ';' {
|
||||
@@ -75,8 +73,6 @@ pub fn plain_mtext(s: &str) -> String {
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
// An unknown code has no argument: drop the backslash and the code only.
|
||||
_ => i += 2,
|
||||
}
|
||||
}
|
||||
'{' | '}' => i += 1,
|
||||
@@ -184,96 +180,11 @@ enum Atom {
|
||||
Para(usize),
|
||||
}
|
||||
|
||||
/// Upper bound on parsed atoms (words, spaces and paragraph breaks) per MTEXT. Hostile contents
|
||||
/// are cut here: once more than this many atoms are parsed, `parse` stops reading and the rest
|
||||
/// of the contents is silently dropped from the layout. There is no warning to the caller.
|
||||
/// Upper bound on parsed atoms (hostile contents).
|
||||
const MAX_ATOMS: usize = 200_000;
|
||||
/// Scale of stacked text relative to the surrounding text.
|
||||
const STACK_SCALE: f64 = 0.7;
|
||||
|
||||
/// Codes that take an argument terminated by `;` (besides `\S`, which has its own scan). Any
|
||||
/// other code stands alone, so a missing `;` must not swallow the text after it.
|
||||
pub(crate) fn takes_arg(code: char) -> bool {
|
||||
matches!(code, 'f' | 'F' | 'H' | 'W' | 'Q' | 'T' | 'A' | 'C' | 'c' | 'p')
|
||||
}
|
||||
|
||||
/// The line-spacing factor to lay out with: AutoCAD's 0.25..=4 range, 1 for a missing or
|
||||
/// non-finite value.
|
||||
pub(crate) fn sane_line_spacing(v: f64) -> f64 {
|
||||
if v.is_finite() && v > 0.0 { v.clamp(0.25, 4.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Four hex digits at `chars[at..at + 4]`.
|
||||
fn hex4(chars: &[char], at: usize) -> Option<u32> {
|
||||
let digits = chars.get(at..at.checked_add(4)?)?;
|
||||
digits.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
|
||||
}
|
||||
|
||||
/// Push a decoded character so that the tokenizers read it as text, never as markup.
|
||||
fn push_literal(out: &mut String, c: char) {
|
||||
if matches!(c, '\\' | '{' | '}') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
/// Decode `\U+hhhh` (one UTF-16 unit; a surrogate pair gives one character, a lone surrogate
|
||||
/// U+FFFD) and `\M+nhhhh` (double-byte East Asian characters) to the characters they stand
|
||||
/// for. Malformed escapes are left for the tokenizers, which drop the backslash and the code.
|
||||
/// An escaped backslash (`\\U+0041`) is not an escape.
|
||||
pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
if !s.contains("\\U+") && !s.contains("\\M+") {
|
||||
return std::borrow::Cow::Borrowed(s);
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (n, plus) = (chars.get(i + 1).copied(), chars.get(i + 2).copied());
|
||||
if n == Some('U') && plus == Some('+') {
|
||||
if let Some(unit) = hex4(&chars, i + 3) {
|
||||
i += 7;
|
||||
let low = |at: usize| {
|
||||
(chars.get(at) == Some(&'\\') && chars.get(at + 1) == Some(&'U') && chars.get(at + 2) == Some(&'+'))
|
||||
.then(|| hex4(&chars, at + 3))
|
||||
.flatten()
|
||||
.filter(|u| (0xDC00..0xE000).contains(u))
|
||||
};
|
||||
let ch = if (0xD800..0xDC00).contains(&unit) {
|
||||
match low(i) {
|
||||
Some(lo) => {
|
||||
i += 7;
|
||||
char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (lo - 0xDC00))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
char::from_u32(unit)
|
||||
};
|
||||
push_literal(&mut out, ch.unwrap_or('\u{fffd}'));
|
||||
continue;
|
||||
}
|
||||
} else if n == Some('M') && plus == Some('+') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && hex4(&chars, i + 4).is_some() {
|
||||
push_literal(&mut out, cadcraft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
// Any other backslash pair stays as it is (and an escaped backslash stays one).
|
||||
out.push('\\');
|
||||
i += 1;
|
||||
if let Some(&next) = chars.get(i) {
|
||||
out.push(next);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn arg(chars: &[char], from: usize) -> (String, usize) {
|
||||
let mut j = from;
|
||||
let mut a = String::new();
|
||||
@@ -325,8 +236,6 @@ fn font_switch(a: &str) -> Option<TextFont> {
|
||||
/// Parse MTEXT contents into words, spaces and paragraph breaks with their formats.
|
||||
fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let h_min = base.height * 1e-3;
|
||||
let h_max = if (base.height * 1e3).is_finite() { base.height * 1e3 } else { f64::MAX };
|
||||
let mut fmts = vec![base];
|
||||
let mut cur = 0usize;
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
@@ -433,22 +342,14 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
None => (false, num(&a)),
|
||||
};
|
||||
if let Some(v) = v.filter(|v| *v > 0.0 && *v < 1e6) {
|
||||
// Relative heights compound; keep the result within 1e-3..1e3 times the
|
||||
// MTEXT's own height, never infinite.
|
||||
derive(&mut fmts, &mut cur, &|f| f.height = if rel { (f.height * v).min(h_max).max(h_min) } else { v });
|
||||
derive(&mut fmts, &mut cur, &|f| f.height = if rel { f.height * v } else { v });
|
||||
}
|
||||
}
|
||||
'W' => {
|
||||
let (a, n) = arg(&chars, i);
|
||||
i = n;
|
||||
// `\W2x;` multiplies the current factor (like `\H2x;`); `\W2;` sets it.
|
||||
let (rel, v) = match a.trim().strip_suffix(['x', 'X']) {
|
||||
Some(r) => (true, num(r)),
|
||||
None => (false, num(&a)),
|
||||
};
|
||||
if let Some(v) = v.filter(|v| *v > 0.01 && *v < 100.0) {
|
||||
// Relative factors compound; keep the result within the absolute range.
|
||||
derive(&mut fmts, &mut cur, &|f| f.wf = if rel { (f.wf * v).clamp(0.01, 100.0) } else { v });
|
||||
if let Some(v) = num(a.trim_end_matches(['x', 'X'])).filter(|v| *v > 0.01 && *v < 100.0) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.wf = v);
|
||||
}
|
||||
}
|
||||
'Q' => {
|
||||
@@ -480,13 +381,11 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.valign = v);
|
||||
}
|
||||
}
|
||||
_ if takes_arg(code) => {
|
||||
// \T and \p: argument up to ';'.
|
||||
_ => {
|
||||
// \T, \p and unknown codes: argument up to ';'.
|
||||
let (_, n) = arg(&chars, i);
|
||||
i = n;
|
||||
}
|
||||
// An unknown code has no argument: only the backslash and the code are dropped.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
'{' => {
|
||||
@@ -793,14 +692,11 @@ fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec<Line
|
||||
/// Lay out MTEXT: inline formatting runs, stacked fractions, paragraphs, word wrap to the
|
||||
/// column width and the attachment point.
|
||||
pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
let contents = unescape_codes(contents);
|
||||
let contents = contents.as_ref();
|
||||
let h = if p.height > 0.0 && p.height.is_finite() { p.height } else { 1.0 };
|
||||
let wf = if p.width_factor.is_finite() && p.width_factor > 0.0 { p.width_factor } else { 1.0 };
|
||||
let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 };
|
||||
let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 };
|
||||
let oblique = if p.oblique.is_finite() { p.oblique } else { 0.0 };
|
||||
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique, valign: 1 };
|
||||
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique, valign: 1 };
|
||||
if para::needed(contents, p) {
|
||||
return para::layout(contents, p, &base);
|
||||
}
|
||||
@@ -823,7 +719,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
for l in &mut lines {
|
||||
l.reorder_bidi(bases.get(l.para).copied().flatten());
|
||||
}
|
||||
let ls = sane_line_spacing(p.line_spacing);
|
||||
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
|
||||
// Baselines below the top of the first line.
|
||||
let mut baselines = Vec::with_capacity(lines.len());
|
||||
let mut y = 0.0;
|
||||
@@ -904,108 +800,7 @@ mod tests {
|
||||
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
|
||||
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
|
||||
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
|
||||
assert_eq!(plain_mtext("\\U+12"), "+12", "a short escape loses only the backslash and the code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_are_decoded_in_every_parser() {
|
||||
// The escape has no `;`, so it must not swallow the rest of the text.
|
||||
assert_eq!(plain_mtext("A\\U+00B0 B C;D"), "A\u{b0} B C;D");
|
||||
assert_eq!(lay("A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
|
||||
// The paragraph path (`\p`) and the plain path agree.
|
||||
assert_eq!(lay("\\pxql;A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
|
||||
let scanned = para::Paragraphs::scan("\\U+00B0\\pi1;x\\U+0041 y;z");
|
||||
assert!(scanned.formatted());
|
||||
// A surrogate pair is one character; a lone surrogate is U+FFFD.
|
||||
assert_eq!(plain_mtext("\\U+D83D\\U+DE00!"), "\u{1f600}!");
|
||||
assert_eq!(lay("\\U+D83D\\U+DE00!", 0.0).lines, vec!["\u{1f600}!"]);
|
||||
assert_eq!(plain_mtext("a\\U+D83Db"), "a\u{fffd}b");
|
||||
assert_eq!(plain_mtext("a\\U+DE00b\\U+D83D"), "a\u{fffd}b\u{fffd}");
|
||||
// A decoded backslash or brace is text, not markup; an escaped backslash is not an escape.
|
||||
assert_eq!(plain_mtext("\\U+005CH2;x"), "\\H2;x");
|
||||
assert_eq!(plain_mtext("\\\\U+0041"), "\\U+0041");
|
||||
// `\M+n` double-byte characters, decoded like the DXF reader does.
|
||||
assert_eq!(plain_mtext("\\M+5BCFE x;y"), "\u{4ef6} x;y");
|
||||
// Malformed escapes lose only the backslash and the code.
|
||||
assert_eq!(plain_mtext("a\\U+12 b"), "a+12 b");
|
||||
assert_eq!(lay("a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(lay("\\pxql;a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(plain_mtext("a\\M+ b"), "a+ b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_code_without_semicolon_keeps_the_text() {
|
||||
assert_eq!(plain_mtext("A\\Zbc d"), "Abc d");
|
||||
assert_eq!(lay("A\\Zbc d", 0.0).lines, vec!["Abc d"]);
|
||||
assert_eq!(lay("\\pxql;A\\Zbc d", 0.0).lines, vec!["Abc d"]);
|
||||
// A trailing lone backslash is dropped.
|
||||
assert_eq!(plain_mtext("A\\"), "A");
|
||||
// Codes with an argument still take it up to the `;`, or to the end.
|
||||
assert_eq!(plain_mtext("A\\H2;B"), "AB");
|
||||
assert_eq!(plain_mtext("A\\H2 B"), "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_oblique_and_line_spacing_are_repaired() {
|
||||
let run = |oblique: f64, spacing: f64| {
|
||||
let p = MTextParams { oblique, line_spacing: spacing, ..MTextParams::new(Vec2::ZERO, 1.0) };
|
||||
layout_mtext_with("AB\\PCD", &p)
|
||||
};
|
||||
let finite =
|
||||
|l: &MTextLayout| l.bounds.min.x.is_finite() && l.bounds.max.x.is_finite() && l.bounds.min.y.is_finite() && l.bounds.max.y.is_finite();
|
||||
let reference = run(0.0, 1.0);
|
||||
for o in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let l = run(o, 1.0);
|
||||
assert!(finite(&l), "oblique {o}");
|
||||
assert_eq!(l.bounds, reference.bounds, "oblique {o}");
|
||||
}
|
||||
for s in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -2.0] {
|
||||
let l = run(0.0, s);
|
||||
assert!(finite(&l), "spacing {s}");
|
||||
assert_eq!(l.bounds, reference.bounds, "spacing {s}");
|
||||
}
|
||||
// Spacing is clamped to AutoCAD's 0.25..=4 range.
|
||||
assert_eq!(run(0.0, 1e9).bounds, run(0.0, 4.0).bounds);
|
||||
assert_eq!(run(0.0, 1e-9).bounds, run(0.0, 0.25).bounds);
|
||||
assert!(run(0.0, 4.0).bounds.height() > reference.bounds.height());
|
||||
// The paragraph layout takes the same path.
|
||||
let p = MTextParams { oblique: f64::NAN, line_spacing: f64::NAN, ..MTextParams::new(Vec2::ZERO, 1.0) };
|
||||
assert!(finite(&layout_mtext_with("\\pxql;AB\\PCD", &p)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_heights_do_not_run_away() {
|
||||
let src = format!("{}A", "\\H999999x;".repeat(60));
|
||||
let l = lay(&src, 0.0);
|
||||
assert!(l.bounds.height().is_finite() && l.bounds.width().is_finite());
|
||||
assert!(l.bounds.height() <= 1e3 * 1.5, "{}", l.bounds.height());
|
||||
assert!(l.bounds.height() > 100.0, "the glyph must not vanish: {}", l.bounds.height());
|
||||
// Shrinking is bounded too.
|
||||
let tiny = lay(&format!("{}A", "\\H0.000001x;".repeat(60)), 0.0);
|
||||
assert!(tiny.bounds.height() > 0.0);
|
||||
}
|
||||
|
||||
/// The width factor of the last format `parse` derived.
|
||||
fn last_wf(contents: &str, base_wf: f64) -> f64 {
|
||||
let base = Fmt { font: TextFont::Stroke, height: 1.0, color: None, under: false, over: false, wf: base_wf, oblique: 0.0, valign: 1 };
|
||||
parse(contents, base).1.last().map_or(f64::NAN, |f| f.wf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_factor_x_is_relative() {
|
||||
assert_eq!(last_wf("\\W2;a", 1.5), 2.0, "absolute sets the factor");
|
||||
assert_eq!(last_wf("\\W2x;a", 1.5), 3.0, "x multiplies the current factor");
|
||||
assert_eq!(last_wf("\\W2;\\W2x;a", 1.0), 4.0, "relative builds on the last absolute");
|
||||
assert_eq!(last_wf("\\W0.5X;a", 1.0), 0.5, "capital X too");
|
||||
// Hostile: a long relative chain stays inside the absolute range.
|
||||
let chain = "\\W99x;".repeat(50);
|
||||
assert_eq!(last_wf(&chain, 1.0), 100.0);
|
||||
let down = "\\W0.02x;".repeat(50);
|
||||
assert_eq!(last_wf(&down, 1.0), 0.01);
|
||||
// Out of range or not a number: ignored.
|
||||
assert_eq!(last_wf("\\W1e999x;a", 1.5), 1.5);
|
||||
assert_eq!(last_wf("\\WNaNx;a", 1.5), 1.5);
|
||||
assert_eq!(last_wf("\\Wx;a", 1.5), 1.5);
|
||||
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -264,7 +264,6 @@ pub struct Paragraphs {
|
||||
|
||||
impl Paragraphs {
|
||||
pub fn scan(contents: &str) -> Paragraphs {
|
||||
let contents = super::unescape_codes(contents);
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut out = Paragraphs { states: vec![ParaProps::default()], ..Default::default() };
|
||||
let mut cur = 0usize;
|
||||
@@ -304,12 +303,10 @@ impl Paragraphs {
|
||||
cur = out.states.len() - 1;
|
||||
}
|
||||
}
|
||||
_ if super::takes_arg(code) => {
|
||||
_ => {
|
||||
let (_, n) = arg(&chars, i);
|
||||
i = n;
|
||||
}
|
||||
// An unknown code has no argument: only the backslash and the code go.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
'\n' => {
|
||||
@@ -607,7 +604,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
|
||||
let lines = LineBuilder::new(¶s, &fmts, base, width, h).build(&atoms);
|
||||
|
||||
// Vertical placement: baselines below the top of each line's column.
|
||||
let ls = super::sane_line_spacing(p.line_spacing);
|
||||
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
|
||||
let cols = p.columns.as_ref().filter(|_| width > 0.0);
|
||||
let max_cols = match cols {
|
||||
Some(c) if c.dynamic => MAX_COLUMNS,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user