2 Commits

Author SHA1 Message Date
Marcos Paulo d8ee4b0ac0 feat(ui): Catppuccin themes; use the masi/craft-libs fork
Adds Latte, Frappé, Macchiato and Mocha to the interface themes (Options, View >
Interface Theme, ui.theme command). craft-ui now comes from the masi/craft-libs fork.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 03:11:45 -03:00
Marcos Paulo a918f32ae2 chore: debrand ArtCraft and Discord references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 02:57:05 -03:00
135 changed files with 883 additions and 12109 deletions
+23
View File
@@ -0,0 +1,23 @@
# Strip upstream (ArtCraft / Discord) branding. Run: ~/.agents/skills/debrand/debrand.pl (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^crates/ui-egui/src/i18n/.*\.tsv$ :: \t.*(?:ArtCraft|Discord)
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub (?s)\s*The ArtCraft name, wordmark, and logos in `docs/brand/`.*?source-code license\. =>
sub \s*The ArtCraft logos in `docs/brand/` are not open source \(`docs/brand/LICENSE-brand\.txt`\)\. =>
sub drawn in ArtCraft, vectorised => drawn by hand, vectorised
sub created\s+in ArtCraft, then => created by hand, then
sub made in ArtCraft \( => drawn by hand (
sub the owner's ArtCraft drawing => the owner's drawing
sub the ArtCraft team and CADCraft contributors => the CADCraft contributors
sub ArtCraft, ArtCraftX,\s =>
sub \bArtCraft, =>
sub https://getartcraft\.com/apps/cadcraft => https://github.com/storytold/cadcraft
sub https://discord\.gg/artcraft => https://github.com/storytold/cadcraft/issues
sub \s*The only exception is the ArtCraft brand in `docs/brand/` \(trademarks, `docs/brand/LICENSE-brand\.txt`\)\. =>
sub (?s)\s*The one\nexception is the ArtCraft brand in `docs/brand/`: ArtCraft trademarks, not open source, used under\n`docs/brand/LICENSE-brand\.txt`\. =>
forbid (?i)artcraft(?! Team and the CADCraft contributors)
forbid (?i)discord
sub Discord \(Help › Discord\), then the theme toggle => the theme toggle
-7
View File
@@ -24,7 +24,6 @@ jobs:
with:
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -35,9 +34,3 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
+1 -1
View File
@@ -23,7 +23,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
## 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.
- **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). 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.
+1 -15
View File
@@ -9,9 +9,7 @@ artwork, fonts, hatch patterns, linetypes, templates or presets.** Every asset i
CADCraft contributors or third-party material under an open licence (OSI open source, public domain
/ CC0, or Creative Commons that allows redistribution). Screenshots of Autodesk software are never
committed. Font files are not added here: shared fonts live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input. The one
exception is the ArtCraft brand in `docs/brand/`: ArtCraft trademarks, not open source, used under
`docs/brand/LICENSE-brand.txt`.
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input.
Generated-in-code assets are original and have no file to list:
- the UI icon set (`crates/ui-egui/src/icons.rs`);
@@ -43,14 +41,6 @@ Generated-in-code assets are original and have no file to list:
| `docs/images/ui-apartment.png` | CADCraft contributors | screenshot of CADCraft itself (examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/images/ui-layout.png` | CADCraft contributors | screenshot of CADCraft itself (a layout of examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/images/ui-bracket.png` | CADCraft contributors | screenshot of CADCraft itself (sample drawing generated in code) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
## Interface translations
@@ -60,7 +50,3 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
+1 -1
View File
@@ -23,7 +23,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
## 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.
- **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). 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.
Generated
+17 -93
View File
@@ -433,12 +433,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "basic-udev"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -546,14 +540,13 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-ui-egui",
"eframe",
"egui",
"hidapi",
"log",
"muda",
"rfd",
@@ -561,12 +554,11 @@ dependencies = [
"sys-locale",
"winit",
"winresource",
"zbus",
]
[[package]]
name = "cadcraft-cli"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -577,14 +569,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -594,7 +586,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -605,14 +597,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"encoding_rs",
"log",
@@ -621,7 +613,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -640,7 +632,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-geom",
"harfrust",
@@ -650,14 +642,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -667,7 +659,6 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"miniz_oxide 0.8.9",
"quick-xml",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -675,7 +666,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -686,21 +677,19 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-fonts",
"cadcraft-geom",
"lyon_tessellation",
"rayon",
"serde_json",
"tiny-skia",
]
[[package]]
name = "cadcraft-ui-egui"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -721,7 +710,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -925,7 +914,7 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
dependencies = [
"egui",
"serde",
@@ -1401,12 +1390,6 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1759,21 +1742,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hidapi"
version = "2.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6"
dependencies = [
"basic-udev",
"cc",
"cfg-if",
"libc",
"nix",
"pkg-config",
"windows-sys 0.61.2",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -2151,38 +2119,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lyon_geom"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
dependencies = [
"arrayvec",
"euclid",
"num-traits",
]
[[package]]
name = "lyon_path"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
dependencies = [
"lyon_geom",
"num-traits",
]
[[package]]
name = "lyon_tessellation"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005"
dependencies = [
"float_next_after",
"lyon_path",
"num-traits",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2336,18 +2272,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -4772,7 +4696,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"serde_json",
]
+2 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.6.2"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -34,7 +34,6 @@ rayon = "1"
# rendering / formats
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
lyon_tessellation = "1.0.22"
skrifa = "0.47"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses)
# and the Unicode Bidirectional Algorithm for right-to-left drawing text.
@@ -42,11 +41,9 @@ harfrust = "0.12"
unicode-bidi = "0.3.18"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.41"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
egui = "0.36"
egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
-4
View File
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the CADCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
Software dependencies are listed in Cargo.lock. One is under a weak-copyleft licence: the acadrust
+13 -72
View File
@@ -1,12 +1,3 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">CADCraft</h1>
<p align="center">
@@ -16,8 +7,7 @@
<p align="center">
A fast, open-source take on the AutoCAD workflow: the command line, object snaps, layers,
dimensions, hatches, blocks and DXF drawings you already know. It runs natively on macOS,
Windows, Linux and FreeBSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
Windows, Linux and FreeBSD, and in the browser via WebAssembly.
</p>
<p align="center">
@@ -28,16 +18,6 @@
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f07a3a?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/cadcraft"><b>CADCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<br>
<p align="center">
@@ -45,11 +25,6 @@
<br><sub><b>Apartment plan</b> (<a href="examples/apartment.dxf">examples/apartment.dxf</a>): walls with pick-point hatching, TrueType MTEXT room labels, a TABLE, a multileader and architectural dimensions — built entirely from CADCraft commands.</sub>
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#screenshots">Screenshots</a> ·
<a href="#why-cadcraft">Why CADCraft</a> ·
@@ -111,7 +86,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) and Simplified Chinese (简体中文, with an installed CJK font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Interface languages | English, Ukrainian (Українська) and German (Deutsch), 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
@@ -322,44 +297,20 @@ RISC-V (riscv64): `cadcraft-<ver>-linux-riscv64.tar.gz`, cross-compiled; needs g
## The Crafting Apps
CADCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
CADCraft is one of the **Crafting Apps**: free, open-source creative tools, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/cadcraft-64.png" alt="" width="32" height="32"> | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/cadcraft-64.png" alt="" width="32" height="32"> | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/cadcraft">CADCraft</a>
</p>
## License and credits
@@ -372,16 +323,6 @@ with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
CADCraft's icons, its single-stroke drafting font, its hatch patterns and its linetypes are all
original work, drawn or defined in code. The sample drawings are generated in code too.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and CADCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Autodesk, AutoCAD and DWG are trademarks or registered trademarks of Autodesk, Inc. in the United States and/or other countries. CADCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc.; these names are used only to describe the workflows and file formats it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+1 -46
View File
@@ -7,7 +7,6 @@
//! cadcraft-cli commands [FILTER] command catalog (JSON)
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -61,7 +60,7 @@ const USAGE: &str = "usage:
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
cadcraft-cli commands [FILTER]
cadcraft-cli mcp [--connect HOST:PORT]
cadcraft-cli perf [N | FILE]
cadcraft-cli perf [N]
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
cadcraft-cli --version
@@ -394,55 +393,11 @@ fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, Strin
Ok(dr)
}
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
fn perf_file(path: &str) -> Result<(), String> {
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::render::{Options, build};
use std::time::Instant;
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let mut s = Session::empty();
let t = Instant::now();
open(&mut s, path)?;
let open_ms = ms(t);
let st = s.state().map_err(|e| e.to_string())?;
let (d, space) = (&st.doc, &st.space);
let ext = d.extents(space);
let size = ext.width().max(ext.height());
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
return Err("perf: the drawing is empty".into());
}
outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height());
outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims");
let mut finest = Options::default();
for k in 0..5 {
let zoom = 4f64.powi(k);
let h = size / zoom;
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
let half = Vec2::new(h * 0.8, h * 0.5);
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
let t = Instant::now();
let n = build(d, space, &screen).prims.len();
outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t));
finest = screen;
}
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
let t = Instant::now();
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t));
Ok(())
}
fn perf(args: &[String]) -> Result<(), String> {
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{select, snap};
use std::time::Instant;
if let Some(path) = args.first().filter(|a| a.parse::<usize>().is_err()) {
return perf_file(path);
}
let n: usize = match args.first() {
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
None => 200_000,
-11
View File
@@ -24,10 +24,6 @@ winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
# View ▸ Theme ▸ System: the desktop's light/dark choice from the XDG desktop portal
# (desktop_theme.rs). Already in Cargo.lock through accesskit; pinned to that version so nothing
# is built twice.
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -35,10 +31,3 @@ sys-locale = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
+11 -149
View File
@@ -1,25 +1,14 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use cadcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry
/// whole drawings or images as base64 still fit.
const MAX_LINE: usize = 256 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
@@ -30,82 +19,25 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
rx
}
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
/// its thread can't be started, or panics).
struct Slot(Arc<AtomicUsize>);
impl Drop for Slot {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot;
serve(stream, tx, ctx);
});
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(true) => {}
Ok(false) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
}
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
let Ok(line) = std::str::from_utf8(&buf) else { break };
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(line) {
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
@@ -130,73 +62,3 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
}
#[test]
fn an_overlong_line_is_refused_without_reading_the_rest() {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
}
});
addr
}
#[test]
fn requests_are_answered_with_their_ids() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
}
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}
-174
View File
@@ -1,174 +0,0 @@
//! The desktop's own light/dark choice, for View ▸ Theme ▸ System.
//!
//! winit reports no system theme on Linux and the BSDs (`RawInput::system_theme` stays `None`),
//! so the System choice fell back to dark whatever the desktop said. This asks the desktop itself:
//! the XDG Desktop Portal's Settings interface, which answers on GNOME, KDE and XFCE alike, over
//! X11 and Wayland alike. The answer is handed to egui as the system theme
//! (`App::raw_input_hook`), so the app and egui's own widgets follow it as they follow macOS and
//! Windows.
//!
//! Ported from PdfCraft (storytold/pdfcraft#881).
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::time::Duration;
/// How often the watcher re-reads the desktop's answer. The portal also publishes a change
/// signal, but subscribing needs a connection kept alive for the life of the window; one round
/// trip every few seconds is cheap and reacts soon enough after a theme switch.
const POLL: Duration = Duration::from_secs(4);
/// Watches the desktop and reports its choice when it changes.
#[derive(Default)]
pub struct DesktopTheme {
/// `None` when no watcher thread could be started (or on a platform where winit answers).
changes: Option<Receiver<Option<egui::Theme>>>,
/// The desktop's last answer.
current: Option<egui::Theme>,
}
impl DesktopTheme {
/// Start watching. The first answer is sent straight away; `wake` is called after each
/// answer, so an idle window repaints and picks it up.
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
if !ASKS_THE_DESKTOP {
return Self::default();
}
let (tx, rx) = channel();
let started = std::thread::Builder::new().name("cadcraft-desktop-theme".into()).spawn(move || {
let mut last = read();
if tx.send(last).is_err() {
return;
}
wake();
loop {
std::thread::sleep(POLL);
let now = read();
if now != last {
last = now;
if tx.send(now).is_err() {
return;
}
wake();
}
}
});
Self { changes: started.ok().map(|_| rx), current: None }
}
/// The desktop's choice, `None` when it states none or there is nobody to ask (no session
/// bus, no portal). Polled once a frame; a desktop that stops answering keeps the last answer.
pub fn current(&mut self) -> Option<egui::Theme> {
if let Some(changes) = &self.changes {
loop {
match changes.try_recv() {
Ok(Some(theme)) => self.current = Some(theme),
Ok(None) => {}
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
}
self.current
}
}
/// Where winit has no answer and the portal does.
const ASKS_THE_DESKTOP: bool = cfg!(all(unix, not(target_os = "macos")));
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
/// thread is its only caller.
#[cfg(all(unix, not(target_os = "macos")))]
fn read() -> Option<egui::Theme> {
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
// The standard key first (GNOME and KDE answer it: 0 no preference, 1 dark, 2 light); then
// GNOME's own schema, which XFCE's portal proxies without the standard key.
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "color-scheme"), ("org.gnome.desktop.interface", "color-scheme")];
let connection = zbus::blocking::Connection::session().ok()?;
for (namespace, key) in KEYS {
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
// wraps it in one more variant. `parse` unwraps either.
let reply = connection
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
let Ok(reply) = reply else { continue };
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
if let Some(scheme) = parse(&answer, 0) {
return scheme;
}
}
None
}
/// The portal's `color-scheme` answer: `Some(choice)` when it read as an answer (`Some(None)`
/// for "no preference"), `None` when it didn't. Variants are unwrapped a few levels deep.
#[cfg(all(unix, not(target_os = "macos")))]
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<Option<egui::Theme>> {
use zbus::zvariant::Value;
match value {
Value::U32(1) => Some(Some(egui::Theme::Dark)),
Value::U32(2) => Some(Some(egui::Theme::Light)),
Value::U32(_) => Some(None),
Value::Str(s) => Some(match s.as_str() {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}),
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
_ => None,
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn read() -> Option<egui::Theme> {
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The standard key answers a number, GNOME's schema a string; either may come wrapped in
/// variants (the older `Read`). Only "dark" and "light" are a choice.
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn the_portal_answer_reads_as_a_choice_or_as_none() {
use zbus::zvariant::Value;
assert_eq!(parse(&Value::U32(1), 0), Some(Some(egui::Theme::Dark)));
assert_eq!(parse(&Value::U32(2), 0), Some(Some(egui::Theme::Light)));
assert_eq!(parse(&Value::U32(0), 0), Some(None));
assert_eq!(parse(&Value::U32(7), 0), Some(None));
assert_eq!(parse(&Value::from("prefer-dark"), 0), Some(Some(egui::Theme::Dark)));
assert_eq!(parse(&Value::from("prefer-light"), 0), Some(Some(egui::Theme::Light)));
assert_eq!(parse(&Value::from("default"), 0), Some(None));
let wrapped = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1)))));
assert_eq!(parse(&wrapped, 0), Some(Some(egui::Theme::Dark)));
assert_eq!(parse(&Value::Bool(true), 0), None);
let mut deep = Value::U32(1);
for _ in 0..10 {
deep = Value::Value(Box::new(deep));
}
assert_eq!(parse(&deep, 0), None, "nesting is bounded");
}
/// A watcher that was never started is inert rather than a panic.
#[test]
fn a_watcher_without_a_thread_is_inert() {
assert_eq!(DesktopTheme::default().current(), None);
}
/// Answers arrive as changes; "no preference" or silence keeps the last real answer.
#[test]
fn the_last_answer_stands_until_a_new_one() {
let (tx, rx) = channel();
let mut watcher = DesktopTheme { changes: Some(rx), current: None };
assert_eq!(watcher.current(), None);
tx.send(Some(egui::Theme::Light)).unwrap();
assert_eq!(watcher.current(), Some(egui::Theme::Light));
tx.send(Some(egui::Theme::Dark)).unwrap();
tx.send(None).unwrap();
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
drop(tx);
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
}
}
+1 -47
View File
@@ -15,13 +15,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod desktop_theme;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod spacemouse;
mod workspace;
use cadcraft_engine::Session;
@@ -29,9 +27,6 @@ use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App {
cad: CadApp,
spacemouse: spacemouse::SpaceMouse,
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
desktop_theme: desktop_theme::DesktopTheme,
workspace: workspace::Workspace,
workspace_error: Option<String>,
#[cfg(target_os = "macos")]
@@ -53,14 +48,6 @@ impl App {
}
}
/// View ▸ Theme ▸ System resolves against `raw.system_theme`, which winit leaves empty on Linux
/// and the BSDs: the desktop's answer, where it gives one, stands in for it.
fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
if desktop.is_some() {
raw.system_theme = desktop;
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
@@ -73,14 +60,12 @@ impl eframe::App for App {
}
}
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
@@ -122,7 +107,6 @@ fn services() -> Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
.add_filter(i18n::t("All files"), &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
@@ -256,17 +240,6 @@ fn main() -> eframe::Result {
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
// Command previews (COPY, MOVE...) of large selections are tessellated by egui, whose index
// buffer outgrew wgpu's default 256 MiB cap; the validation error is fatal. Ask for what the
// adapter actually supports.
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
let base = std::sync::Arc::clone(&create.device_descriptor);
create.device_descriptor = std::sync::Arc::new(move |adapter| {
let mut d = base(adapter);
d.required_limits.max_buffer_size = d.required_limits.max_buffer_size.max(adapter.limits().max_buffer_size);
d
});
}
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
// keeps the native Wayland backend.
@@ -325,12 +298,8 @@ fn main() -> eframe::Result {
if app.session.docs.is_empty() {
app.session.new_drawing(false);
}
let ctx = cc.egui_ctx.clone();
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
Ok(Box::new(App {
desktop_theme,
cad: app,
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
workspace,
workspace_error,
#[cfg(target_os = "macos")]
@@ -342,7 +311,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{config_dir_from, control_port_from, filters_for_save, follow_desktop};
use super::{config_dir_from, control_port_from, filters_for_save};
use std::ffi::OsString;
use std::path::PathBuf;
@@ -408,19 +377,4 @@ mod tests {
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
);
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
#[test]
fn the_desktop_answers_for_the_system_theme() {
let mut raw = egui::RawInput::default();
follow_desktop(&mut raw, Some(egui::Theme::Light));
assert_eq!(raw.system_theme, Some(egui::Theme::Light));
let mut raw = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
follow_desktop(&mut raw, None);
assert_eq!(raw.system_theme, Some(egui::Theme::Dark));
let mut raw = egui::RawInput::default();
follow_desktop(&mut raw, None);
assert_eq!(raw.system_theme, None);
}
}
+5 -11
View File
@@ -35,7 +35,6 @@ impl NativeMenu {
let mut counter = 0usize;
let app_menu = Submenu::new("CADCraft", true);
let about = MenuItem::with_id("cc-about", i18n::tr(language, "About CADCraft"), true, None);
let discord = MenuItem::with_id("cc-discord", i18n::tr(language, "Join the ArtCraft Discord…"), true, None);
let predefined = vec![
("Services", PredefinedMenuItem::services(Some(i18n::tr(language, "Services")))),
("Hide CADCraft", PredefinedMenuItem::hide(Some(i18n::tr(language, "Hide CADCraft")))),
@@ -45,7 +44,7 @@ impl NativeMenu {
];
// The Mac place for preferences: CADCraft ▸ Settings… (⌘,) opens the Options dialog.
let settings = MenuItem::with_id("cc-settings", i18n::tr(language, "Settings..."), true, accel("Cmd+Comma"));
let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator()]);
let _ = app_menu.append_items(&[&about, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator()]);
for (label, item) in &predefined {
let _ = app_menu.append(item);
if ["Services", "Show All"].contains(label) {
@@ -53,11 +52,10 @@ impl NativeMenu {
}
}
items.insert("cc-about".into(), ("ui.dialog.about".into(), "About CADCraft".into(), about));
items.insert("cc-discord".into(), ("ui.discord".into(), "Join the ArtCraft Discord…".into(), discord));
items.insert("cc-settings".into(), ("options".into(), "Settings...".into(), settings));
let _ = menu.append(&app_menu);
for (title, entries) in menus::tree(app) {
let sub = Submenu::new(i18n::tr_menu(language, &title), true);
let sub = Submenu::new(i18n::tr(language, &title), true);
build(&sub, &entries, &mut items, &mut submenus, &mut counter, language);
let _ = menu.append(&sub);
submenus.push((title, sub));
@@ -69,11 +67,7 @@ impl NativeMenu {
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
if cmd == "ui.discord" {
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
} else {
menus::activate(app, &cmd.clone());
}
menus::activate(app, &cmd.clone());
ctx.request_repaint();
}
}
@@ -87,7 +81,7 @@ impl NativeMenu {
item.set_text(i18n::tr(language, label));
}
for (label, submenu) in &self.submenus {
submenu.set_text(i18n::tr_menu(language, label));
submenu.set_text(i18n::tr(language, label));
}
}
let now = cadcraft_ui_egui::now_ms();
@@ -113,7 +107,7 @@ fn build(
for e in entries {
match e {
Entry::Sub { label, children } => {
let sub = Submenu::new(i18n::tr_menu(language, label), true);
let sub = Submenu::new(i18n::tr(language, label), true);
build(&sub, children, items, submenus, counter, language);
let _ = parent.append(&sub);
submenus.push((label.clone(), sub));
-242
View File
@@ -1,242 +0,0 @@
//! Native 3Dconnexion input. The reader publishes one latest sample, never an input queue;
//! only movement starting/stopping or status/buttons wake egui. Active motion runs at 60 Hz.
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod motion;
use cadcraft_ui_egui::{CadApp, spacemouse::Status};
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::time::Duration;
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Instant,
};
#[derive(Default)]
struct Shared {
status: Status,
device: Option<String>,
detail: Option<String>,
#[cfg(any(target_os = "windows", target_os = "linux"))]
motion: motion::Motion,
#[cfg(any(target_os = "windows", target_os = "linux"))]
last_report: Option<Instant>,
buttons: u8,
recalibrate: bool,
}
pub struct SpaceMouse {
shared: Arc<Mutex<Shared>>,
stop: Arc<AtomicBool>,
last_frame: Instant,
#[cfg(any(target_os = "windows", target_os = "linux"))]
moving: bool,
}
impl SpaceMouse {
pub fn start(ctx: egui::Context) -> Self {
let shared = Arc::new(Mutex::new(Shared::default()));
let stop = Arc::new(AtomicBool::new(false));
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
let (input, quit, context) = (Arc::clone(&shared), Arc::clone(&stop), ctx.clone());
if let Err(error) = std::thread::Builder::new().name("spacemouse-hid".into()).spawn(move || read(input, quit, context)) {
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
let _ = ctx;
Self {
shared,
stop,
last_frame: Instant::now(),
#[cfg(any(target_os = "windows", target_os = "linux"))]
moving: false,
}
}
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
let Ok(mut shared) = self.shared.lock() else { return };
if std::mem::take(&mut app.spacemouse.calibrate_requested) {
shared.recalibrate = true;
}
let changed = app.spacemouse.status != shared.status || app.spacemouse.device != shared.device || app.spacemouse.detail != shared.detail;
app.spacemouse.status = shared.status;
app.spacemouse.device.clone_from(&shared.device);
app.spacemouse.detail.clone_from(&shared.detail);
if changed && !matches!(shared.status, Status::Unavailable | Status::Searching) {
app.set_status(cadcraft_ui_egui::i18n::tr(app.language(), shared.status.message()));
}
let buttons = std::mem::take(&mut shared.buttons);
#[cfg(any(target_os = "windows", target_os = "linux"))]
let motion = if shared.last_report.is_some_and(|at| now.saturating_duration_since(at) < Duration::from_millis(250)) {
shared.motion
} else {
motion::Motion::default()
};
drop(shared);
let allowed = app.spacemouse.settings.enabled
&& ctx.input(|i| i.focused)
&& !app.ui.start_tab
&& app.ui.dialog.is_none()
&& app.closing.is_none()
&& app.canvas.rect.is_some()
&& !egui::Popup::is_any_open(ctx);
if allowed && buttons & 1 != 0 {
let _ = app.run("zoom", serde_json::json!({"mode": "extents"}));
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
let moving = allowed && motion.active();
if moving {
if self.moving {
let settings = app.spacemouse.settings;
let rate = if settings.reverse_zoom { -settings.zoom_speed } else { settings.zoom_speed };
if let Err(error) = app.run(
"ui.spacemouse.navigate",
serde_json::json!({
"pan": motion.pan.map(|v| v * settings.pan_speed * elapsed),
"zoom": (motion.zoom * rate * elapsed).exp()
}),
) {
app.set_status(error);
}
}
ctx.request_repaint_after(Duration::from_millis(16));
}
self.moving = moving;
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
let _ = elapsed;
}
}
impl Drop for SpaceMouse {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn set_status(shared: &Mutex<Shared>, ctx: &egui::Context, status: Status, device: Option<String>, detail: Option<String>) {
let Ok(mut state) = shared.lock() else { return };
let changed = state.status != status || state.device != device || state.detail != detail;
if !changed {
return;
}
if let Some(error) = &detail {
log::warn!("3D mouse: {error}");
}
state.status = status;
state.device = device;
state.detail = detail;
state.motion = motion::Motion::default();
state.last_report = None;
state.buttons = 0;
drop(state);
ctx.request_repaint();
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn retry(stop: &AtomicBool) {
for _ in 0..40 {
if stop.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn read(shared: Arc<Mutex<Shared>>, stop: Arc<AtomicBool>, ctx: egui::Context) {
while !stop.load(Ordering::Relaxed) {
let mut api = match hidapi::HidApi::new() {
Ok(api) => api,
Err(error) => {
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
retry(&stop);
continue;
}
};
while !stop.load(Ordering::Relaxed) {
if let Err(error) = api.refresh_devices() {
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
break;
}
let device =
api.device_list().find(|device| matches!(device.vendor_id(), 0x046d | 0x256f) && device.usage_page() == 1 && device.usage() == 8);
let Some(info) = device else {
set_status(&shared, &ctx, Status::Searching, None, None);
retry(&stop);
continue;
};
let name = info.product_string().unwrap_or("3Dconnexion SpaceMouse").to_string();
match info.open_device(&api) {
Ok(device) => {
set_status(&shared, &ctx, Status::Calibrating, Some(name.clone()), None);
if let Err(error) = read_device(&device, &shared, &stop, &ctx, &name) {
set_status(&shared, &ctx, Status::Disconnected, Some(name), Some(error.to_string()));
}
}
Err(error) => set_status(&shared, &ctx, Status::OpenFailed, Some(name), Some(error.to_string())),
}
retry(&stop);
}
retry(&stop);
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn read_device(device: &hidapi::HidDevice, shared: &Mutex<Shared>, stop: &AtomicBool, ctx: &egui::Context, name: &str) -> hidapi::HidResult<()> {
let mut filter = motion::Filter::default();
let mut buffer = [0; 64];
let mut buttons = 0;
while !stop.load(Ordering::Relaxed) {
let recalibrate = if let Ok(mut state) = shared.lock() { std::mem::take(&mut state.recalibrate) } else { return Ok(()) };
if recalibrate {
filter.recalibrate();
set_status(shared, ctx, Status::Calibrating, Some(name.into()), None);
}
let len = device.read_timeout(&mut buffer, 50)?;
let now = Instant::now();
let report = buffer.get(..len).and_then(motion::decode);
let Ok(mut state) = shared.lock() else { return Ok(()) };
let mut wake = false;
match report {
Some(motion::Report::Buttons(next)) => {
let pressed = next & !buttons;
buttons = next;
state.buttons |= pressed & 1;
state.recalibrate |= pressed & 2 != 0;
wake = pressed != 0;
}
Some(motion::Report::Axes { values, mask }) => {
let motion = filter.update(values, mask, now);
wake = state.motion.active() != motion.active();
state.motion = motion;
state.last_report = Some(now);
if filter.calibrated() && state.status != Status::Connected {
state.status = Status::Connected;
wake = true;
}
}
None => {
if state.motion.active() && state.last_report.is_some_and(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
state.motion = motion::Motion::default();
wake = true;
}
}
}
drop(state);
if wake {
ctx.request_repaint();
}
}
Ok(())
}
-158
View File
@@ -1,158 +0,0 @@
//! Bounded, dependency-free decoding and rest calibration of 3Dconnexion HID input reports.
use std::time::{Duration, Instant};
const FULL_SCALE: f64 = 500.0;
const DEADZONE: f64 = 0.04;
const REST_LIMIT: f64 = 60.0;
const REST_RANGE: f64 = 10.0;
const CALIBRATION_TIME: Duration = Duration::from_millis(400);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Report {
/// Mask 1 = translation, 2 = rotation, 3 = a combined six-axis report.
Axes {
values: [i16; 6],
mask: u8,
},
Buttons(u8),
}
/// hidapi includes the report ID. A legacy motion report has six payload bytes; combined
/// report 1 has twelve. Windows may pad the short button report to the input-report size.
pub fn decode(bytes: &[u8]) -> Option<Report> {
let (&id, payload) = bytes.split_first()?;
if id == 3 {
return payload.first().copied().map(Report::Buttons);
}
let (mask, offset, count) = match id {
1 if payload.len() >= 12 => (3, 0, 6),
1 if payload.len() >= 6 => (1, 0, 3),
2 if payload.len() >= 6 => (2, 3, 3),
_ => return None,
};
let mut values = [0; 6];
for (out, pair) in values.iter_mut().skip(offset).take(count).zip(payload.as_chunks::<2>().0.iter()) {
*out = i16::from_le_bytes([*pair.first()?, *pair.get(1)?]);
}
Some(Report::Axes { values, mask })
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Motion {
pub pan: [f64; 2],
pub zoom: f64,
}
impl Motion {
pub fn active(self) -> bool {
self.pan.iter().any(|v| *v != 0.0) || self.zoom != 0.0
}
}
#[derive(Default)]
struct Rest {
since: Option<Instant>,
count: u32,
sum: [f64; 6],
min: [f64; 6],
max: [f64; 6],
}
impl Rest {
fn observe(&mut self, raw: [f64; 6], now: Instant) -> Option<[f64; 6]> {
if raw.iter().any(|v| v.abs() > REST_LIMIT) {
*self = Self::default();
return None;
}
if self.since.is_none() || raw.iter().zip(self.min.iter().zip(&self.max)).any(|(v, (min, max))| v.max(*max) - v.min(*min) > REST_RANGE) {
*self = Self { since: Some(now), count: 1, sum: raw, min: raw, max: raw };
return None;
}
for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(raw) {
*min = min.min(value);
*max = max.max(value);
}
// A fixed bound even if a device sends far more than the expected 125 reports/sec.
if self.count < 128 {
self.count += 1;
for (sum, value) in self.sum.iter_mut().zip(raw) {
*sum += value;
}
}
if self.count >= 16 && self.since.is_some_and(|since| now.saturating_duration_since(since) >= CALIBRATION_TIME) {
Some(self.sum.map(|sum| sum / f64::from(self.count)))
} else {
None
}
}
}
#[derive(Default)]
pub struct Filter {
raw: [f64; 6],
bias: Option<[f64; 6]>,
rest: Rest,
parts: u8,
seen: [Option<Instant>; 2],
idle_since: Option<Instant>,
}
impl Filter {
pub fn calibrated(&self) -> bool {
self.bias.is_some()
}
pub fn recalibrate(&mut self) {
*self = Self::default();
}
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
for (index, seen) in self.seen.iter_mut().enumerate() {
if mask & (1 << index) != 0 {
*seen = Some(now);
}
}
for (index, (raw, value)) in self.raw.iter_mut().zip(values).enumerate() {
if mask & if index < 3 { 1 } else { 2 } != 0 {
*raw = f64::from(value);
}
}
self.parts |= mask & 3;
let complete = self.parts == 3;
if complete {
self.parts = 0;
}
if self.bias.is_none() && complete {
self.bias = self.rest.observe(self.raw, now);
}
let Some(mut bias) = self.bias else { return Motion::default() };
// Follow small resting drift only after two seconds entirely inside the deadzone.
if complete {
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (bias, raw) in bias.iter_mut().zip(self.raw) {
*bias += (raw - *bias) * 0.01;
}
self.bias = Some(bias);
}
} else {
self.idle_since = None;
}
}
let mut axes = [0.0; 6];
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
*axis = if value.abs() <= DEADZONE { 0.0 } else { value.signum() * (value.abs() - DEADZONE) / (1.0 - DEADZONE) };
}
// A missing rotation report must not keep zooming while translations still arrive.
for (group, seen) in axes.as_chunks_mut::<3>().0.iter_mut().zip(self.seen) {
if seen.is_none_or(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
group.fill(0.0);
}
}
let [tx, ty, tz, _rx, _ry, rz] = axes;
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
}
}
-11
View File
@@ -22,14 +22,6 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.rangoDJ]
sync_github_name = true
[people.Tru10]
display_name = "by Truitt"
sync_github_name = false
@@ -45,6 +37,3 @@ sync_github_name = true
[people.WladmirJunior]
real_name = "Wladmir Júnior"
sync_github_name = true
[people.Losminek]
sync_github_name = true
+21 -68
View File
@@ -153,11 +153,9 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
name: "BRICK",
description: "Brick or masonry-type surface",
lines: vec![
// Running bond: courses 0.25 high, bricks 0.5 long. Each vertical family draws the
// joints of every other course, 0.5 apart; the second is offset half a brick.
pl(0.0, (0.0, 0.0), (0.0, 0.25), &[]),
pl(90.0, (0.0, 0.0), (0.0, 0.5), &[0.25, -0.25]),
pl(90.0, (0.25, 0.0), (0.0, 0.5), &[-0.25, 0.25]),
pl(90.0, (0.0, 0.0), (0.25, 0.25), &[0.25, -0.25]),
pl(90.0, (0.25, 0.0), (0.25, 0.25), &[-0.25, 0.25]),
],
},
HatchPattern {
@@ -205,73 +203,28 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
description: "Staircase effect",
lines: vec![pl(0.0, (0.0, 0.0), (0.125, 0.125), &[0.125, -0.125]), pl(90.0, (0.125, 0.0), (0.125, 0.125), &[0.125, -0.125])],
},
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
scaled_pattern(
HatchPattern {
name: "AR-CONC",
description: "Random dot and stone pattern",
lines: vec![
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
],
},
16.0,
),
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
scaled_pattern(
HatchPattern {
name: "AR-SAND",
description: "Random dot pattern",
lines: vec![
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
],
},
16.0,
),
HatchPattern {
name: "AR-CONC",
description: "Random dot and stone pattern",
lines: vec![
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
],
},
HatchPattern {
name: "AR-SAND",
description: "Random dot pattern",
lines: vec![
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
],
},
]
}
pub fn pattern(name: &str) -> Option<HatchPattern> {
standard_patterns().into_iter().find(|p| p.name.eq_ignore_ascii_case(name))
}
/// The library pattern `name` as drawn in `d`. In a metric drawing (`MEASUREMENT` 1) every
/// length is multiplied by 25.4, as CAD programs use ISO-scaled pattern definitions there (as
/// for linetypes, see [`standard_linetypes_for`]): a hatch at scale 1 measures in millimetres
/// what it measures in inches in an imperial drawing.
pub fn pattern_for(d: &Drawing, name: &str) -> Option<HatchPattern> {
let p = pattern(name)?;
Some(if d.header.i64("MEASUREMENT", 0) == 1 { scaled_pattern(p, 25.4) } else { p })
}
/// `p` with every length (origins, offsets, dashes) multiplied by `k`.
fn scaled_pattern(mut p: HatchPattern, k: f64) -> HatchPattern {
for l in &mut p.lines {
l.origin = (l.origin.0 * k, l.origin.1 * k);
l.delta = (l.delta.0 * k, l.delta.1 * k);
for d in &mut l.dashes {
*d *= k;
}
}
p
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metric_drawings_draw_iso_scaled_patterns() {
let imp = pattern_for(&Drawing::new_imperial(), "ANSI31").map(|p| p.lines[0].delta.1);
let met = pattern_for(&Drawing::new_metric(), "ansi31").map(|p| p.lines[0].delta.1);
assert_eq!(imp, Some(0.125));
assert!(met.is_some_and(|m| (m - 0.125 * 25.4).abs() < 1e-12), "{met:?}");
assert!(pattern_for(&Drawing::new_metric(), "NOPE").is_none());
}
}
+1 -21
View File
@@ -162,12 +162,6 @@ impl Autosaver {
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
/// files of drawings that were saved (no unsaved changes left) or closed.
pub fn plan(&mut self, s: &Session, now: f64, minutes: u32, dir: &str) -> Vec<Job> {
self.plan_with(s, now, minutes, dir, false)
}
/// [`Autosaver::plan`], with `urgent` writing every drawing with changes not yet autosaved
/// now instead of `minutes` after the change (autosave still off when `minutes` is 0).
fn plan_with(&mut self, s: &Session, now: f64, minutes: u32, dir: &str, urgent: bool) -> Vec<Job> {
let mut jobs = Vec::new();
let live: HashSet<u64> = s.docs.iter().map(|d| d.uid).collect();
let busy = &self.busy;
@@ -199,7 +193,7 @@ impl Autosaver {
continue;
}
let since = *t.since.get_or_insert(now);
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
if minutes == 0 || dir.trim().is_empty() || now - since < f64::from(minutes) * 60.0 {
continue;
}
let path = t
@@ -357,20 +351,6 @@ mod native {
self.next_due(now, minutes).map(Duration::from_secs_f64)
}
/// Autosave every drawing with changes not yet autosaved right away, without waiting for
/// SAVETIME (the app may not survive what just happened, e.g. a GPU error). Returns how
/// many drawings are being written.
pub fn save_now(&mut self, s: &Session) -> usize {
let now = self.start.elapsed().as_secs_f64();
let jobs = self.plan_with(s, now, savetime(), &savefilepath(), true);
let writes = jobs.iter().filter(|j| matches!(j, Job::Write { .. })).count();
self.perform(jobs);
// The lock file marks the files as this running app's (Drawing Recovery skips them).
self.last_beat = None;
self.heartbeat(&savefilepath(), now);
writes
}
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
pub fn perform(&mut self, jobs: Vec<Job>) {
for job in jobs {
-3
View File
@@ -447,9 +447,6 @@ impl Interactive for ArrayM {
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default()
}
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
self.inner.as_ref().and_then(|m| m.drag(s, cursor))
}
}
// ---------------- ARRAYRECT ----------------
+6 -28
View File
@@ -1,12 +1,12 @@
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, multiline
//! styles, groups) and zero-length geometry or empty text.
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, groups) and
//! zero-length geometry or empty text.
//!
//! The JSON form takes `{type?, names?}` and purges without asking. Typed, it asks for the type,
//! the names and whether to verify each one, as AutoCAD's -PURGE does.
use std::collections::{HashSet, VecDeque};
use cadcraft_doc::{Drawing, EntityKind, Handle, STANDARD_MLINE_STYLE};
use cadcraft_doc::{Drawing, EntityKind, Handle};
use serde_json::{Map, Value, json};
use super::super::qselect::wildcard;
@@ -21,7 +21,6 @@ enum Kind {
Layers,
Linetypes,
MleaderStyles,
MlineStyles,
TableStyles,
TextStyles,
ZeroLength,
@@ -44,7 +43,7 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
("Plotstyles", &["plotstyles"], Kind::Absent),
("SHapes", &["shapes"], Kind::Absent),
("textSTyles", &["textstyles", "textstyle", "styles", "style"], Kind::TextStyles),
("Mlinestyles", &["mlinestyles", "mlinestyle"], Kind::MlineStyles),
("Mlinestyles", &["mlinestyles"], Kind::Absent),
("SEctionviewstyles", &["sectionviewstyles"], Kind::Absent),
("Tablestyles", &["tablestyles", "tablestyle"], Kind::TableStyles),
("Visualstyles", &["visualstyles"], Kind::Absent),
@@ -57,17 +56,8 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
/// The named kinds `All` purges, in an order where purging one frees the next (blocks free
/// layers, layers free linetypes, dimension styles free text styles).
const ALL: &[Kind] = &[
Kind::Blocks,
Kind::Groups,
Kind::Dimstyles,
Kind::MleaderStyles,
Kind::MlineStyles,
Kind::TableStyles,
Kind::Layers,
Kind::Linetypes,
Kind::TextStyles,
];
const ALL: &[Kind] =
&[Kind::Blocks, Kind::Groups, Kind::Dimstyles, Kind::MleaderStyles, Kind::TableStyles, Kind::Layers, Kind::Linetypes, Kind::TextStyles];
fn norm(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
@@ -87,7 +77,6 @@ fn noun(kind: Kind) -> &'static str {
Kind::Layers => "layer",
Kind::Linetypes => "linetype",
Kind::MleaderStyles => "multileader style",
Kind::MlineStyles => "multiline style",
Kind::TableStyles => "table style",
Kind::TextStyles => "text style",
Kind::ZeroLength => "zero-length object",
@@ -104,7 +93,6 @@ fn json_key(kind: Kind) -> &'static str {
Kind::Layers => "layers",
Kind::Linetypes => "linetypes",
Kind::MleaderStyles => "mleaderstyles",
Kind::MlineStyles => "mlinestyles",
Kind::TableStyles => "tablestyles",
Kind::TextStyles => "textstyles",
Kind::ZeroLength => "zeroLength",
@@ -210,15 +198,6 @@ fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
.map(|t| t.name.clone())
.collect()
}
Kind::MlineStyles => {
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::MLine(m) = &e.kind { Some(m.style.clone()) } else { None }));
let cur = d.header.str("CMLSTYLE", STANDARD_MLINE_STYLE);
d.mline_styles
.iter()
.filter(|t| !is(&t.name, &[STANDARD_MLINE_STYLE, &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
.map(|t| t.name.clone())
.collect()
}
Kind::TableStyles => {
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t.style.clone()) } else { None }));
let cur = d.header.str("CTABLESTYLE", "Standard");
@@ -259,7 +238,6 @@ fn purge_one(s: &mut Session, kind: Kind, name: &str) -> Result<bool> {
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, name),
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, name),
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, name),
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, name),
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, name),
Kind::Groups => retain(&mut d.groups, |g| &g.name, name),
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
-6
View File
@@ -609,12 +609,6 @@ impl Interactive for PasteM {
_ => Ok(Step::Continue),
}
}
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
if self.mode == PasteMode::Orig || s.clipboard.is_empty() {
return None;
}
Some(crate::prompt::Drag { handles: Vec::new(), matrix: Mat3::translate(c - s.clipboard_base), extra: Vec::new(), clipboard: true })
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
let m = Mat3::translate(c - s.clipboard_base);
s.clipboard
+1 -114
View File
@@ -12,7 +12,7 @@ pub use cadcraft_dxf::Version;
use serde_json::{Value, json};
use super::*;
use crate::{Accept, Input, Prompt, Result, Session, Step};
use crate::{Result, Session};
pub(crate) use pgp::{specs as alias_specs, user_alias};
pub use template::{qnew_template, set_qnew_template};
@@ -153,69 +153,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
.noundo(),
CommandSpec::new("script", "Run Script...", run_script)
.menu(&["Tools", "Run Script..."])
.alias(&["scr"])
.params("{path: script (.scr) file} | {data: base64} → {lines}")
.noundo()
.interactive(|_| Ok(Box::new(ScriptM))),
]
}
/// SCRIPT: run the lines of a `.scr` file through [`Session::script`] — the same execution the
/// CLI, MCP and the control channel already use, now reachable from the command line and menus.
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let text = if let Some(_path) = str_param(p, "path") {
#[cfg(not(target_arch = "wasm32"))]
let path = _path;
#[cfg(not(target_arch = "wasm32"))]
{
let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?;
String::from_utf8_lossy(&bytes).into_owned()
}
#[cfg(target_arch = "wasm32")]
return Err(bad("script", "paths are not available on the web; pass `data`"));
} else {
let data = str_param(p, "data").ok_or_else(|| bad("script", "`path` or `data` (base64) is required"))?;
let bytes = base64_decode(data).ok_or_else(|| bad("script", "invalid base64"))?;
String::from_utf8_lossy(&bytes).into_owned()
};
let lines = text.lines().count();
s.script(&text)?;
Ok(json!({ "lines": lines }))
}
/// SCRIPT typed at the command line or chosen from the menu: asks for the script file by name
/// (the prompt form; the menu item runs this same machine).
struct ScriptM;
impl Interactive for ScriptM {
fn name(&self) -> &'static str {
"SCRIPT"
}
fn prompt(&self, _: &Session) -> Prompt {
Prompt::new("Enter name of script file", Accept::TEXT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if matches!(i, Input::Cancel | Input::Enter) {
return Ok(Step::Cancel);
}
let Input::Text(t) = i else { return Ok(Step::Continue) };
let t = t.trim();
if t.is_empty() {
return Ok(Step::Cancel);
}
match run_script(s, &json!({ "path": t })) {
Ok(_) => Ok(Step::Done),
Err(EngineError::BadParams { msg, .. }) => {
s.echo(msg);
Ok(Step::Done)
}
Err(e) => Err(e),
}
}
}
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = template::run_new(s, p)? {
return Ok(r);
@@ -231,9 +171,6 @@ fn file_name(path: &str) -> String {
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
// asks where to save instead of exporting over the original.
let path = path.filter(|p| is_drawing_file(p));
let i = s.open_drawing(d, &file_name(name), path);
if let Some(st) = s.docs.get_mut(i) {
// QSAVE keeps a binary DXF binary.
@@ -525,56 +462,6 @@ mod tests {
s.state().unwrap().title.clone()
}
fn temp_script(name: &str, text: &str) -> String {
let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id()));
std::fs::write(&path, text).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn script_command_runs_a_scr_file() {
let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
let mut s = Session::new();
let r = s.execute("script", &json!({ "path": path })).unwrap();
assert_eq!(r["lines"], 3);
assert_eq!(s.state().unwrap().doc.model.len(), 2, "the comment is skipped, circle and rectang draw");
}
#[test]
fn script_command_reports_a_missing_file() {
let mut s = Session::new();
let e = s.execute("script", &json!({ "path": "no/such/script.scr" })).unwrap_err().to_string();
assert!(e.contains("no/such/script.scr"), "{e}");
assert_eq!(s.state().unwrap().doc.model.len(), 0);
}
/// A script that runs itself must be refused, not recurse until the stack dies.
#[test]
fn a_script_cannot_start_a_script() {
let inner = temp_script("inner.scr", "circle 0,0 1\n");
let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
let mut s = Session::new();
let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();
assert!(e.contains("already running"), "{e}");
}
#[test]
fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {
let good = temp_script("typed.scr", "circle 0,0 1\n");
let mut s = Session::new();
s.cmdline("script").unwrap();
assert!(s.running.is_some(), "SCRIPT asks for the file name");
s.cmdline(&good).unwrap();
assert!(s.running.is_none());
assert_eq!(s.state().unwrap().doc.model.len(), 1);
// A file that cannot be read: the error is echoed, the command still ends.
s.cmdline("script").unwrap();
s.cmdline("no/such/script.scr").unwrap();
assert!(s.running.is_none());
assert!(s.log.iter().any(|l| l.contains("no/such/script.scr")), "{:?}", s.log);
}
#[test]
fn closing_earlier_inactive_drawing_keeps_active_drawing() {
let mut s = Session::new();
+7 -164
View File
@@ -1,7 +1,6 @@
//! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects.
mod patfile;
pub use patfile::{PatternChoice, pattern_choices};
pub(super) use patfile::{lines_for, run_patterns};
use cadcraft_color::Color;
@@ -11,7 +10,7 @@ use serde_json::{Value, json};
use super::helpers::lwpoly;
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod assoc;
mod edit;
@@ -356,63 +355,9 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
Ok(json!({ "handle": hex.first(), "handles": hex }))
}
/// True when two loops have the same vertex sequence (ignoring cyclic shift and winding order).
fn same_loop(a: &HatchLoop, b: &HatchLoop) -> bool {
if a.vertices.len() != b.vertices.len() {
return false;
}
let n = a.vertices.len();
if n == 0 {
return true;
}
let near = |v1: &PolyVertex, v2: &PolyVertex| v1.p.near(v2.p, 1e-6) && (v1.bulge - v2.bulge).abs() < 1e-6;
(0..n).any(|k| (0..n).all(|i| near(&a.vertices[i], &b.vertices[(i + k) % n])))
|| (0..n).any(|k| {
(0..n).all(|i| {
let j = (k + n - (i % n)) % n;
let prev_j = (j + n - 1) % n;
a.vertices[i].p.near(b.vertices[j].p, 1e-6) && (a.vertices[i].bulge + b.vertices[prev_j].bulge).abs() < 1e-6
})
})
}
/// Deduplicate boundary loops that describe the same boundary.
fn dedup_loops(loops: &mut Vec<HatchLoop>) {
let mut unique: Vec<HatchLoop> = Vec::with_capacity(loops.len());
for l in loops.drain(..) {
if let Some(existing) = unique.iter_mut().find(|u| same_loop(u, &l)) {
for s in l.sources {
if !existing.sources.contains(&s) {
existing.sources.push(s);
}
}
existing.outer = existing.outer || l.outer;
} else {
unique.push(l);
}
}
*loops = unique;
}
/// Add the hatches for groups of loops: one per group when separate, otherwise one for all.
fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>) -> Result<Vec<Handle>> {
let groups: Vec<Vec<HatchLoop>> = if st.separate {
let mut unique_groups: Vec<Vec<HatchLoop>> = Vec::new();
for mut g in groups {
dedup_loops(&mut g);
if g.is_empty() {
continue;
}
if !unique_groups.iter().any(|u| u.len() == g.len() && u.iter().zip(&g).all(|(a, b)| same_loop(a, b))) {
unique_groups.push(g);
}
}
unique_groups
} else {
let mut all: Vec<HatchLoop> = groups.into_iter().flatten().collect();
dedup_loops(&mut all);
vec![all]
};
let groups: Vec<Vec<HatchLoop>> = if st.separate { groups } else { vec![groups.into_iter().flatten().collect()] };
let mut out = Vec::new();
for loops in groups.into_iter().filter(|g| !g.is_empty()) {
let h = s.add_entity(st.hatch(loops))?;
@@ -583,11 +528,7 @@ impl Interactive for HatchM {
fn prompt(&self, s: &Session) -> Prompt {
// The sub-prompts offer the current value: Enter keeps it.
match self.asking {
Some(HatchAsk::Pattern) => {
return Prompt::new("Enter a pattern name", Accept::TEXT)
.default(self.settings.pattern.clone())
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
}
Some(HatchAsk::Pattern) => return Prompt::new("Enter a pattern name", Accept::TEXT).default(self.settings.pattern.clone()),
Some(HatchAsk::Scale) => {
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
}
@@ -601,10 +542,8 @@ impl Interactive for HatchM {
Some(HatchAsk::Origin) => return Prompt::new("Specify the pattern origin", Accept::POINT).kw(&ORIGIN_KW).default("Use current"),
None => {}
}
// The pattern picker stays offered while boundaries are picked: a choice sets the pattern.
let picker = |p: Prompt| if self.gradient { p } else { p.picker(Picker::HatchPattern(self.settings.pattern.clone())) };
if self.selecting {
return picker(Prompt::new("Select objects", Accept::SELECT).kw(&[
return Prompt::new("Select objects", Accept::SELECT).kw(&[
"picK internal point",
"Undo",
"seTtings",
@@ -612,9 +551,9 @@ impl Interactive for HatchM {
"Angle",
"Origin",
"sEparate",
]));
]);
}
picker(Prompt::new("Pick internal point", Accept::POINT).kw(&[
Prompt::new("Pick internal point", Accept::POINT).kw(&[
"Select objects",
"Undo",
"seTtings",
@@ -623,7 +562,7 @@ impl Interactive for HatchM {
"Angle",
"Origin",
"sEparate",
]))
])
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(a) = self.asking.take() {
@@ -677,11 +616,6 @@ impl Interactive for HatchM {
}
Ok(Step::Continue)
}
// A pattern chosen in the picker while boundaries are picked.
Input::Text(t) if !self.gradient => {
self.answer(s, HatchAsk::Pattern, &t)?;
Ok(Step::Continue)
}
// Enter ends boundary picking in either mode and creates the hatch.
Input::Enter => self.finish(s),
_ => Ok(Step::Continue),
@@ -749,95 +683,4 @@ mod tests {
assert!(!hatch.solid);
assert!(hatch.gradient.is_none());
}
#[test]
fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() {
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.start("hatch").unwrap();
let picker = s.current_prompt().and_then(|p| p.picker);
assert_eq!(picker, Some(Picker::HatchPattern(HatchSettings::from(&s, &Value::Null, false).pattern)));
// A choice made in the picker while boundaries are picked.
s.input(Input::Text("AR-CONC".into())).unwrap();
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("AR-CONC".into())));
// And at the pattern prompt.
s.input(Input::Keyword("Pattern".into())).unwrap();
assert!(s.current_prompt().unwrap().picker.is_some());
s.input(Input::Text("BRICK".into())).unwrap();
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
// An unknown name keeps the pattern.
s.start("hatch").unwrap();
s.input(Input::Text("NO-SUCH".into())).unwrap();
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("BRICK".into())));
// GRADIENT offers no pattern picker.
s.cancel();
s.start("gradient").unwrap();
assert!(s.current_prompt().unwrap().picker.is_none());
}
#[test]
fn pattern_choices_list_the_library_with_its_lines() {
let all = pattern_choices();
assert_eq!(all.first().map(|c| c.name.as_str()), Some("SOLID"));
assert!(all[0].lines.is_empty());
let ansi31 = all.iter().find(|c| c.name == "ANSI31").unwrap();
assert_eq!(ansi31.lines.len(), 1);
assert!(!ansi31.description.is_empty());
}
#[test]
fn repeated_picks_and_handles_in_one_region_do_not_produce_duplicate_loops() {
// One internal point
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap();
let (_, h1) = only_hatch(&s);
assert_eq!(h1.loops.len(), 1);
// Two different points in the same rectangle
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[2, 2], [8, 8]], "pattern": "SOLID" })).unwrap();
let (_, h2) = only_hatch(&s);
assert_eq!(h2.loops.len(), 1, "two points in same region must deduplicate loops");
// The same point twice
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[5, 5], [5, 5]], "pattern": "SOLID" })).unwrap();
let (_, h3) = only_hatch(&s);
assert_eq!(h3.loops.len(), 1, "identical points must deduplicate loops");
// Three points in the same rectangle
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[2, 2], [5, 5], [8, 8]], "pattern": "SOLID" })).unwrap();
let (_, h4) = only_hatch(&s);
assert_eq!(h4.loops.len(), 1);
// Same boundary handle twice
let mut s = Session::new();
let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
let handle = r["handle"].as_str().unwrap();
s.execute("hatch", &json!({ "handles": [handle, handle], "pattern": "SOLID" })).unwrap();
let (_, h5) = only_hatch(&s);
assert_eq!(h5.loops.len(), 1, "repeated boundary handle must deduplicate loops");
// Two points in disjoint rectangles: both regions preserved
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("rectang", &json!({ "p1": [20, 0], "p2": [30, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[5, 5], [25, 5]], "pattern": "SOLID" })).unwrap();
let (_, h6) = only_hatch(&s);
assert_eq!(h6.loops.len(), 2, "disjoint rectangles must both be kept");
// Gradient with duplicate points in the same rectangle
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("gradient", &json!({ "points": [[2, 2], [8, 8]], "color1": "1", "color2": "3" })).unwrap();
let (_, hg) = only_hatch(&s);
assert_eq!(hg.loops.len(), 1, "gradient with repeated picks in same region must deduplicate loops");
}
}
+2 -5
View File
@@ -10,7 +10,7 @@ use super::assoc::{detach, same_shape};
use super::{ORIGIN_KW, OriginMode, closed_loop, island_style};
use crate::cmd::helpers::lwpoly;
use crate::cmd::{bad, bool_or, point_param, str_param, targets};
use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Upper bound on hatches edited by one call.
const MAX_EDITED: usize = 10_000;
@@ -328,10 +328,7 @@ impl Interactive for HatchEditM {
};
Prompt::new("Island detection style", Accept::TEXT).kw(&["Ignore", "Outer", "Normal"]).default(cur)
}
Ask::Pattern => {
let current = h.map(|h| h.pattern).unwrap_or_default();
Prompt::new("Enter a pattern name", Accept::TEXT).default(current.clone()).picker(Picker::HatchPattern(current))
}
Ask::Pattern => Prompt::new("Enter a pattern name", Accept::TEXT).default(h.map(|h| h.pattern).unwrap_or_default()),
Ask::Scale => Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", h.map_or(1.0, |h| h.scale))),
Ask::Angle => Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER)
.default(format!("{:.0}", h.map_or(0.0, |h| h.angle.to_degrees()))),
-19
View File
@@ -44,25 +44,6 @@ pub(crate) fn names() -> Vec<String> {
v
}
/// A pattern offered for new hatches: a library pattern or one a `.pat` file loaded.
#[derive(Clone, Debug, PartialEq)]
pub struct PatternChoice {
pub name: String,
pub description: String,
/// The line families at unit scale (none for SOLID).
pub lines: Vec<PatternLine>,
}
/// Every pattern offered for new hatches, in the order of [`names`], for a pattern picker.
pub fn pattern_choices() -> Vec<PatternChoice> {
let mut v: Vec<PatternChoice> = standard_patterns()
.into_iter()
.map(|p| PatternChoice { name: p.name.to_string(), description: p.description.to_string(), lines: p.lines })
.collect();
v.extend(store().iter().map(|p| PatternChoice { name: p.name.clone(), description: p.description.clone(), lines: p.lines.clone() }));
v
}
/// Add the patterns of a `.pat` file's text (a pattern loaded before under the same name is
/// replaced). Returns the names added and the file's problems.
pub(crate) fn load(text: &str) -> (Vec<String>, Vec<String>) {
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("mview", "Viewports", run_mview)
.alias(&["mv"])
.params(
"{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), name? (restore: a model space viewport configuration, default *Active; its viewports fill p1-p2 or the printable area), layout?}",
"{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), layout?}",
)
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
CommandSpec::new("mview.polygonal", "Polygonal Viewport", |s, p| run_mview(s, &viewports::with(p.clone(), "option", json!("polygonal"))))
+7 -146
View File
@@ -1,25 +1,22 @@
//! Clipped viewports and MVIEW's options: polygonal and object viewports (MVIEW Polygonal /
//! Object), VPCLIP, ON/OFF, Lock and Restore (a model space viewport configuration as layout
//! viewports), and the typed MVIEW prompts.
//! Object), VPCLIP, ON/OFF, Lock and Restore, and the typed MVIEW prompts.
//!
//! A clipped viewport keeps its rectangle as the boundary's extents and points at a paper-space
//! entity (a closed polyline, circle, ellipse or spline) whose outline clips what it shows
//! ([`Viewport::clip`], DXF group 340).
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport, VportConfig, VportTile};
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport};
use cadcraft_geom::{Bounds2, PolyVertex, Vec2};
use serde_json::{Value, json};
use super::super::curves::{self, handle_value};
use super::super::helpers::{lwpoly, rect_vertices};
use super::super::{bad, bool_or, point_param, points_param, str_param, targets};
use super::{fit_view, layout_viewports, mview, printable, split, target_layout};
use super::super::{bad, bool_or, points_param, str_param, targets};
use super::{fit_view, layout_viewports, mview, split, target_layout};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// Most vertices a typed or JSON polygonal boundary may have.
const MAX_VERTICES: usize = 10_000;
/// Most viewports MVIEW Restore creates (a configuration read from a file may hold more tiles).
const MAX_RESTORED: usize = 64;
/// The polygon outline's extents of a closed paper-space entity usable as a clip boundary.
fn boundary_bounds(k: &EntityKind) -> Option<Bounds2> {
@@ -213,66 +210,6 @@ fn mview_lock(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>(), "locked": locked }))
}
/// Where tile `t` of a viewport configuration lands when the whole configuration fills `r`; `None`
/// for a tile with no usable area (configurations come from files).
fn tile_rect(t: &VportTile, r: Bounds2) -> Option<Bounds2> {
let f = |v: f64| v.is_finite().then(|| v.clamp(0.0, 1.0));
let (x0, y0, x1, y1) = (f(t.min.x)?, f(t.min.y)?, f(t.max.x)?, f(t.max.y)?);
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
return None;
}
let at = |x: f64, y: f64| Vec2::new(r.min.x + x * r.width(), r.min.y + y * r.height());
Some(Bounds2::new(at(x0, y0), at(x1, y1)))
}
/// The view a restored tile shows in a viewport of size `r`: its saved centre, and its height
/// grown when the viewport is narrower than the tile was, so the whole saved view still shows.
fn tile_view(t: &VportTile, r: Bounds2) -> Option<(Vec2, f64)> {
if !(t.center.is_finite() && t.height.is_finite() && t.height > 0.0) {
return None;
}
let aspect = r.width() / r.height().max(1e-12);
let h = if t.aspect.is_finite() && t.aspect > 0.0 && aspect > 0.0 { t.height.max(t.height * t.aspect / aspect) } else { t.height };
(h.is_finite() && h > 0.0).then_some((t.center, h))
}
/// MVIEW Restore: `{name? (default *Active), p1?, p2? (default: the printable area), layout?}`.
/// One viewport per tile of the model space viewport configuration, arranged as the tiles are
/// over the window, each showing its tile's view.
fn mview_restore(s: &mut Session, p: &Value) -> Result<Value> {
let space = target_layout(s, "mview", p)?;
let Space::Paper(layout) = &space else { return Err(bad("mview", "not a layout")) };
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(VportConfig::ACTIVE).to_string();
let tiles = super::super::view::config_tiles(s, &name)
.ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::<String>())))?;
let rect = match (point_param(p, "p1"), point_param(p, "p2")) {
(Some(a), Some(b)) => Bounds2::new(a, b),
_ => printable(s, layout)?,
};
if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() || rect.width() < 1e-6 || rect.height() < 1e-6 {
return Err(bad("mview", "the viewport has no area"));
}
if rect.width() > 1e7 || rect.height() > 1e7 {
return Err(bad("mview", "the viewport is too large"));
}
let common = s.current_common();
let d = s.doc_mut()?;
let mut out = Vec::new();
for t in tiles.iter().take(MAX_RESTORED) {
let Some(r) = tile_rect(t, rect) else { continue };
let id = next_viewport_id(d);
let mut vp = new_viewport(d, r, id);
if let Some((c, h)) = tile_view(t, r) {
(vp.view_center, vp.view_height) = (c, h);
}
out.push(d.add(&space, common.clone(), EntityKind::Viewport(vp))?);
}
if out.is_empty() {
return Err(EngineError::Other(format!("Viewport configuration \"{name}\" has no viewports to restore.")));
}
Ok(json!({ "configuration": name, "viewports": handles_json(&out) }))
}
/// MVIEW's options other than corners and Fit; `None` when `option` is not one of them.
pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option<Result<Value>> {
let opt = str_param(p, "option")?.trim().to_ascii_lowercase();
@@ -282,7 +219,7 @@ pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option<Result<Value>>
"on" => mview_on(s, p, true),
"off" => mview_on(s, p, false),
"lock" | "l" => mview_lock(s, p),
"restore" | "r" => mview_restore(s, p),
"restore" | "r" => Err(bad("mview", "named viewport configurations are not available yet")),
"fit" | "f" | "corners" | "" => return None,
_ => Err(bad("mview", "option must be fit, polygonal, object, on, off, lock or restore")),
})
@@ -534,10 +471,6 @@ enum Stage {
Polygon(PolygonInput),
Object,
Lock,
/// Restore: the configuration's name, then where its viewports go.
RestoreName,
RestoreCorner(String),
RestoreOpposite(String, Vec2),
/// ON/OFF (Some(on)) or Lock (None, with the chosen state) on picked viewports.
Select {
on: Option<bool>,
@@ -574,18 +507,6 @@ impl MviewM {
s.echo(format!("{n} viewport(s) created, zoomed to the model extents."));
Ok(Step::Done)
}
/// MVIEW Restore with the typed name, over `rect` (None: Fit).
fn restore(s: &mut Session, name: &str, rect: Option<Bounds2>) -> Result<Step> {
let mut p = json!({ "option": "restore", "name": name });
if let Some(r) = rect {
p = with(p, "p1", json!([r.min.x, r.min.y]));
p = with(p, "p2", json!([r.max.x, r.max.y]));
}
let r = mview_restore(s, &p)?;
let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0);
s.echo(format!("{n} viewport(s) restored from \"{name}\"."));
Ok(Step::Done)
}
fn corner_keyword(&mut self, s: &mut Session, k: &str) -> Result<Step> {
if k == "fit" || k == "f" {
return self.create(s, None);
@@ -603,7 +524,7 @@ impl MviewM {
"lock" | "l" => Stage::Lock,
"object" | "o" => Stage::Object,
"polygonal" | "p" => Stage::Polygon(PolygonInput::default()),
"restore" | "r" => Stage::RestoreName,
"restore" | "r" => return Err(EngineError::Other("Named viewport configurations are not available yet.".into())),
_ => return Err(EngineError::Other("Requires a point or option keyword.".into())),
};
Ok(Step::Continue)
@@ -641,9 +562,6 @@ impl Interactive for MviewM {
Stage::Polygon(pi) => pi.prompt(),
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
}
}
@@ -735,53 +653,6 @@ impl Interactive for MviewM {
};
Ok(Step::Continue)
}
Stage::RestoreName => {
let name = match &i {
Input::Enter => VportConfig::ACTIVE.to_string(),
Input::Text(t) | Input::Keyword(t) if !t.trim().is_empty() => t.trim().to_string(),
_ => {
self.stage = Stage::RestoreName;
return Ok(Step::Continue);
}
};
self.stage = Stage::RestoreName;
if name == "?" {
let names = super::super::view::config_names(s);
s.echo(if names.is_empty() { "No saved viewport configurations.".to_string() } else { names.join(", ") });
return Ok(Step::Continue);
}
if super::super::view::config_tiles(s, &name).is_none() {
return Err(EngineError::Other(format!(
"Could not find viewport configuration \"{}\".",
name.chars().take(64).collect::<String>()
)));
}
self.stage = Stage::RestoreCorner(name);
Ok(Step::Continue)
}
Stage::RestoreCorner(name) => match i {
Input::Enter => Self::restore(s, &name, None),
Input::Keyword(k) | Input::Text(k) if matches!(k.trim().to_ascii_lowercase().as_str(), "fit" | "f") => Self::restore(s, &name, None),
Input::Point(a) => {
self.stage = Stage::RestoreOpposite(name, a);
Ok(Step::Continue)
}
Input::Keyword(_) | Input::Text(_) => {
self.stage = Stage::RestoreCorner(name);
Err(EngineError::Other("Requires a point or option keyword.".into()))
}
_ => {
self.stage = Stage::RestoreCorner(name);
Ok(Step::Continue)
}
},
Stage::RestoreOpposite(name, a) => match i {
Input::Point(b) => Self::restore(s, &name, Some(Bounds2::new(a, b))),
_ => {
self.stage = Stage::RestoreOpposite(name, a);
Ok(Step::Continue)
}
},
Stage::Select { on, locked, mut picked } => match i {
Input::Enter => {
if picked.is_empty() {
@@ -802,22 +673,12 @@ impl Interactive for MviewM {
},
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match &self.stage {
Stage::Opposite(a) => {
split(Bounds2::new(*a, c), self.count, &self.arrangement).into_iter().map(|r| lwpoly(rect_vertices(r.min, r.max), true)).collect()
}
Stage::Polygon(pi) => pi.preview(c),
Stage::RestoreOpposite(name, a) => {
let r = Bounds2::new(*a, c);
super::super::view::config_tiles(s, name)
.unwrap_or_default()
.iter()
.take(MAX_RESTORED)
.filter_map(|t| tile_rect(t, r))
.map(|r| lwpoly(rect_vertices(r.min, r.max), true))
.collect()
}
_ => Vec::new(),
}
}
-61
View File
@@ -1712,26 +1712,6 @@ impl SelectThen {
}
}
/// Objects whose text MIRROR keeps readable instead of mirroring it with the geometry.
fn keeps_reading_when_mirrored(k: &EntityKind) -> bool {
match k {
EntityKind::Insert(i) => !i.attribs.is_empty(),
k => matches!(
k,
EntityKind::Text(_)
| EntityKind::AttDef(_)
| EntityKind::MText(_)
| EntityKind::Dimension(_)
| EntityKind::MLeader(_)
| EntityKind::Table(_)
| EntityKind::Tolerance(_)
),
}
}
/// Text objects transformed for each frame of a MIRROR preview (on the CPU); more are not drawn.
const MAX_MIRROR_TEXT_PREVIEW: usize = 1000;
/// A finite length greater than zero (rejects 0, negatives, NaN and infinity).
fn positive_length(v: Option<f64>) -> Option<f64> {
v.filter(|f| f.is_finite() && *f > 1e-12)
@@ -1947,41 +1927,6 @@ impl Interactive for SelectThen {
out.push(line(base, c));
out
}
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
if (self.op == Op::Rotate && self.reference) || !self.sel.done {
return None;
}
let base = self.pts.first().copied()?;
let matrix = match self.op {
Op::Move | Op::Copy => Mat3::translate(c - base),
Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)),
Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)),
Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?),
Op::Mirror if !self.ask_erase => Mat3::mirror(base, c),
_ => return None,
};
let mut extra = vec![line(base, c)];
if self.op != Op::Mirror {
return Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra, clipboard: false });
}
// Mirrored text keeps reading left to right (MIRRTEXT 0), which the matrix alone can't
// draw: those objects are transformed one by one, the rest is mirrored on the GPU.
let d = s.doc().ok()?;
let mut handles = Vec::with_capacity(self.objs.len());
let mut texts = 0;
for h in &self.objs {
let Some(e) = d.entity(*h) else { continue };
if !keeps_reading_when_mirrored(&e.kind) {
handles.push(*h);
} else if texts < MAX_MIRROR_TEXT_PREVIEW {
texts += 1;
let mut k = e.kind.clone();
k.transform(&matrix);
extra.push(k);
}
}
Some(crate::prompt::Drag { handles, matrix, extra, clipboard: false })
}
}
/// An OFFSET option prompt opened from "Specify offset distance".
@@ -2785,12 +2730,6 @@ impl Interactive for DisplaceM {
}
self.inner.preview(s, c)
}
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
if self.displacement {
return None;
}
self.inner.drag(s, c)
}
}
pub(crate) fn _unused(_: Circle) {}
+1 -66
View File
@@ -11,7 +11,6 @@
use std::sync::{Mutex, OnceLock};
use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfig};
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::*;
@@ -234,7 +233,7 @@ fn unknown_device(cmd: &str, name: &str) -> EngineError {
/// paper's printable area. A device change also takes the device's default orientation unless
/// `landscape` is given. Returns whether the paper was set here (else `paper`, if any, is a
/// standard size for the caller to look up).
pub fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
pub(crate) fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
let given = match p.get("device") {
Some(v) => Some(v.as_str().ok_or_else(|| bad(cmd, "`device` must be a plotter name"))?.trim()),
None => None,
@@ -326,74 +325,10 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
Ok(out)
}
// ---------- PLOTWINDOW ----------
fn run_plotwindow(s: &mut Session, p: &Value) -> Result<Value> {
let (a, b) = (point_req("plotwindow", p, "p1")?, point_req("plotwindow", p, "p2")?);
set_plot_window(s, a, b)
}
fn set_plot_window(s: &mut Session, a: Vec2, b: Vec2) -> Result<Value> {
let w = Bounds2::new(a, b);
if !(w.width() > 0.0 && w.height() > 0.0) {
return Err(bad("plotwindow", "the window needs a width and a height"));
}
s.plot_window = Some(w);
Ok(json!({ "window": [[w.min.x, w.min.y], [w.max.x, w.max.y]] }))
}
/// PLOTWINDOW: two corners picked in the drawing, with the window's rectangle following the cursor.
#[derive(Default)]
struct PlotWindowM {
first: Option<Vec2>,
}
impl Interactive for PlotWindowM {
fn name(&self) -> &'static str {
"PLOTWINDOW"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.first {
None => Prompt::new("Specify first corner", Accept::POINT),
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (i, self.first) {
(Input::Point(p), None) => {
self.first = Some(p);
Ok(Step::Continue)
}
(Input::Point(p), Some(a)) => {
set_plot_window(s, a, p)?;
Ok(Step::Done)
}
(Input::Cancel, _) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
let Some(a) = self.first else { return Vec::new() };
let corners = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y), a];
corners
.windows(2)
.filter_map(|w| match w {
[p, q] => Some(super::helpers::line(*p, *q)),
_ => None,
})
.collect()
}
}
// ---------- PLOTTERMANAGER ----------
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("plotwindow", "Plot Window", run_plotwindow)
.params("{p1: [x,y], p2: [x,y]} (typed: two corners): the window the Plot dialog's \"Window <\" plots, kept in the session")
.enabled(has_doc)
.noundo()
.interactive(|_| Ok(Box::new(PlotWindowM::default()))),
CommandSpec::new("plottermanager", "Plotter Manager...", run_plottermanager)
.menu(&["File", "Plotter Manager..."])
.params(
+1 -6
View File
@@ -60,10 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Format", "Multileader Style..."])
.alias(&["mls"])
.params("{name, arrowSize?, textHeight?, landingGap?, dogleg?, textStyle?, annotative?, current?} → styles"),
CommandSpec::new("ddptype", "Point Style...", run_ptype)
.menu(&["Format", "Point Style..."])
.alias(&["ptype"])
.params("{pdmode, pdsize}"),
CommandSpec::new("ddptype", "Point Style...", run_ptype).menu(&["Format", "Point Style..."]).params("{pdmode, pdsize}"),
CommandSpec::new("rename", "Rename...", run_rename)
.menu(&["Format", "Rename..."])
.alias(&["ren", "-rename"])
@@ -374,8 +371,6 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
h.pattern_lines = super::hatch::lines_for(v);
}
h.pattern = v.to_ascii_uppercase();
// The old pattern's definition lines no longer apply.
h.pattern_lines.clear();
h.solid = h.pattern == "SOLID";
if !h.solid {
// A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set.
+77 -4
View File
@@ -1,11 +1,11 @@
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::Vec2;
use cadcraft_geom::{Polyline, Vec2};
use serde_json::{Value, json};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -26,6 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
.noundo()
.interactive(|_| Ok(Box::<PickM>::default())),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
.params("{handles?}")
.noundo(),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom).params("{mode: distance|radius|angle|area, ...}").noundo(),
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
@@ -272,8 +276,69 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "matches": found, "total": total, "message": msg }))
}
// ---------------- MEASUREGEOM ----------------
// (MASSPROP is in `inquiry/regions.rs`.)
// ---------------- MASSPROP / MEASUREGEOM ----------------
/// Area, perimeter, centroid and second moments of a closed boundary.
fn region_props(pts: &[Vec2]) -> Option<(f64, f64, Vec2, f64, f64)> {
let n = pts.len();
if n < 3 {
return None;
}
let (mut a, mut cx, mut cy, mut ixx, mut iyy, mut per) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for i in 0..n {
let (p, q) = (*pts.get(i)?, *pts.get((i + 1) % n)?);
let c = p.cross(q);
a += c;
cx += (p.x + q.x) * c;
cy += (p.y + q.y) * c;
ixx += (p.y * p.y + p.y * q.y + q.y * q.y) * c;
iyy += (p.x * p.x + p.x * q.x + q.x * q.x) * c;
per += p.dist(q);
}
a /= 2.0;
if a.abs() < 1e-15 {
return None;
}
let c = Vec2::new(cx / (6.0 * a), cy / (6.0 * a));
Some((a.abs(), per, c, (ixx / 12.0).abs(), (iyy / 12.0).abs()))
}
fn closed_points(k: &EntityKind) -> Option<Vec<Vec2>> {
match k {
EntityKind::Circle(c) => {
let mut v = Vec::new();
cadcraft_geom::Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-5, &mut v);
v.pop();
Some(v)
}
EntityKind::LwPolyline(p) => {
let mut v = Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(1e-5);
if v.len() > 1 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
v.pop();
}
Some(v)
}
EntityKind::Hatch(h) => h.loops.first().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(1e-5)),
_ => None,
}
}
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let d = s.doc()?;
let mut out = Vec::new();
let mut lines = Vec::new();
for h in hs {
let Some(e) = d.entity(h) else { continue };
let Some((a, per, c, ixx, iyy)) = closed_points(&e.kind).and_then(|v| region_props(&v)) else { continue };
lines.push(format!("---------------- REGIONS ----------------\nArea: {a:.4}\nPerimeter: {per:.4}\nCentroid: X: {:.4} Y: {:.4}\nMoments of inertia: X: {ixx:.4} Y: {iyy:.4}", c.x, c.y));
out.push(json!({ "handle": h.hex(), "area": a, "perimeter": per, "centroid": [c.x, c.y], "ixx": ixx, "iyy": iyy }));
}
if out.is_empty() {
return Err(EngineError::Other("Select closed objects (regions, closed polylines, circles).".into()));
}
Ok(json!({ "objects": out, "message": lines.join("\n") }))
}
fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("radius.measure", "`handle` is required"))?;
@@ -345,4 +410,12 @@ mod tests {
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
}
#[test]
fn rectangle_mass_properties() {
let (a, per, c, _, _) = region_props(&[Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(4.0, 2.0), Vec2::new(0.0, 2.0)]).unwrap();
assert!((a - 8.0).abs() < 1e-12);
assert!((per - 12.0).abs() < 1e-12);
assert!(c.near(Vec2::new(2.0, 1.0), 1e-12));
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
mod named_views;
mod vports;
pub(super) use vports::{config_names, config_tiles, model_vports, tile_model};
pub(super) use vports::{model_vports, tile_model};
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
-16
View File
@@ -71,22 +71,6 @@ fn with_option(p: &Value, option: &str) -> Value {
p
}
/// The tiles of viewport configuration `name` (any case), for MVIEW Restore: `*Active` is the
/// model space as shown now (one full tile with the model view when it isn't tiled), any other
/// name a saved configuration. `None` when there is no such configuration.
pub(in crate::cmd) fn config_tiles(s: &Session, name: &str) -> Option<Vec<VportTile>> {
let name = name.trim();
if name.eq_ignore_ascii_case(VportConfig::ACTIVE) {
return Some(live(s.state().ok()?, s.viewport_px).0);
}
s.doc().ok()?.vports.iter().find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name)).map(|c| c.tiles.clone())
}
/// The saved viewport configurations' names.
pub(in crate::cmd) fn config_names(s: &Session) -> Vec<String> {
s.doc().map(|d| d.vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect()).unwrap_or_default()
}
// ---------------- tile state ----------------
type Rect = (Vec2, Vec2);
-9
View File
@@ -75,15 +75,6 @@ impl Interactive for Guarded {
Vec::new()
})
}
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
if self.broken.get().is_some() {
return None;
}
catch_unwind(AssertUnwindSafe(|| self.inner.drag(s, cursor))).unwrap_or_else(|_| {
self.broken.set(Some("panic in preview"));
None
})
}
}
#[cfg(test)]
+1 -31
View File
@@ -37,9 +37,8 @@ use serde_json::Value;
pub use cadcraft_doc as doc;
pub use cadcraft_geom as geom;
pub use cadcraft_render as render;
pub use cmd::hatch::{PatternChoice, pattern_choices};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
pub use prompt::{Accept, Input, Interactive, Prompt, Step};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
@@ -543,8 +542,6 @@ pub struct Session {
pub active: usize,
pub settings: Settings,
pub running: Option<Running>,
/// A script is running ([`Session::script`]); a SCRIPT command inside it is refused.
in_script: bool,
/// The command suspended while a transparent command (`'ZOOM`, `'PAN`…) prompts inside it;
/// it resumes when that command ends. One level, as in AutoCAD.
pub suspended: Option<Running>,
@@ -585,8 +582,6 @@ pub struct Session {
pub last_used: LastUsed,
/// Where `clipboard` came from and the layers, styles and blocks its objects need.
pub clipboard_source: Option<cmd::clipboard::ClipSource>,
/// The window last picked with PLOTWINDOW (the Plot dialog's "Window <").
pub plot_window: Option<cadcraft_geom::Bounds2>,
}
impl Default for Session {
@@ -608,7 +603,6 @@ impl Session {
active: 0,
settings: Settings::default(),
running: None,
in_script: false,
suspended: None,
log: Vec::new(),
last_command: None,
@@ -630,7 +624,6 @@ impl Session {
last_dim: None,
last_used: LastUsed::default(),
clipboard_source: None,
plot_window: None,
}
}
pub fn new_drawing(&mut self, metric: bool) -> usize {
@@ -1190,16 +1183,6 @@ impl Session {
r.machine.preview(self, cursor).into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
}
/// The active command's preview as a transform of existing objects (see
/// [`Interactive::drag`]), with its extra rubber-band geometry as entities.
pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec<Entity>)> {
let r = self.running.as_ref()?;
let d = r.machine.drag(self, cursor)?;
let common = self.current_common();
let extra = d.extra.iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k.clone() }).collect();
Some((d, extra))
}
/// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a
/// script line (see [`Session::script`]), so `MOVE L 0,0 5,5` ends the selection with the
/// second space and `ERASE L ` with the trailing one. Input after the command ends is ignored.
@@ -1317,20 +1300,7 @@ impl Session {
/// Run a script, as AutoCAD reads one: a space or a line end ends an input, and an empty
/// input (two spaces in a row, a space at the end of a line, an empty line) is Enter. At
/// free-text prompts spaces are part of the text. Lines starting with `;` are comments.
///
/// A script cannot run a script: the SCRIPT command (or another `cmd {json}` call) inside a
/// running one is refused, so two files that run each other cannot recurse until the stack dies.
pub fn script(&mut self, text: &str) -> Result<()> {
if self.in_script {
return Err(EngineError::BadParams { cmd: "script".into(), msg: "a script is already running; nested scripts are not supported".into() });
}
self.in_script = true;
let r = self.script_lines(text);
self.in_script = false;
r
}
fn script_lines(&mut self, text: &str) -> Result<()> {
for line in text.lines() {
// Trailing spaces are inputs (Enter); other trailing whitespace (`\r`) is not.
let line = line.trim_end_matches(|c: char| c.is_whitespace() && c != ' ');
+2 -37
View File
@@ -1,7 +1,7 @@
//! Interactive commands: prompts, inputs and the command-line coordinate grammar.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Mat3, Vec2};
use cadcraft_geom::Vec2;
use serde::Serialize;
use crate::{Result, Session};
@@ -45,34 +45,17 @@ pub struct Prompt {
/// index): Dynamic Input shows its dimensions ([`crate::grip_dims`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub grip: Option<(Handle, usize)>,
/// A visual chooser the UI can show for this prompt; a choice made in it is entered as
/// [`Input::Text`].
#[serde(skip_serializing_if = "Option::is_none")]
pub picker: Option<Picker>,
}
/// A visual chooser for a prompt ([`Prompt::picker`]).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Picker {
/// A hatch pattern by name (see [`crate::pattern_choices`]); holds the current pattern.
HatchPattern(String),
}
impl Prompt {
pub fn new(message: impl Into<String>, accept: Accept) -> Self {
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None, picker: None }
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None }
}
/// The grip being stretched (the `grip` field).
pub fn grip(mut self, g: (Handle, usize)) -> Self {
self.grip = Some(g);
self
}
/// The visual chooser the UI can show (the `picker` field).
pub fn picker(mut self, p: Picker) -> Self {
self.picker = Some(p);
self
}
pub fn kw(mut self, k: &[&str]) -> Self {
self.keywords = k.iter().map(|s| s.to_string()).collect();
self
@@ -182,24 +165,6 @@ pub trait Interactive: Send {
fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec<EntityKind> {
Vec::new()
}
/// The preview as existing objects under one transform, when it is that (MOVE, COPY,
/// ROTATE, SCALE). The UI then draws them from geometry uploaded once instead of rebuilding
/// the transformed copies every frame; `None` falls back to [`Interactive::preview`].
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
None
}
}
/// See [`Interactive::drag`].
#[derive(Clone, Debug)]
pub struct Drag {
pub handles: Vec<Handle>,
pub matrix: Mat3,
/// Rubber-band geometry drawn as well (the base-to-cursor line), and dragged objects the
/// matrix can't draw (text MIRROR keeps readable), already transformed.
pub extra: Vec<EntityKind>,
/// The dragged objects are the clipboard's ([`Session::clipboard`]), not `handles`.
pub clipboard: bool,
}
/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist<angle`, `dist<angle`, `#x,y` (absolute),
+5 -40
View File
@@ -154,31 +154,13 @@ pub(crate) fn osnap_with(
base: Option<Vec2>,
deferred: bool,
) -> Option<SnapHit> {
osnap_with_neighborhood(d, space, ix, cursor, aperture, osmode, base, deferred).0
}
/// Find the best object snap and retain the local-space primitives gathered for that query.
#[allow(clippy::too_many_arguments)]
pub(crate) fn osnap_with_neighborhood(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
cursor: Vec2,
aperture: f64,
osmode: u32,
base: Option<Vec2>,
deferred: bool,
) -> (Option<SnapHit>, Option<Vec<Prim>>) {
if osmode == 0 || osmode & mode::OFF != 0 || !cursor.is_finite() || !(aperture.is_finite() && aperture > 0.0) {
return (None, None);
return None;
}
let (mut best, near_prims) = match osnap_in_with_prims(d, space, ix, cursor, aperture, osmode, base, deferred, &[], None) {
Some((hit, prims)) => (hit, Some(prims)),
None => (None, None),
};
let mut best = osnap_in(d, space, ix, cursor, aperture, osmode, base, deferred, &[], None);
let views = viewport::near(d, space, cursor, aperture);
if views.is_empty() {
return (best, near_prims);
return best;
}
let model = Space::Model;
let model_ix = crate::spatial::index(d, &model);
@@ -208,7 +190,7 @@ pub(crate) fn osnap_with_neighborhood(
best = Some(hit);
}
}
(best, near_prims)
best
}
#[allow(clippy::too_many_arguments)]
@@ -224,22 +206,6 @@ fn osnap_in(
frozen: &[String],
clip: Option<Bounds2>,
) -> Option<SnapHit> {
osnap_in_with_prims(d, space, ix, cursor, aperture, osmode, base, deferred, frozen, clip).and_then(|(hit, _)| hit)
}
#[allow(clippy::too_many_arguments)]
fn osnap_in_with_prims(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
cursor: Vec2,
aperture: f64,
osmode: u32,
base: Option<Vec2>,
deferred: bool,
frozen: &[String],
clip: Option<Bounds2>,
) -> Option<(Option<SnapHit>, Vec<Prim>)> {
let (mut cands, near_prims) = gather_in(d, space, ix, cursor, aperture, osmode, frozen)?;
// Intersections between nearby primitives; Apparent Intersection also where their extensions
// meet (lines extended, arcs as full circles: in 2D, the "apparent" crossing of objects
@@ -270,8 +236,7 @@ fn osnap_in_with_prims(
}
}
}
let hit = finish(cands, &near_prims, cursor, aperture, osmode, base, deferred, clip);
Some((hit, near_prims))
finish(cands, &near_prims, cursor, aperture, osmode, base, deferred, clip)
}
/// Whether a primitive has a meaningful extension (a line, an arc, a circle, an xline or ray).
+2 -3
View File
@@ -116,10 +116,9 @@ pub fn lattice_snap(p: Vec2, d: f64, origin: Vec2, angle: f64) -> Vec2 {
/// Grid snap for a cursor update: with snap on and isometric style, the cursor snapped to the
/// isometric lattice, and the settings with grid snap off so the rectangular grid leaves it be.
/// Otherwise both unchanged, also when PolarSnap (SNAPTYPE 1) is the snap type: snap mode then
/// steps along the tracking paths instead.
/// Otherwise both unchanged.
pub(crate) fn grid_snap(doc: &cadcraft_doc::Drawing, mut s: Settings, p: Vec2) -> (Vec2, Settings) {
if !(s.snapmode && s.isodraft) || s.polarsnap {
if !(s.snapmode && s.isodraft) {
return (p, s);
}
let (origin, angle) = super::grid_frame(doc);
+5 -37
View File
@@ -587,42 +587,14 @@ pub fn candidate(
cfg: &TrackConfig,
base: Option<Vec2>,
) -> Option<Acquired> {
if !can_acquire(cursor, aperture, cfg) {
let nothing_to_acquire = !cfg.otrack && !cfg.running(mode::EXT | mode::PAR | mode::APP);
if nothing_to_acquire || !(cursor.is_finite() && aperture.is_finite() && aperture > 0.0) {
return None;
}
let ix = crate::spatial::index(d, space);
let prims = super::gather(d, space, &ix, cursor, aperture, mode::NEA).map(|(_, p)| p).unwrap_or_default();
candidate_from_prims(cursor, hit, aperture, cfg, base, &prims)
}
pub(crate) fn candidate_with_prims(
cursor: Vec2,
hit: Option<&SnapHit>,
aperture: f64,
cfg: &TrackConfig,
base: Option<Vec2>,
prims: &[Prim],
) -> Option<Acquired> {
if !can_acquire(cursor, aperture, cfg) {
return None;
}
candidate_from_prims(cursor, hit, aperture, cfg, base, prims)
}
fn can_acquire(cursor: Vec2, aperture: f64, cfg: &TrackConfig) -> bool {
(cfg.otrack || cfg.running(mode::EXT | mode::PAR | mode::APP)) && cursor.is_finite() && aperture.is_finite() && aperture > 0.0
}
fn candidate_from_prims(
cursor: Vec2,
hit: Option<&SnapHit>,
aperture: f64,
cfg: &TrackConfig,
base: Option<Vec2>,
prims: &[Prim],
) -> Option<Acquired> {
let hit = hit.filter(|h| h.deferred.is_none() && h.mode != mode::NEA && h.point.is_finite());
let ext_end = if cfg.running(mode::EXT) { extension_near(prims, hit.map_or(cursor, |h| h.point), aperture) } else { None };
let ext_end = if cfg.running(mode::EXT) { extension_near(&prims, hit.map_or(cursor, |h| h.point), aperture) } else { None };
if let Some((at, ext)) = ext_end
&& hit.is_none_or(|h| h.point.dist(at) <= cadcraft_geom::EPS * (1.0 + at.len()))
{
@@ -789,12 +761,8 @@ impl crate::Session {
let last = self.last_segment.filter(|(_, b)| q.base.is_some_and(|base| base.near(*b, 1e-9 * (1.0 + b.len())))).map(|(a, b)| a.angle_to(b));
let cfg = TrackConfig::relative_to(&s, angbase, last);
let osmode = s.osmode | if s.osnaphatch { mode::HATCH } else { 0 };
let ix = crate::spatial::index(&doc, &space);
let (hit, near_prims) = super::osnap_with_neighborhood(&doc, &space, &ix, raw, q.aperture, osmode, q.base, q.deferred);
let cand = match (osmode & mode::HATCH, near_prims.as_deref()) {
(0, Some(prims)) => candidate_with_prims(raw, hit.as_ref(), q.aperture, &cfg, q.base, prims),
_ => candidate(&doc, &space, raw, hit.as_ref(), q.aperture, &cfg, q.base),
};
let hit = super::osnap(&doc, &space, raw, q.aperture, osmode, q.base, q.deferred);
let cand = candidate(&doc, &space, raw, hit.as_ref(), q.aperture, &cfg, q.base);
let wait = self.tracking.hover(cand, q.now, cfg.shift_acquire, q.shift);
if let Some(h) = hit {
self.tracking.last = None;
-44
View File
@@ -342,50 +342,6 @@ fn copy_rotate_scale_mirror() {
assert_eq!(s.doc().unwrap().model.len(), 5);
}
#[test]
fn copy_and_rotate_drag_as_a_transform_of_the_selection() {
fn start(cmd: &str) -> Session {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
for t in [cmd, "all", "", "0,0"] {
s.cmdline(t).unwrap();
}
s
}
let s = start("copy");
let (d, extra) = s.drag(Vec2::new(3.0, 4.0)).expect("COPY drags its selection");
assert_eq!(d.handles.len(), 1);
assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(4.0, 4.0), 1e-9));
assert_eq!(extra.len(), 1, "the base-to-cursor line");
let (d, _) = start("rotate").drag(Vec2::new(0.0, 5.0)).expect("ROTATE drags its selection");
assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(0.0, 1.0), 1e-9));
// MIRROR: geometry is mirrored by the matrix; text, which stays readable, is transformed
// object by object and comes with the extra geometry.
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
s.execute("text", &json!({"at": [2, 2], "height": 1, "text": "A"})).unwrap();
for t in ["mirror", "all", "", "0,0"] {
s.cmdline(t).unwrap();
}
let (d, extra) = s.drag(Vec2::new(0.0, 5.0)).expect("MIRROR drags its selection");
assert_eq!(d.handles.len(), 1, "the line is mirrored by the matrix");
assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(-1.0, 0.0), 1e-9));
assert_eq!(extra.len(), 2, "the base-to-cursor line and the text");
let EntityKind::Text(t) = &extra[1].kind else { panic!("expected the text") };
assert!(t.insert.xy().near(Vec2::new(-2.0, 2.0), 1e-9) && t.rotation.abs() < 1e-9, "mirrored, still reading left to right");
// PASTECLIP drags the clipboard.
let mut s = Session::new();
let l = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
s.execute("copyclip", &json!({"handles": [l["handles"][0].clone()]})).unwrap();
s.cmdline("pasteclip").unwrap();
let (d, _) = s.drag(Vec2::new(5.0, 5.0)).expect("PASTECLIP drags the clipboard");
assert!(d.clipboard && d.handles.is_empty());
assert!(d.matrix.apply(s.clipboard_base).near(Vec2::new(5.0, 5.0), 1e-9));
}
#[test]
fn scale_reference_typed_and_picked() {
// Issue #17: SCALE ▸ Reference scales by new length / reference length.
+4 -48
View File
@@ -65,24 +65,10 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
let deg = cadcraft_geom::norm_angle(a).to_degrees();
match aunits {
1 => {
// Round at the displayed precision before splitting, carrying through a full turn.
let scale = 10i64.pow(prec.saturating_sub(4) as u32);
let seconds_per_unit = match prec {
0 => 3600,
1 | 2 => 60,
_ => 1,
};
let units = (deg * (3600 / seconds_per_unit * scale) as f64).round() as i64;
let total = units * seconds_per_unit % (360 * 3600 * scale);
let (d, m) = (total / (3600 * scale), (total / (60 * scale)) % 60);
match prec {
0 => format!("{d}d"),
1 | 2 => format!("{d}d{m}'"),
_ => {
let s = (total % (60 * scale)) as f64 / scale as f64;
format!("{d}d{m}'{:.*}\"", prec.saturating_sub(4), s)
}
}
let d = deg.floor();
let m = ((deg - d) * 60.0).floor();
let s = (deg - d - m / 60.0) * 3600.0;
format!("{}d{}'{:.*}\"", d as i64, m as i64, prec.saturating_sub(4), s)
}
2 => format!("{:.*}g", prec, deg / 0.9),
3 => format!("{:.*}r", prec, cadcraft_geom::norm_angle(a)),
@@ -294,36 +280,6 @@ mod tests {
assert_eq!(format_distance(30.0, 3, 2), "2'-6.00\"");
}
#[test]
fn dms_display_precision() {
let angle = (12.0 + 34.0 / 60.0 + 56.78912 / 3600.0_f64).to_radians();
let expected = ["13d", "12d35'", "12d35'", "12d34'57\"", "12d34'57\"", "12d34'56.8\"", "12d34'56.79\"", "12d34'56.789\"", "12d34'56.7891\""];
for (prec, expected) in expected.into_iter().enumerate() {
assert_eq!(format_angle(angle, 1, prec as i64), expected, "AUPREC {prec}");
}
}
#[test]
fn dms_rounding_carries() {
let dms = |d: f64, m: f64, s: f64| (d + m / 60.0 + s / 3600.0).to_radians();
let negative = (-0.0001_f64).to_radians();
let cases = [
(dms(29.0, 59.0, 59.6), 0, "30d"),
(dms(29.0, 59.0, 59.6), 2, "30d0'"),
(dms(29.0, 59.0, 59.6), 4, "30d0'0\""),
(dms(29.0, 0.0, 59.99996), 2, "29d1'"),
(dms(29.0, 0.0, 59.99996), 4, "29d1'0\""),
(dms(29.0, 59.0, 59.99996), 5, "30d0'0.0\""),
(dms(29.0, 59.0, 59.99996), 8, "30d0'0.0000\""),
(negative, 0, "0d"),
(negative, 4, "0d0'0\""),
(negative, 5, "359d59'59.6\""),
];
for (angle, prec, expected) in cases {
assert_eq!(format_angle(angle, 1, prec), expected, "AUPREC {prec}");
}
}
#[test]
fn parse_distances() {
assert_eq!(parse_distance("12.5"), Some(12.5));
-7
View File
@@ -198,13 +198,6 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
assert!(dir.join(format!("{}.lock", a.instance)).exists());
assert!(autosave::recoverable(&dir, "someone-else").is_empty());
// After a GPU error a change made a moment ago is autosaved right away, not SAVETIME later.
assert!(a.tick(&s).is_none(), "no change waiting");
draw(&mut s);
assert!(a.tick(&s).is_some(), "SAVETIME (3 minutes) is not up yet");
assert_eq!(a.save_now(&s), 1);
assert_eq!(a.save_now(&s), 0, "being written or written: nothing more to save");
a.shutdown();
assert!(!std::path::Path::new(&path).exists());
assert!(!dir.join(format!("{}.lock", a.instance)).exists());
-150
View File
@@ -1,150 +0,0 @@
//! Drafting follow-ups (#626): isometric snap gives way to PolarSnap, and MVIEW Restore puts a
//! model space viewport configuration on a layout.
use cadcraft_doc::{EntityKind, Space, Viewport, VportTile};
use cadcraft_engine::Session;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::snap::tracking::CursorQuery;
use serde_json::{Value, json};
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
fn setvar(s: &mut Session, name: &str, value: Value) {
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
}
/// Move the cursor to `raw` as the canvas does.
fn hover(s: &mut Session, raw: Vec2, base: Option<Vec2>) -> Vec2 {
let q = CursorQuery { base, deferred: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false };
let r = s.snap_cursor(raw, &q);
s.cursor = r.point;
r.point
}
#[test]
fn isometric_snap_gives_way_to_polarsnap() {
let mut s = Session::new();
setvar(&mut s, "OSMODE", json!(0));
s.execute("isodraft", &json!({ "plane": "left" })).unwrap();
setvar(&mut s, "SNAPMODE", json!(1));
setvar(&mut s, "SNAPUNIT", json!([1, 1]));
s.cmdline("line 0,0").unwrap();
let base = Some(v(0.0, 0.0));
// Grid snap (SNAPTYPE 0): the isometric lattice.
let off = v(0.3, 2.2);
let lattice = hover(&mut s, off, base);
assert!(!lattice.near(off, 1e-6), "snapped to the lattice: {lattice:?}");
// PolarSnap: steps of POLARDIST along the polar paths, and the cursor is free off them.
setvar(&mut s, "SNAPTYPE", json!(1));
setvar(&mut s, "POLARDIST", json!(2.5));
let p = hover(&mut s, v(0.02, 6.1), base);
assert!(p.near(v(0.0, 5.0), 1e-9), "PolarSnap along 90°: {p:?}");
let q = hover(&mut s, off, base);
assert!(q.near(off, 1e-9), "off the paths the isometric lattice no longer snaps: {q:?}");
// Back to grid snap: the lattice again.
setvar(&mut s, "SNAPTYPE", json!(0));
assert!(hover(&mut s, off, base).near(lattice, 1e-9));
}
/// The layout viewports with the given handles, in order.
fn viewports(s: &Session, layout: &str, handles: &Value) -> Vec<Viewport> {
let d = s.doc().unwrap();
let st = d.space(&Space::Paper(layout.into())).unwrap();
handles
.as_array()
.unwrap()
.iter()
.map(|h| {
let h = cadcraft_doc::Handle::parse_hex(h.as_str().unwrap()).unwrap();
match &st.get(h).unwrap().kind {
EntityKind::Viewport(v) => v.clone(),
other => panic!("not a viewport: {other:?}"),
}
})
.collect()
}
/// A model space with two side-by-side tiles saved as "Two" (the left tile zoomed in).
fn two_tiles() -> (Session, Vec<VportTile>) {
let mut s = Session::new();
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
s.execute("zoom", &json!({ "mode": "center", "center": [10, 10], "height": 8 })).unwrap();
s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap();
let tiles = s.doc().unwrap().vports.iter().find(|c| c.name == "Two").unwrap().tiles.clone();
assert_eq!(tiles.len(), 2);
(s, tiles)
}
fn check_restored(vps: &[Viewport], tiles: &[VportTile], window: Bounds2) {
assert_eq!(vps.len(), tiles.len());
for (vp, t) in vps.iter().zip(tiles) {
let at = |f: Vec2| v(window.min.x + f.x * window.width(), window.min.y + f.y * window.height());
let (lo, hi) = (at(t.min), at(t.max));
assert!(vp.center.xy().near((lo + hi) * 0.5, 1e-9), "{vp:?} {t:?}");
assert!((vp.width - (hi.x - lo.x)).abs() < 1e-9 && (vp.height - (hi.y - lo.y)).abs() < 1e-9);
assert!(vp.view_center.near(t.center, 1e-9), "each viewport shows its tile's view");
assert!(vp.view_height >= t.height - 1e-9);
}
}
#[test]
fn mview_restore_puts_a_saved_configuration_on_a_layout() {
let (mut s, tiles) = two_tiles();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let window = Bounds2::new(v(10.0, 10.0), v(210.0, 110.0));
let r = s.execute("mview", &json!({ "option": "restore", "name": "two", "p1": [10, 10], "p2": [210, 110] })).unwrap();
assert_eq!(r["configuration"], "two");
check_restored(&viewports(&s, "Layout1", &r["viewports"]), &tiles, window);
// Typed: name (? lists the saved ones), then two corners.
s.cmdline("mview").unwrap();
s.cmdline("r").unwrap();
assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text());
s.cmdline("?").unwrap();
assert!(s.log.iter().any(|l| l.contains("Two")), "{:?}", s.log);
assert!(s.cmdline("nope").is_err() || s.running.is_some(), "an unknown name asks again");
assert!(s.prompt_text().contains("configuration name"), "{}", s.prompt_text());
s.cmdline("Two").unwrap();
s.cmdline("10,120").unwrap();
s.cmdline("110,170").unwrap();
assert!(s.running.is_none());
assert!(s.log.iter().any(|l| l.contains("2 viewport(s) restored")), "{:?}", s.log);
// Enter takes *Active (the tiles on show) and Fit.
let before = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().count();
s.cmdline("mview").unwrap();
s.cmdline("r").unwrap();
s.cmdline("").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
let after = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().count();
assert_eq!(after, before + 2, "the model space shows two tiles");
// Unknown names, model space and hostile windows are refused.
assert!(s.execute("mview", &json!({ "option": "restore", "name": "nope" })).is_err());
assert!(s.execute("mview", &json!({ "option": "restore", "p1": [0, 0], "p2": [0, 5] })).is_err());
assert!(s.execute("mview", &json!({ "option": "restore", "p1": [0, 0], "p2": [1e300, 5] })).is_err());
assert!(s.execute("mview", &json!({ "option": "restore", "layout": "Model" })).is_err());
}
#[test]
fn mview_restore_ignores_unusable_tiles_from_files() {
let (mut s, _) = two_tiles();
let bad = [
VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN },
];
if let Some(c) = s.doc_mut().unwrap().vports.iter_mut().find(|c| c.name == "Two") {
c.tiles = bad.to_vec();
}
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let r = s.execute("mview", &json!({ "option": "restore", "name": "Two", "p1": [0, 0], "p2": [100, 50] })).unwrap();
let vps = viewports(&s, "Layout1", &r["viewports"]);
assert_eq!(vps.len(), 1, "only the third tile has an area (clamped to the window)");
assert!((vps[0].width - 100.0).abs() < 1e-9 && vps[0].view_height.is_finite() && vps[0].view_height > 0.0);
}
-30
View File
@@ -1,30 +0,0 @@
//! Opening a file that isn't a drawing (SVG, read into entities): the drawing gets no file name,
//! so QSAVE asks for one instead of writing CADCraft's SVG export over the original.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::Drawing;
use serde_json::json;
#[test]
fn an_opened_svg_is_not_saved_over() {
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"exported".to_vec()), plot: None });
let dir = std::env::temp_dir().join(format!("cadcraft-open-svg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let svg = dir.join("Logo.svg");
std::fs::write(&svg, b"<svg/>").unwrap();
let mut s = Session::empty();
s.execute("open", &json!({"path": svg.to_string_lossy()})).unwrap();
let st = s.state().unwrap();
assert_eq!(st.title, "Logo.svg");
assert_eq!(st.path, None);
assert!(s.execute("qsave", &json!({})).is_err(), "QSAVE asks for a file name");
assert_eq!(std::fs::read(&svg).unwrap(), b"<svg/>", "the original is untouched");
// A drawing file keeps its path.
let dxf = dir.join("Plan.dxf");
std::fs::write(&dxf, b"0\nEOF\n").unwrap();
s.execute("open", &json!({"path": dxf.to_string_lossy()})).unwrap();
assert_eq!(s.state().unwrap().path.as_deref(), Some(dxf.to_string_lossy().as_ref()));
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1,52 +0,0 @@
//! Model space is built on all cores for the screen: the chunks are joined in drawing order, so
//! the display list is exactly what one thread builds (vertex and triangle indices included).
use cadcraft_engine::Session;
use cadcraft_engine::doc::Space;
use cadcraft_engine::render::{DisplayList, Options, build, build_entities};
use serde_json::json;
fn same(a: &DisplayList, b: &DisplayList) {
assert_eq!(a.prims.len(), b.prims.len());
assert!(a.prims == b.prims, "primitives differ");
assert!(a.verts == b.verts, "vertices differ");
assert!(a.tris == b.tris, "triangles differ");
assert_eq!((a.bounds.min, a.bounds.max), (b.bounds.min, b.bounds.max));
assert_eq!(a.expanded, b.expanded);
}
#[test]
fn parallel_model_space_matches_one_thread() {
let mut s = Session::new();
// A block with a hatch in it, inserted many times: block expansion in every chunk.
let r = s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 3]})).unwrap();
let h = r["handle"].clone();
s.execute("hatch", &json!({ "handles": [h.clone()] })).unwrap();
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": [h], "keep": "delete"})).unwrap();
// Well over two chunks of mixed entities: lines, circles (curves), text, inserts, points,
// solid fills (triangles), so both vertex and triangle offsets are exercised.
for i in 0..700 {
let x = f64::from(i) * 10.0;
match i % 7 {
0 => s.execute("line", &json!({"points": [[x, 0], [x + 5.0, 5]]})),
1 => s.execute("circle", &json!({"center": [x, 20], "radius": 3})),
2 => s.execute("text", &json!({"at": [x, 40], "height": 2, "text": format!("T{i}")})),
3 => s.execute("insert", &json!({"name": "B", "at": [x, 60]})),
4 => s.execute("point", &json!({"at": [x, 80]})),
5 => {
let r = s.execute("rectang", &json!({"p1": [x, 100], "p2": [x + 4.0, 104]})).unwrap();
s.execute("hatch", &json!({ "handles": [r["handle"].clone()], "pattern": "SOLID" }))
}
_ => s.execute("line", &json!({"points": [[x, 120], [x, 125]]})),
}
.unwrap();
}
let st = s.state().unwrap();
let d = &st.doc;
let n = d.space(&Space::Model).map(|m| m.len()).unwrap_or(0);
assert!(n > 600, "{n} entities: enough for several parallel chunks");
let one_thread = build_entities(d, d.space(&Space::Model).unwrap().iter().map(|e| e.as_ref()), &Options::default());
let parallel = build(d, &Space::Model, &Options::default());
assert!(!parallel.tris.is_empty() && !parallel.verts.is_empty());
same(&parallel, &one_thread);
}
-18
View File
@@ -172,21 +172,3 @@ fn hostile_names_and_values_are_refused() {
assert!(s.execute("plottermanager", &p).is_err(), "{p}");
}
}
#[test]
fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() {
let mut s = Session::new();
// Typed: two corners, with the window's rectangle following the cursor after the first.
s.cmdline("plotwindow").unwrap();
assert_eq!(s.current_prompt().unwrap().message, "Specify first corner");
s.cmdline("10,20").unwrap();
assert_eq!(s.preview(cadcraft_engine::geom::Vec2::new(40.0, 5.0)).len(), 4, "the rubber-band rectangle");
s.cmdline("40,5").unwrap();
assert!(s.running.is_none());
let w = s.plot_window.expect("the picked window");
assert_eq!((w.min.x, w.min.y, w.max.x, w.max.y), (10.0, 5.0, 40.0, 20.0));
// JSON, and a window without area is refused.
let r = s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 4]})).unwrap();
assert_eq!(r["window"], json!([[0.0, 0.0], [3.0, 4.0]]));
assert!(s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 0]})).is_err());
}
-76
View File
@@ -1,76 +0,0 @@
//! PURGE removes multiline styles no multiline uses (#626): not STANDARD, not the current
//! style, not one used by a multiline in model space or in a block that is still inserted.
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, MLine, MLineJust, MLineStyle, Space};
use cadcraft_engine::geom::{Vec2, Vec3};
use serde_json::json;
fn mline(style: &MLineStyle) -> EntityKind {
EntityKind::MLine(MLine::new(style, &[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)], false, MLineJust::Zero, 1.0))
}
fn block(name: &str, style: &MLineStyle, handle: u64) -> Arc<Block> {
let mut b = Block::new(name);
b.entities.push(Entity::new(Handle(handle), mline(style)));
Arc::new(b)
}
/// Styles: Model (used in model space), InBlock (used in the inserted block WALLS), Gone (used
/// only in the unreferenced block OLD), Spare (unused), Current (unused but current).
fn session() -> Session {
let mut s = Session::new();
let d = s.doc_mut().unwrap();
let style = |n: &str| MLineStyle { name: n.into(), ..MLineStyle::default() };
for n in ["Model", "InBlock", "Gone", "Spare", "Current"] {
d.mline_styles.push(style(n));
}
d.header.set_str("CMLSTYLE", "Current");
d.add(&Space::Model, Common::default(), mline(&style("Model"))).unwrap();
d.blocks.insert("WALLS".into(), block("WALLS", &style("InBlock"), 0x9000));
d.blocks.insert("OLD".into(), block("OLD", &style("Gone"), 0x9001));
let ins = Insert {
block: "WALLS".into(),
insert: Vec3::ZERO,
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,
};
d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
s
}
fn styles(s: &Session) -> Vec<String> {
s.doc().unwrap().mline_styles.iter().map(|m| m.name.clone()).collect()
}
#[test]
fn purge_mlinestyles_keeps_used_standard_and_current() {
let mut s = session();
let r = s.execute("purge", &json!({ "type": "mlinestyles" })).unwrap();
assert_eq!(styles(&s), ["STANDARD", "Model", "InBlock", "Gone", "Current"], "{r}");
assert_eq!(r["purged"]["mlinestyles"], json!(["Spare"]));
}
#[test]
fn purge_all_frees_styles_of_purged_blocks() {
let mut s = session();
s.script("-PURGE A * N\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(s.doc().unwrap().block("OLD").is_none());
assert_eq!(styles(&s), ["STANDARD", "Model", "InBlock", "Current"], "{:?}", s.log);
}
#[test]
fn purge_mlinestyles_at_the_command_line() {
let mut s = session();
s.script("-PURGE M Sp* N\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(styles(&s), ["STANDARD", "Model", "InBlock", "Gone", "Current"]);
}
+2 -63
View File
@@ -1,9 +1,9 @@
//! Object snap tracking (OTRACK, F11) and the Extension, Parallel and Apparent Intersection
//! snaps, driven through `Session::snap_cursor` as the UI does, without the UI.
use cadcraft_doc::{Common, EntityKind, Insert, Line, Space};
use cadcraft_doc::EntityKind;
use cadcraft_engine::Session;
use cadcraft_engine::geom::{Vec2, Vec3};
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::snap::tracking::{Acquired, CursorQuery, CursorSnap, DWELL, MAX_ACQUIRED, PathKind, polar_angles};
use cadcraft_engine::snap::{self, mode};
use serde_json::json;
@@ -25,28 +25,6 @@ fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) {
s.execute("line", &json!({ "points": [[a.0, a.1], [b.0, b.1]] })).unwrap();
}
fn insert_block(s: &mut Session, name: &str, at: Vec2) -> String {
s.doc_mut()
.unwrap()
.add(
&Space::Model,
Common::default(),
EntityKind::Insert(Insert {
block: name.into(),
insert: Vec3::new(at.x, at.y, 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,
}),
)
.unwrap()
.hex()
}
fn setvar(s: &mut Session, name: &str, value: serde_json::Value) {
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
}
@@ -59,45 +37,6 @@ fn at(s: &mut Session, cursor: Vec2, base: Option<Vec2>) -> CursorSnap {
s.snap_cursor(cursor, &query(base, 100.0))
}
#[test]
fn default_cursor_tracking_acquires_nested_block_geometry() {
let mut s = session(mode::END | mode::CEN | mode::INT | mode::EXT);
assert!(s.settings.otrack);
let line = s
.doc_mut()
.unwrap()
.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) }))
.unwrap()
.hex();
s.execute("block", &json!({ "name": "INNER", "base": [0, 0], "handles": [line], "keep": "delete" })).unwrap();
let inner = insert_block(&mut s, "INNER", Vec2::ZERO);
s.execute("block", &json!({ "name": "OUTER", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap();
insert_block(&mut s, "OUTER", Vec2::ZERO);
rest(&mut s, v(10.02, 0.02), None);
assert_eq!(s.tracking.acquired.len(), 1);
assert!(matches!(
s.tracking.acquired.first(),
Some(Acquired::Point { at, name, ext: Some(_) }) if at.near(v(10.0, 0.0), 1e-9) && *name == "Endpoint"
));
}
#[test]
fn osnaphatch_geometry_is_not_used_for_tracking_extension() {
let mut s = session(mode::END | mode::EXT);
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 5] })).unwrap();
let boundary = s.doc().unwrap().model.iter().last().unwrap().handle.hex();
s.execute("hatch", &json!({ "handles": [boundary] })).unwrap();
s.execute("erase", &json!({ "handles": [boundary] })).unwrap();
setvar(&mut s, "OSNAPHATCH", json!(1));
rest(&mut s, v(10.02, 4.98), None);
assert_eq!(s.tracking.acquired.len(), 1);
assert!(matches!(s.tracking.acquired.first(), Some(Acquired::Point { ext: None, .. })));
}
/// Rest the cursor at `p` long enough to acquire (or release) what is there, then move off.
fn rest(s: &mut Session, p: Vec2, base: Option<Vec2>) {
s.snap_cursor(p, &query(base, 0.0));
+2 -2
View File
@@ -162,8 +162,8 @@ fn on_off_lock_restore_and_arrangements() {
assert!(viewport(&s, &vh).locked, "{:?}", s.log);
s.execute("mview", &json!({"option": "lock", "locked": false, "handles": [vh]})).unwrap();
assert!(!viewport(&s, &vh).locked);
// Restore needs a viewport configuration that exists (followups_626.rs covers the rest).
assert!(s.execute("mview", &json!({"option": "restore", "name": "none saved"})).is_err());
// Restore needs named viewport configurations.
assert!(s.execute("mview", &json!({"option": "restore"})).is_err());
// 3 viewports arranged Above: the large one on top.
s.cmdline("mview 3 a 200,0 300,100").unwrap();
let vps = layout_viewports(&s);
-11
View File
@@ -213,17 +213,6 @@ pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 {
(n - stroke::GAP / stroke::CAP * height * width_factor).max(0.0)
}
/// Width of the gap MTEXT leaves between two words. Words are measured without their trailing
/// letter gap, so the stroke font's gap is added on both sides of the space, as TEXT lays it out.
pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 {
if let TextFont::Outline(face) = font
&& let Some(w) = ttf::width(face, " ", height, width_factor)
{
return w;
}
stroke_advance(' ', height, width_factor) + stroke::GAP / stroke::CAP * height * width_factor
}
/// The font a piece of text is set in: the built-in stroke font or an installed TrueType /
/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font.
#[derive(Clone, Debug, Default)]
+1 -7
View File
@@ -571,7 +571,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
}
Atom::Word(segs) => {
let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum();
let sw = pending_space.map(|f| crate::space_width(&fmt(f).font, fmt(f).height, fmt(f).wf)).unwrap_or(0.0);
let sw = pending_space.map(|f| text_width(&fmt(f).font, " ", fmt(f).height, fmt(f).wf)).unwrap_or(0.0);
if width > 0.0 && !line.items.is_empty() && line.width + sw + ww > width + 1e-9 {
finish(&mut lines, &mut line, fmt(last_fmt), para);
} else if !line.items.is_empty() || pending_space.is_some() {
@@ -693,12 +693,6 @@ mod tests {
assert!((c.x - 5.0).abs() < 1e-9 && (c.y - 5.0).abs() < 1e-9);
}
#[test]
fn stroke_word_space_matches_text() {
let l = layout_mtext("A B", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0);
assert!((l.bounds.width() - crate::line_width("A B", 1.0, 1.0)).abs() < 1e-9);
}
fn lay(s: &str, width: f64) -> MTextLayout {
layout_mtext(s, Vec2::ZERO, 1.0, width, 1, 0.0, 1.0)
}
+1 -1
View File
@@ -426,7 +426,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let paras = Paragraphs::scan(contents);
let (atoms, fmts) = parse(contents, base.clone());
let fmt = |i: usize| fmts.get(i).unwrap_or(base);
let space_w = |f: usize| crate::space_width(&fmt(f).font, fmt(f).height, fmt(f).wf);
let space_w = |f: usize| crate::text_width(&fmt(f).font, " ", fmt(f).height, fmt(f).wf);
let mut lines: Vec<PLine> = Vec::new();
let mut k = 0usize;
+9 -16
View File
@@ -16,7 +16,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use cadcraft_geom::Vec2;
use skrifa::instance::{LocationRef, Size};
@@ -580,7 +580,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
/// font's cap height.
#[derive(Default)]
pub(crate) struct GlyphOutline {
pub(crate) contours: Arc<[Vec<Vec2>]>,
pub(crate) contours: Vec<Vec<Vec2>>,
pub(crate) advance: f64,
pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape).
@@ -598,13 +598,12 @@ enum GlyphKey {
}
/// Cached glyph outlines keyed by (font bytes address, length, face, glyph). Fonts live for the
/// whole process in the font table, so the address identifies them. A read-write lock: display
/// lists are built on all cores and nearly every lookup is a hit, which a mutex serialized.
/// whole process in the font table, so the address identifies them.
type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc<GlyphOutline>>;
fn glyph_cache() -> &'static RwLock<GlyphCache> {
static C: OnceLock<RwLock<GlyphCache>> = OnceLock::new();
C.get_or_init(|| RwLock::new(HashMap::new()))
fn glyph_cache() -> &'static Mutex<GlyphCache> {
static C: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
const MAX_CACHED_GLYPHS: usize = 40_000;
@@ -692,15 +691,9 @@ pub(crate) fn glyph_by_id(face: &Face, gid: u32) -> Arc<GlyphOutline> {
outline(face, GlyphKey::Id(gid))
}
/// Closed, flattened contours of a glyph at 1000 font units per em. Shared with the outline
/// cache so renderers can tessellate once before applying each occurrence's affine placement.
pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec<Vec2>]> {
glyph_by_id(face, u32::from(gid)).contours.clone()
}
fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k);
if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).get(&key) {
if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
return g.clone();
}
let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) };
@@ -716,8 +709,8 @@ fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
}
let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5));
let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32());
let g = Arc::new(GlyphOutline { contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) });
let mut cache = glyph_cache().write().unwrap_or_else(PoisonError::into_inner);
let g = Arc::new(GlyphOutline { contours: pen.contours, advance, cap, mapped, gid, face: Some(face.clone()) });
let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner);
if cache.len() > MAX_CACHED_GLYPHS {
cache.clear();
}
-1
View File
@@ -20,4 +20,3 @@ thiserror = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
miniz_oxide = "0.8"
quick-xml = { workspace = true }
+6 -12
View File
@@ -6,14 +6,13 @@
//!
//! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the
//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS`, `CADCRAFT_LAYERSTATES`,
//! `CADCRAFT_PLOTSTYLES`, `CADCRAFT_HANDSEED` and `CADCRAFT_MLEADERSTYLE` (XRECORDs). Other
//! readers keep or ignore them.
//! `CADCRAFT_PLOTSTYLES` and `CADCRAFT_HANDSEED` (XRECORDs). Other readers keep or ignore them.
use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::{ArrayParams, AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
use cadcraft_dxf::{Tag, Version};
use cadcraft_dxf::Tag;
use cadcraft_render::Arrowhead;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -33,8 +32,6 @@ pub(crate) const ANNOTATION_KEY: &str = "CADCRAFT_ANNOTATION";
pub(crate) const PLOT_STYLE_NAMES_KEY: &str = "ACAD_PLOTSTYLENAME";
/// Named-object-dictionary key of the XRECORD holding the drawing's next entity handle (hex).
pub(crate) const HANDSEED_KEY: &str = "CADCRAFT_HANDSEED";
/// Named-object-dictionary key of the XRECORD holding the current multileader style.
pub(crate) const MLEADER_STYLE_KEY: &str = "CADCRAFT_MLEADERSTYLE";
/// Most plot style tables and plot style names read from a file.
pub(crate) const MAX_PLOT_STYLE_TABLES: usize = 1000;
pub(crate) const MAX_PLOT_STYLE_NAMES: usize = 100_000;
@@ -141,14 +138,11 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[
/// DIMSAH: separate arrow blocks in use.
pub(crate) const DIMSAH: i32 = 173;
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1,
/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them
/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of
/// groups an R2000 reader doesn't expect. The reader takes both.
/// Variables newer than the DXF version written (R2000): DIMFXL, DIMJOGANG, DIMARCSYM,
/// DIMFXLON, DIMLTYPE, DIMLTEX1, DIMLTEX2. The writer puts them in a style's `CADCRAFT`
/// "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of DIMSTYLE groups that
/// an R2000 reader doesn't expect; the reader takes both.
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347];
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose
/// files are the 2004 format (AC1018).
pub(crate) const NEWER_SINCE: Version = Version::R2004;
/// Xdata list name of [`NEWER_CODES`] values.
pub(crate) const DIMVARS_LIST: &str = "DIMVARS";
+2 -24
View File
@@ -23,17 +23,8 @@ use cadcraft_dxf::{Section, Tag};
/// starts with `CADCRAFT`): they are read into the model, not kept raw. A change that starts
/// modelling another entry adds it here. (An entry the writer writes is also never written
/// twice: the kept one is left out.)
const MODELLED_KEYS: &[&str] = &[
"ACAD_GROUP",
"ACAD_LAYOUT",
"ACAD_MLEADERSTYLE",
"ACAD_MLINESTYLE",
"ACAD_PLOTSTYLENAME",
"ACAD_SCALELIST",
"ACAD_TABLESTYLE",
"ACAD_IMAGE_DICT",
"ACAD_IMAGE_VARS",
];
const MODELLED_KEYS: &[&str] =
&["ACAD_GROUP", "ACAD_LAYOUT", "ACAD_MLEADERSTYLE", "ACAD_PLOTSTYLENAME", "ACAD_TABLESTYLE", "ACAD_IMAGE_DICT", "ACAD_IMAGE_VARS"];
/// Extension dictionary entries not kept raw: dimension associativity (CADCraft reads and
/// rewrites it), draw order and annotation scale contexts (written from their own models by the
@@ -598,19 +589,6 @@ impl Map<'_> {
while let Some(r) = o.tags.get(i) {
i += 1;
if r.code == 102 && r.value.trim().starts_with('{') {
// A `{NAME` group is a handle list (`{ACAD_REACTORS 330 … }`) only when every
// group inside it is a reference. Application data groups also use 102 brackets
// with mixed values and their own closing form — the AcDs thumbnail schema's
// `{ATTRRECORD 341 h 2 name 280 1 291 1 ATTRRECORD}` — and collapsing those into
// a handle list rewrote the handle under the LAST code read (291, a bool) and
// dropped the rest, which strict DXF readers reject. Those are written as read,
// references remapped, by the plain path below.
let end = (i..o.tags.len()).find(|&k| o.tags[k].code == 102).unwrap_or(o.tags.len());
let inner = &o.tags[i..end];
if !inner.is_empty() && !inner.iter().all(|n| is_pointer(n.code)) {
out.push(tag(r));
continue;
}
let name = r.value.trim().trim_start_matches('{').to_string();
let mut hs = Vec::new();
let mut code = 330;
+3 -7
View File
@@ -742,10 +742,9 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
}
let solid = solid || pattern.eq_ignore_ascii_case("SOLID");
let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians());
// Keep the file's definition lines even for a name the library knows: the library's versions
// of some patterns (AR-CONC) only approximate AutoCAD's, and drawing them at the file's scale
// could be hundreds of times denser than the hatch the author saw.
let pattern_lines = if solid { Vec::new() } else { pattern_definition(tags, scale, angle, origin) };
// Definition lines matter only for a pattern the library doesn't define.
let pattern_lines =
if solid || cadcraft_doc::library::pattern(&pattern).is_some() { Vec::new() } else { pattern_definition(tags, scale, angle, origin) };
Some(EntityKind::Hatch(Hatch {
pattern: pattern.to_ascii_uppercase(),
solid,
@@ -1844,9 +1843,6 @@ impl Objects {
}
fn apply(self, d: &mut Drawing, rx: &Rx) {
if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) {
d.header.set_str("CMLEADERSTYLE", &style);
}
// Table styles (named by their ACAD_TABLESTYLE dictionary entries).
for (h, tags) in &self.table_styles {
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
+12 -105
View File
@@ -1,7 +1,6 @@
//! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000). Later
//! versions keep that structure (the groups they add are optional, DXF Reference) and differ in
//! `$ACADVER`, from 2004 in the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller
//! picks the text encoding) and MULTILEADER entities (older files get LEADER + MTEXT).
//! `$ACADVER` and, from 2007, in UTF-8 text; the caller picks the text encoding.
use std::collections::HashMap;
@@ -122,17 +121,9 @@ struct Ctx {
block_records: HashMap<String, String>,
/// Raster images' IMAGEDEF, IMAGEDEF_REACTOR and dictionary handles.
images: crate::dxf_image::Plan,
/// The version the file ends up in (the DWG version for a DWG save): it decides which
/// entity types exist (MULTILEADER from 2007).
target: Version,
}
impl Ctx {
/// Whether multileaders are written as MULTILEADER (AutoCAD 2007 / AC1021 and later);
/// older files get LEADER + MTEXT/INSERT.
fn native_mleader(&self) -> bool {
self.target >= Version::R2007
}
fn style(&self, name: &str) -> Option<String> {
self.styles.get(&name.to_ascii_uppercase()).cloned()
}
@@ -842,7 +833,6 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.s(1000, c);
}
}
EntityKind::MLeader(m) if !cx.native_mleader() => mleader_fallback(w, d, e, m, owner, paper, cx),
EntityKind::MLeader(m) => {
w.s(0, "MULTILEADER");
common(w, e, owner, paper, "AcDbMLeader");
@@ -924,12 +914,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.f(52, h.angle.to_degrees());
w.f(41, h.scale);
w.i(77, 0);
// The lines read from the file, else the library's pattern of that name.
let lines = if h.pattern_lines.is_empty() {
cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default()
} else {
h.pattern_lines.clone()
};
let pat = cadcraft_doc::library::pattern(&h.pattern);
// A pattern the library doesn't define keeps the lines read from its file.
let lines = pat.map(|p| p.lines).unwrap_or_else(|| h.pattern_lines.clone());
w.i(78, lines.len() as i64);
for pl in lines {
let ang = pl.angle.to_radians() + h.angle;
@@ -1029,36 +1016,6 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
}
}
/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text
/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one);
/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`.
fn code_page(d: &Drawing, version: Version) -> String {
let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default();
let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some();
if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() }
}
/// A multileader for a file older than 2007 (no MULTILEADER): a LEADER per leader line, ending
/// at the landing, plus its MTEXT or its block's INSERT, with the multileader's properties.
fn mleader_fallback(w: &mut W, d: &Drawing, e: &Entity, m: &MLeader, owner: &str, paper: bool, cx: &Ctx) {
let part = |w: &mut W, kind: EntityKind| Entity { handle: Handle::parse_hex(&w.h()).unwrap_or(Handle(0)), common: e.common.clone(), kind };
let style = d.header.str("DIMSTYLE", "Standard");
for arm in m.leaders.iter().take(64) {
let mut vertices = arm.clone();
vertices.push(m.landing);
let le = part(w, EntityKind::Leader(Leader { vertices, arrow: true, spline: m.spline, style: style.clone() }));
entity(w, d, &le, owner, paper, cx);
}
if let Some(t) = &m.text {
let te = part(w, EntityKind::MText(t.clone()));
entity(w, d, &te, owner, paper, cx);
}
if let Some(ins) = &m.block {
let ie = part(w, EntityKind::Insert(ins.clone()));
entity(w, d, &ie, owner, paper, cx);
}
}
/// A hatch's gradient fill (DXF Reference, HATCH group codes 450–470): two-colour gradient
/// with its rotation (radians), shift (0 = centered, 1 = shifted) and per-colour 463 records
/// carrying ACI (63) and, for true colours, the RGB value (421). Those groups belong to R2004+
@@ -1449,18 +1406,6 @@ pub fn write(d: &Drawing) -> String {
/// The drawing's DXF tags, declaring `version` in `$ACADVER`.
pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
tags_for(d, version, version)
}
/// The R2000 DXF the DWG bridge converts to a `dwg` version DWG: its entity types are the DWG
/// version's (MULTILEADER from 2007).
pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {
cadcraft_dxf::write_ascii(&tags_for(d, Version::R2000, dwg))
}
/// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the
/// file ends up in: `version`, or the DWG version the bridge writes) has.
fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
// Structural handles start above every entity handle.
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new() };
// Pre-allocate handles for block records.
@@ -1485,7 +1430,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
.collect();
user_blocks.iter_mut().for_each(|b| b.1 = format!("{:X}", 0));
let user_blocks: Vec<(String, String, &Block)> = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect();
let mut cx = Ctx { paper_views: paper_views(d), target, ..Ctx::default() };
let mut cx = Ctx { paper_views: paper_views(d), ..Ctx::default() };
cx.block_records = user_blocks.iter().map(|(n, h, _)| (n.to_ascii_uppercase(), h.clone())).collect();
// Dimension blocks.
let mut dim_defs: Vec<(String, String, Vec<Entity>)> = Vec::new(); // filled below
@@ -1610,9 +1555,6 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
// The drawing's own next handle: table records and objects are numbered above it, so
// reading it back (instead of $HANDSEED) keeps their handles across saves (#495).
xrecords.push((dxf_ext::HANDSEED_KEY, w.h(), vec![format!("{:X}", d.handseed)]));
if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) {
xrecords.push((dxf_ext::MLEADER_STYLE_KEY, w.h(), vec![name.to_string()]));
}
if let Some(chunks) = dxf_ext::constraint_chunks(&d.constraints, &d.parametric) {
xrecords.push((dxf_ext::CONSTRAINTS_KEY, w.h(), chunks));
}
@@ -1650,7 +1592,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
w.s(9, "$ACADVER");
w.s(1, version.code());
w.s(9, "$DWGCODEPAGE");
w.s(3, code_page(d, version));
w.s(3, "ANSI_1252");
let ext = d.extents(&Space::Model);
if !ext.is_empty() {
w.s(9, "$EXTMIN");
@@ -1659,6 +1601,11 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
w.p(10, ext.max.to3(0.0));
}
header_vars(&mut w, d);
// The current multileader style (an R2007 variable; older readers skip it).
if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) {
w.s(9, "$CMLEADERSTYLE");
w.s(2, name);
}
let seed_pos = w.t.len();
w.s(9, "$HANDSEED");
w.s(5, "0");
@@ -1682,7 +1629,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
if !cx.assoc.is_empty() {
classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false));
}
if cx.native_mleader() && every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) {
if every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) {
classes.push(("MULTILEADER", "AcDbMLeader", 1025, true));
}
if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) {
@@ -1920,11 +1867,6 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue };
if !dxf_ext::NEWER_CODES.contains(code) {
w.dimval(*code, enc, false);
continue;
}
// Table groups where the file version has them, else xdata.
if version >= dxf_ext::NEWER_SINCE {
w.dimval(*code, enc, false);
} else if v.get(*field) != defaults.get(*field) {
newer.i(1070, i64::from(*code));
newer.dimval(*code, enc, true);
@@ -2313,45 +2255,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
*t = Tag::s(5, seed);
}
crate::dxf_raw::finish(&mut w.t, d);
add_class_instance_counts(&mut w.t);
mark_xrefs(&mut w.t, d);
w.t
}
/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish`
/// restores foreign classes and objects so every class gets its actual instance count.
fn add_class_instance_counts(tags: &mut Vec<Tag>) {
let mut counts: HashMap<String, i64> = HashMap::new();
for t in tags.iter().filter(|t| t.code == 0) {
let kind = t.str();
if kind != "CLASS" {
*counts.entry(kind.to_ascii_uppercase()).or_default() += 1;
}
}
let mut start = 0;
while start < tags.len() {
if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") {
start += 1;
continue;
}
let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i);
let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
let count = counts.get(&name).copied().unwrap_or(0);
let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) {
tags[start + 1 + i] = Tag::i(91, count);
false
} else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) {
tags.insert(start + 2 + i, Tag::i(91, count));
true
} else {
false
};
// Inserting the group shifts the next record's start by one.
start = end + usize::from(inserted);
}
}
/// Xref blocks (DXF Reference, BLOCK): bit 4 of group 70 and the external drawing's path in
/// group 1, set on the written BLOCK records of blocks that have an `xref_path`.
fn mark_xrefs(tags: &mut [Tag], d: &Drawing) {
+3 -6
View File
@@ -1,4 +1,4 @@
//! CADCraft file formats: DXF/DWG read, SVG and SVGZ import, SVG/PNG/PDF export (plotting).
//! CADCraft file formats: DXF read/write, SVG, PNG and PDF export (plotting).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -15,7 +15,6 @@ mod dxf_xref;
pub mod pdf;
pub mod plotter;
pub mod svg;
mod svg_read;
/// Multilines and their styles (MLINE, MLINESTYLE).
mod dxf_mline;
@@ -55,15 +54,13 @@ fn ext(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name
/// (SVG and gzip-compressed SVGZ).
/// Read a drawing; the format comes from the content (DXF ASCII/binary) or the name.
pub fn read(bytes: &[u8], name: &str) -> Result<Drawing> {
if cadcraft_dwg::is_dwg(bytes) {
let dxf = cadcraft_dwg::dwg_to_dxf(bytes).map_err(IoError::Format)?;
return dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")));
}
match ext(name).as_str() {
"svg" | "svgz" => svg_read::read(bytes),
"dxf" | "" => dxf_read::read(bytes),
e if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())),
e => Err(IoError::Unsupported(e.to_string())),
@@ -95,7 +92,7 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<Vec<u8>
// gradients, so a drawing from an R2000 DXF doesn't lose them by default.
"dwg" if !opts.binary => {
let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));
cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
cadcraft_dwg::dxf_to_dwg_as(dxf_write::write(d).as_bytes(), version.code()).map_err(IoError::Format)
}
e if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{e}`"))),
_ => write_framed(d, name, None),
+5 -68
View File
@@ -70,12 +70,6 @@ pub struct PdfOptions {
/// embedded font subset (like AutoCAD's PDFSHX for its own fonts). Stroke-font text is
/// always geometry.
pub text_as_geometry: bool,
/// Margins (left, bottom, right, top; mm) of the plot device's paper, bounding the printable
/// area of a model-space plot. `None` keeps the page setup's (none for model space).
pub margins: Option<[f64; 4]>,
/// Entity hyperlinks read from a drawing file (`PE_URL` extended data) become link
/// annotations too, after [`PdfOptions::links`]. Default on.
pub hyperlinks: bool,
}
impl Default for PdfOptions {
@@ -95,15 +89,13 @@ impl Default for PdfOptions {
include_off_layers: false,
links: Vec::new(),
text_as_geometry: false,
margins: None,
hyperlinks: true,
}
}
}
impl PdfOptions {
/// Parse `{paper?, width?, height?, landscape?, fit?, scale?, lineweights?, compress?, title?,
/// window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm), hyperlinks?: bool}`.
/// window?: [[x1, y1], [x2, y2]]}`.
pub fn from_json(v: &Value) -> PdfOptions {
let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let paper_mm = match (num("width"), num("height")) {
@@ -127,21 +119,10 @@ impl PdfOptions {
compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true),
title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(),
window: window(v.get("window")),
margins: margins(v.get("margins")),
hyperlinks: v.get("hyperlinks").and_then(Value::as_bool).unwrap_or(true),
}
}
}
/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`.
pub(crate) fn margins(v: Option<&Value>) -> Option<[f64; 4]> {
let a: Vec<f64> = v?.as_array()?.iter().take(5).filter_map(Value::as_f64).collect();
match a[..] {
[l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]),
_ => None,
}
}
/// A plot window from `[[x1, y1], [x2, y2]]`: finite corners and a positive size, else `None`.
fn window(v: Option<&Value>) -> Option<Bounds2> {
let corner = |i: usize| {
@@ -233,10 +214,6 @@ pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<PageSetup>
if let Some(t) = &o.plot_style_table {
page.plot_style_table = t.clone();
}
// The plot device's margins bound a model-space plot (a layout's come with its page setup).
if let (Space::Model, Some(m)) = (space, o.margins) {
page.margins_mm = m;
}
page.width_mm = page.width_mm.clamp(1.0, MAX_SHEET_MM);
page.height_mm = page.height_mm.clamp(1.0, MAX_SHEET_MM);
Ok(page)
@@ -292,8 +269,7 @@ pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u
} else {
d
};
let links = all_links(d, o);
let pages = spaces.iter().map(|s| sheet(drawn, s, o, &links)).collect::<Result<Vec<_>>>()?;
let pages = spaces.iter().map(|s| sheet(drawn, s, o)).collect::<Result<Vec<_>>>()?;
let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() };
let fonts = if o.text_as_geometry { fonts::Fonts::default() } else { fonts::Fonts::collect(pages.iter().map(|p| &p.list)) };
Ok(assemble(d, &pages, &fonts, o, &title))
@@ -316,7 +292,7 @@ struct SheetPage {
}
/// Plot one space.
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String)]) -> Result<SheetPage> {
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<SheetPage> {
let page = page_for(d, space, o)?;
let unit_mm = paper::paper_unit_mm(d);
let sheet = Sheet::from_page(&page, unit_mm);
@@ -325,16 +301,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String
Space::Model => true,
});
let plot_style_table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(std::sync::Arc::new);
let ropts = cadcraft_render::Options {
tolerance: 0.001,
min_dash: 0.0,
text: true,
fill: true,
lineweights,
view_height: 0.0,
plot_style_table,
view: None,
};
let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights, view_height: 0.0, plot_style_table };
let k = unit_mm * PT_PER_MM;
let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty());
let fit = o.fit.unwrap_or(window.is_some() || matches!(space, Space::Model));
@@ -372,40 +339,10 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String
}
}
}
let links = links(&list, &map, &clip_pt, links_to);
let links = links(&list, &map, &clip_pt, &o.links);
Ok(SheetPage { media, list, map, clip_pt, layers, links, label: sheet_name(space) })
}
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
if !o.hyperlinks {
return out;
}
let given: std::collections::HashSet<Handle> = out.iter().map(|(h, _)| *h).collect();
for (h, extra) in &d.foreign.entities {
if out.len() >= MAX_LINKS {
break;
}
if given.contains(h) {
continue;
}
if let Some(url) = pe_url(&extra.xdata) {
out.push((*h, url));
}
}
out
}
/// The link target of `PE_URL` extended data: the first string (group 1000) after
/// `1001 PE_URL`, before the next application's data.
fn pe_url(xdata: &[cadcraft_doc::RawTag]) -> Option<String> {
let start = xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("PE_URL"))?;
let url = xdata.get(start + 1..)?.iter().take_while(|t| t.code != 1001).find(|t| t.code == 1000)?.value.trim();
(!url.is_empty() && url.len() <= MAX_URL).then(|| url.to_string())
}
/// Link annotation rectangles: the area (in points) each linked entity covers on the sheet,
/// clipped to the plot area.
fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, links: &[(Handle, String)]) -> Vec<(Bounds2, String)> {
+28 -33
View File
@@ -19,10 +19,9 @@ use crate::{IoError, PdfOptions, Result, pdf};
pub const MAX_PNG_PIXELS: f64 = 40.0e6;
/// Plot `space` with JSON options (the engine's `plot` hook): PDF unless `output` is `png` or
/// `svg`. `margins: [l, b, r, t]` (mm) bound the printable area of this plot (model space or a
/// layout).
/// `svg`. `margins: [l, b, r, t]` (mm) replaces a layout's printable area for this plot.
pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
let margins = pdf::margins(opts.get("margins"));
let margins = margins(opts.get("margins"));
let with_margins;
let d = match (space, margins) {
(Space::Paper(n), Some(m)) => {
@@ -37,15 +36,28 @@ pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
};
match opts.get("output").and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() {
None | Some("pdf") => pdf::plot(d, space, opts),
Some("png") => png(d, space, opts),
Some("png") => png(d, space, opts, margins),
Some("svg") => svg(d, space, opts),
Some(other) => Err(IoError::Unsupported(other.to_string())),
}
}
/// The sheet a plot draws on (a model-space plot's printable area inside the device margins).
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<(Sheet, cadcraft_doc::PageSetup)> {
let page = pdf::page_for(d, space, o)?;
/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`.
fn margins(v: Option<&Value>) -> Option<[f64; 4]> {
let a: Vec<f64> = v?.as_array()?.iter().filter_map(Value::as_f64).collect();
match a[..] {
[l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]),
_ => None,
}
}
/// The sheet a plot draws on, and whether the drawing is fitted to its printable area (model
/// space, or a layout asked to `fit`).
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, margins: Option<[f64; 4]>) -> Result<(Sheet, cadcraft_doc::PageSetup)> {
let mut page = pdf::page_for(d, space, o)?;
if let (Space::Model, Some(m)) = (space, margins) {
page.margins_mm = m;
}
Ok((Sheet::from_page(&page, paper::paper_unit_mm(d)), page))
}
@@ -63,9 +75,9 @@ fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 {
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> Result<Vec<u8>> {
let o = PdfOptions::from_json(opts);
let (sheet, page) = sheet(d, space, &o)?;
let (sheet, page) = sheet(d, space, &o, margins)?;
let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0);
// Pixels per paper unit, lowered so the image stays within the caps.
let mut k = sheet.unit_mm / 25.4 * dpi;
@@ -89,14 +101,11 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
// Chord tolerance: a fifth of a pixel.
let tolerance = (0.2 / (k * est)).max(1e-9);
let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() };
let mut list = cadcraft_render::build_plot(d, space, &ropts);
let list = cadcraft_render::build_plot(d, space, &ropts);
let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k);
let b = window.unwrap_or(list.bounds);
let s = if fit { fit_scale(&b, &sheet, &o) } else { 1.0 };
// Plot style ends, joins and fill patterns at the plot scale, as PDF draws them (a layout's
// are already drawn).
cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm);
let view = if fit {
let b = window.unwrap_or(list.bounds);
let s = fit_scale(&b, &sheet, &o);
let from = if b.is_empty() { Vec2::ZERO } else { b.center() };
// The world point at the image centre, with `from` on the printable area's centre.
let center = from + (half - sheet.printable.center()) / s;
@@ -111,25 +120,11 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
let o = PdfOptions::from_json(opts);
let Space::Model = space else {
let window = sheet(d, space, &o)?.0.bounds();
return Ok(crate::svg::export_window(d, space, Some(window)).into_bytes());
let window = match space {
Space::Paper(_) => Some(sheet(d, space, &o, None)?.0.bounds()),
Space::Model => o.window.filter(|w| !w.is_empty()),
};
// Model space as plotted: the plot style table, lineweights, and the table's ends, joins and
// fills at the scale that fits the window (or the extents) on the device's paper.
let (sheet, page) = sheet(d, space, &o)?;
let window = o.window.filter(|w| !w.is_empty());
let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new);
let lineweights = o.lineweights.unwrap_or(true);
let est = fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o);
// Chord tolerance: about 0.05 mm on paper.
let tolerance = (0.05 / (sheet.unit_mm * est)).max(1e-9);
let tolerance = if tolerance.is_finite() { tolerance } else { cadcraft_render::Options::default().tolerance };
let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() };
let mut list = cadcraft_render::build_plot(d, space, &ropts);
let s = fit_scale(&window.unwrap_or(list.bounds), &sheet, &o);
cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm);
Ok(crate::svg::export_list(&list, window).into_bytes())
Ok(crate::svg::export_window(d, space, window).into_bytes())
}
#[cfg(test)]
-5
View File
@@ -16,11 +16,6 @@ pub fn export(d: &Drawing, space: &Space) -> String {
/// or the extents with a 2% margin when `window` is `None`.
pub fn export_window(d: &Drawing, space: &Space, window: Option<Bounds2>) -> String {
let list = cadcraft_render::build(d, space, &cadcraft_render::Options::default());
export_list(&list, window)
}
/// Write a display list (as [`export_window`] does for a space's own).
pub fn export_list(list: &cadcraft_render::DisplayList, window: Option<Bounds2>) -> String {
let window = window.filter(|w| !w.is_empty());
let b = window.unwrap_or(list.bounds);
let (w, h) = if b.is_empty() { (1.0, 1.0) } else { (b.width().max(1e-9), b.height().max(1e-9)) };
-945
View File
@@ -1,945 +0,0 @@
//! SVG → Drawing: the static W3C SVG subset that drawing tools emit.
//!
//! Reads shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`), `path`
//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping with `transform`
//! attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers
//! (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres with
//! the page's top-left corner at the origin and Y pointing up — where a laser or pen job
//! wants it.
//!
//! Not read (each is a follow-up): CSS class rules (`<style>`), `use` references, fills,
//! gradients and hatch patterns, text, embedded raster images, rounded rectangle corners
//! (`rx`/`ry`), and elliptical arcs under non-uniform transforms (those are flattened into
//! line segments instead of losing the shape).
use std::collections::HashSet;
use cadcraft_color::Color;
use cadcraft_doc::*;
use cadcraft_geom::{Mat3, PolyVertex, Spline, Vec2, Vec3};
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use crate::{IoError, Result};
/// A decompressed SVGZ payload larger than this is refused (decompression bomb guard).
const MAX_DECOMPRESSED: usize = 64 << 20;
/// Shape elements per file (rect, circle, path, …; each counted once).
const MAX_ELEMENTS: usize = 500_000;
/// Path data segments per `d` attribute.
const MAX_PATH_SEGMENTS: usize = 100_000;
/// Vertices in one polyline, and control points in one spline.
const MAX_CURVE_POINTS: usize = 100_001;
/// Layers created from Inkscape layer groups.
const MAX_LAYERS: usize = 256;
/// Nested `<g>`/`<svg>` transforms.
const MAX_TRANSFORM_DEPTH: usize = 64;
/// Flattened chords per elliptical arc under a non-uniform transform.
const MAX_ARC_CHORDS: usize = 10_000;
/// Elements whose whole subtree is skipped (paint servers, text, raster images, metadata).
const SKIP: &[&[u8]] = &[
b"defs",
b"symbol",
b"clipPath",
b"mask",
b"pattern",
b"marker",
b"linearGradient",
b"radialGradient",
b"filter",
b"style",
b"text",
b"image",
b"title",
b"desc",
b"metadata",
];
/// Elements that hold children and push a transform stack frame.
const CONTAINERS: &[&[u8]] = &[b"g", b"svg", b"a", b"switch"];
/// Read an SVG document, or a gzip-compressed one (`.svgz`, Inkscape's "Compressed SVG").
pub fn read(bytes: &[u8]) -> Result<Drawing> {
let unpacked;
let plain: &[u8] = if bytes.starts_with(&[0x1f, 0x8b]) {
unpacked = gunzip(bytes)?;
&unpacked
} else {
bytes
};
let text = std::str::from_utf8(plain).map_err(|_| IoError::Format("SVG files must be UTF-8".into()))?;
let mut parser = Parser::new();
parser.parse(text)?;
Ok(parser.drawing)
}
/// Decompress a gzip member (RFC 1952) with a hard output cap.
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
let damaged = || IoError::Format("this SVGZ file's gzip stream is damaged".into());
if bytes.get(2) != Some(&8) {
return Err(damaged());
}
let flg = bytes.get(3).copied().unwrap_or(0);
let mut pos = 10usize;
if flg & 0x04 != 0 {
// FEXTRA
let extra = bytes.get(pos..pos + 2).ok_or_else(damaged)?;
pos += 2 + usize::from(u16::from_le_bytes([extra[0], extra[1]]));
}
if flg & 0x08 != 0 {
pos = skip_cstring(bytes, pos).ok_or_else(damaged)?; // FNAME
}
if flg & 0x10 != 0 {
pos = skip_cstring(bytes, pos).ok_or_else(damaged)?; // FCOMMENT
}
if flg & 0x02 != 0 {
pos = pos.saturating_add(2); // FHCRC
}
let end = bytes.len().checked_sub(8).filter(|e| *e >= pos).ok_or_else(damaged)?;
miniz_oxide::inflate::decompress_to_vec_with_limit(&bytes[pos..end], MAX_DECOMPRESSED).map_err(|_| damaged())
}
/// Skip a NUL-terminated field; `None` when the terminator is missing.
fn skip_cstring(bytes: &[u8], from: usize) -> Option<usize> {
let rest = bytes.get(from..)?;
let nul = rest.iter().position(|b| *b == 0)?;
Some(from + nul + 1)
}
fn parse_err<E: std::fmt::Display>(e: E) -> IoError {
IoError::Format(format!("SVG: {e}"))
}
fn v3(p: Vec2) -> Vec3 {
Vec3::new(p.x, p.y, 0.0)
}
struct Parser {
drawing: Drawing,
/// Root viewBox/unit mapping composed with every open container transform.
stack: Vec<Mat3>,
/// Current Inkscape layer name.
layer: String,
layers: HashSet<String>,
layer_color: u8,
elements: usize,
/// > 0 while inside a skipped subtree; counts the starts seen there.
skip: usize,
}
impl Parser {
fn new() -> Self {
Parser {
drawing: Drawing::new_metric(),
stack: vec![Mat3::IDENTITY],
layer: "0".into(),
layers: HashSet::from(["0".to_string()]),
layer_color: 1,
elements: 0,
skip: 0,
}
}
fn parse(&mut self, text: &str) -> Result<()> {
let mut reader = Reader::from_str(text);
reader.config_mut().trim_text(true);
loop {
match reader.read_event().map_err(parse_err)? {
Event::Start(e) => {
let name = e.name();
self.on_start(name.as_ref(), &e)?;
}
Event::Empty(e) => {
let name = e.name();
self.on_element(name.as_ref(), &e)?;
}
Event::End(e) => {
let name = e.name();
self.on_end(name.as_ref());
}
Event::Eof => break,
_ => {}
}
}
Ok(())
}
/// A start tag: open a transform frame, descend into a skipped subtree, or treat a
/// shape written as start+end the same as a self-closed one.
fn on_start(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
if self.skip > 0 {
self.skip += 1;
return Ok(());
}
if SKIP.contains(&name) {
self.skip = 1;
return Ok(());
}
if hidden(e) {
self.skip = 1;
return Ok(());
}
if CONTAINERS.contains(&name) { self.enter_group(name, e) } else { self.on_element(name, e) }
}
fn on_element(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
if self.skip > 0 {
return Ok(());
}
if SKIP.contains(&name) || hidden(e) {
return Ok(());
}
match name {
b"rect" => self.rect(e),
b"circle" => self.circle(e),
b"ellipse" => self.ellipse(e),
b"line" => self.line(e),
b"polyline" => self.poly_points(e, false),
b"polygon" => self.poly_points(e, true),
b"path" => self.path(e),
_ => Ok(()),
}
}
fn on_end(&mut self, name: &[u8]) {
if self.skip > 0 {
self.skip -= 1;
} else if CONTAINERS.contains(&name) && self.stack.len() > 1 {
self.stack.pop();
}
}
fn enter_group(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
if name == b"svg" && self.stack.len() == 1 {
self.stack[0] = viewport(e)?;
}
if name == b"g" && attr(e, b"inkscape:groupmode").as_deref() == Some("layer") {
let raw = attr(e, b"inkscape:label").or_else(|| attr(e, b"id"));
if let Some(raw) = raw {
self.set_layer(&raw);
}
}
if self.stack.len() >= MAX_TRANSFORM_DEPTH {
return Err(IoError::Format(format!("SVG: more than {MAX_TRANSFORM_DEPTH} nested groups")));
}
let current = *self.stack.last().unwrap_or(&Mat3::IDENTITY);
let next = match attr(e, b"transform").as_deref().and_then(parse_transform) {
Some(local) => current.then_before(local),
None => current,
};
self.stack.push(next);
Ok(())
}
fn set_layer(&mut self, raw: &str) {
let name = sanitize_layer(raw);
if !self.layers.contains(&name) {
if self.layers.len() >= MAX_LAYERS {
return;
}
let color = Color::Index(self.layer_color);
self.layer_color = if self.layer_color >= 7 { 1 } else { self.layer_color + 1 };
self.drawing.layers.push(Layer { name: name.clone(), color, ..Layer::default() });
self.layers.insert(name.clone());
}
self.layer = name;
}
/// The composed mapping for the current nesting: SVG user units → drawing units (mm).
fn cur(&self) -> Mat3 {
*self.stack.last().unwrap_or(&Mat3::IDENTITY)
}
fn map(&self, p: Vec2) -> Vec2 {
self.cur().apply(p)
}
fn map_vec(&self, v: Vec2) -> Vec2 {
self.cur().apply_vec(v)
}
fn add(&mut self, kind: EntityKind) -> Result<()> {
self.elements += 1;
if self.elements > MAX_ELEMENTS {
return Err(IoError::Format(format!("SVG: more than {MAX_ELEMENTS} elements")));
}
let common = Common { layer: self.layer.clone(), ..Common::default() };
self.drawing.add(&Space::Model, common, kind).map(|_| ()).map_err(parse_err)
}
// --- shapes -------------------------------------------------------------
fn rect(&mut self, e: &BytesStart) -> Result<()> {
let x = num(e, b"x", 0.0);
let y = num(e, b"y", 0.0);
let w = num(e, b"width", 0.0);
let h = num(e, b"height", 0.0);
if w <= 0.0 || h <= 0.0 {
return Ok(());
}
let pts = [Vec2::new(x, y), Vec2::new(x + w, y), Vec2::new(x + w, y + h), Vec2::new(x, y + h)];
self.add_polyline(&pts.map(|p| self.map(p)), true)
}
fn circle(&mut self, e: &BytesStart) -> Result<()> {
let r = num(e, b"r", 0.0);
if r <= 0.0 {
return Ok(());
}
self.add_rounded(Vec2::new(num(e, b"cx", 0.0), num(e, b"cy", 0.0)), r, r)
}
fn ellipse(&mut self, e: &BytesStart) -> Result<()> {
let rx = num(e, b"rx", 0.0);
let ry = num(e, b"ry", 0.0);
if rx <= 0.0 || ry <= 0.0 {
return Ok(());
}
self.add_rounded(Vec2::new(num(e, b"cx", 0.0), num(e, b"cy", 0.0)), rx, ry)
}
/// A circle or ellipse through a transform. A circle stays a `CIRCLE` under rotations,
/// uniform scales and reflections; anything else becomes an `ELLIPSE` holding the axes
/// of the transformed shape — a linear map sends the unit circle to an ellipse whose
/// semi-axes are the square roots of the eigenvalues of `A·Aᵀ`.
fn add_rounded(&mut self, center_user: Vec2, rx: f64, ry: f64) -> Result<()> {
let center = self.map(center_user);
let bx = self.map_vec(Vec2::new(rx, 0.0));
let by = self.map_vec(Vec2::new(0.0, ry));
let len = |v: Vec2| v.x.hypot(v.y);
let (lbx, lby, dot) = (len(bx), len(by), bx.x * by.x + bx.y * by.y);
if lbx.is_finite() && lby.is_finite() && (lbx - lby).abs() <= 1e-9 * (lbx + lby) && dot.abs() <= 1e-9 * lbx * lby {
return self.add(EntityKind::Circle(Circle { center: v3(center), radius: lbx }));
}
let xx = bx.x * bx.x + bx.y * bx.y;
let yy = by.x * by.x + by.y * by.y;
let xy = bx.x * by.x + bx.y * by.y;
let trace = xx + yy;
let disc = (trace * trace - 4.0 * (xx * yy - xy * xy)).max(0.0).sqrt();
let l_max = 0.5 * (trace + disc);
let l_min = 0.5 * (trace - disc);
let s_max = l_max.max(0.0).sqrt();
if s_max <= f64::EPSILON {
return Ok(());
}
let axis = if xy.abs() <= 1e-12 * trace.max(1.0) {
if yy > xx { Vec2::new(0.0, 1.0) } else { Vec2::new(1.0, 0.0) }
} else {
let v = Vec2::new(xy, l_max - xx);
let l = len(v);
if l <= f64::EPSILON {
return Ok(());
}
v / l
};
let ratio = (l_min.max(0.0).sqrt() / s_max).clamp(1e-9, 1.0);
let major = center + axis * s_max;
self.add(EntityKind::Ellipse(Ellipse { center: v3(center), major: v3(major), ratio, start: 0.0, end: std::f64::consts::TAU }))
}
fn line(&mut self, e: &BytesStart) -> Result<()> {
let a = Vec2::new(num(e, b"x1", 0.0), num(e, b"y1", 0.0));
let b = Vec2::new(num(e, b"x2", 0.0), num(e, b"y2", 0.0));
self.add(EntityKind::Line(Line { a: v3(self.map(a)), b: v3(self.map(b)) }))
}
fn poly_points(&mut self, e: &BytesStart, closed: bool) -> Result<()> {
let Some(raw) = attr(e, b"points") else {
return Ok(());
};
let nums: Vec<f64> = Numbers::new(&raw).collect();
let mut pts: Vec<Vec2> = nums.as_chunks::<2>().0.iter().map(|[x, y]| self.map(Vec2::new(*x, *y))).collect();
if pts.len() > MAX_CURVE_POINTS {
pts.truncate(MAX_CURVE_POINTS);
}
self.add_polyline(&pts, closed)
}
fn add_polyline(&mut self, pts: &[Vec2], closed: bool) -> Result<()> {
if pts.len() < 2 {
return Ok(());
}
let vertices = pts.iter().map(|p| PolyVertex::new(*p)).collect();
self.add(EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }))
}
// --- path data ----------------------------------------------------------
fn path(&mut self, e: &BytesStart) -> Result<()> {
let Some(d) = attr(e, b"d") else {
return Ok(());
};
for sp in parse_path(&d)? {
self.emit_subpath(&sp)?;
}
Ok(())
}
/// Emit one subpath: runs of lines and arcs become an `LWPOLYLINE` (an arc becomes a
/// bulge vertex, exactly like DXF's polyline bulge), runs of Bézier curves become a
/// `SPLINE`. Under a non-uniform transform a circular arc is no longer circular, so it
/// is flattened into line vertices instead.
fn emit_subpath(&mut self, sp: &Subpath) -> Result<()> {
let m = self.cur();
let det = m.det();
let ex = m.apply_vec(Vec2::new(1.0, 0.0));
let ey = m.apply_vec(Vec2::new(0.0, 1.0));
let mag = (ex.x * ex.x + ex.y * ex.y + ey.x * ey.x + ey.y * ey.y).max(1.0);
let uniform =
(ex.x * ex.x + ex.y * ex.y - (ey.x * ey.x + ey.y * ey.y)).abs() <= 1e-9 * mag && (ex.x * ey.x + ex.y * ey.y).abs() <= 1e-9 * mag;
let scale = det.abs().sqrt();
let mut run: Vec<PolyVertex> = Vec::new();
let mut ctrl: Vec<Vec2> = Vec::new();
let mut cur_user = sp.start;
let mut cur = m.apply(sp.start);
for seg in &sp.segs {
match seg {
Seg::Line(p) => {
if run.is_empty() {
run.push(PolyVertex::new(cur));
}
cur = m.apply(*p);
run.push(PolyVertex::new(cur));
cur_user = *p;
}
Seg::Cubic(c1, c2, p) => {
self.flush_run(&mut run, false)?;
if ctrl.is_empty() {
ctrl.push(cur);
}
ctrl.push(m.apply(*c1));
ctrl.push(m.apply(*c2));
cur = m.apply(*p);
ctrl.push(cur);
cur_user = *p;
if ctrl.len() > MAX_CURVE_POINTS {
return Err(IoError::Format("SVG: one path has too many curve points".into()));
}
}
Seg::Arc { rx, ry, rot, large, sweep, end } => {
if uniform && scale > 0.0 {
let r = rx * scale;
let flipped = det < 0.0;
if r > 0.0
&& let Some((_, delta)) = arc_center_circular(cur, m.apply(*end), r, *large, *sweep != flipped)
{
if run.is_empty() {
run.push(PolyVertex::new(cur));
}
if let Some(last) = run.last_mut() {
last.bulge = (delta / 4.0).tan();
}
run.push(PolyVertex::new(m.apply(*end)));
cur = m.apply(*end);
cur_user = *end;
}
} else if *rx > 0.0 && *ry > 0.0 {
self.flush_run(&mut run, false)?;
self.flush_ctrl(&mut ctrl)?;
for p in flatten_arc(&m, cur_user, *end, *rx, *ry, *rot, *large, *sweep)? {
if run.is_empty() {
run.push(PolyVertex::new(cur));
}
run.push(PolyVertex::new(p));
}
cur = m.apply(*end);
cur_user = *end;
}
}
}
if run.len() > MAX_CURVE_POINTS {
return Err(IoError::Format("SVG: one path has too many vertices".into()));
}
}
self.flush_run(&mut run, sp.closed)?;
self.flush_ctrl(&mut ctrl)?;
Ok(())
}
fn flush_run(&mut self, run: &mut Vec<PolyVertex>, closed: bool) -> Result<()> {
if run.len() >= 2 {
let vertices = std::mem::take(run);
self.add(EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }))?;
} else {
run.clear();
}
Ok(())
}
fn flush_ctrl(&mut self, ctrl: &mut Vec<Vec2>) -> Result<()> {
if ctrl.len() >= 4 {
let spline = Spline::from_control(std::mem::take(ctrl), 3);
self.add(EntityKind::Spline(spline))?;
} else {
ctrl.clear();
}
Ok(())
}
}
/// The element is not rendered (`display="none"`, or a style that says so).
fn hidden(e: &BytesStart) -> bool {
if attr(e, b"display").as_deref() == Some("none") {
return true;
}
attr(e, b"style").is_some_and(|s| s.replace(' ', "").contains("display:none"))
}
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
for a in e.attributes().flatten() {
if a.key.as_ref() == name {
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
}
}
None
}
fn num(e: &BytesStart, name: &[u8], fallback: f64) -> f64 {
attr(e, name).as_deref().and_then(parse_length_mm).unwrap_or(fallback)
}
/// A bare number, or a number with an absolute CSS/SVG unit: millimetres here, everything
/// else (px, the default; pt, pc, in, cm) through the CSS reference pixel (96 per inch).
fn parse_length_mm(s: &str) -> Option<f64> {
let s = s.trim();
let cut = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')).unwrap_or(s.len());
let (n, unit) = s.split_at(cut);
let value: f64 = n.trim().parse().ok()?;
if !value.is_finite() {
return None;
}
match unit.trim() {
"" | "px" => Some(value),
"mm" => Some(value),
"cm" => Some(value * 10.0),
"in" => Some(value * 25.4),
"pt" => Some(value * 25.4 / 72.0),
"pc" => Some(value * 25.4 / 6.0),
_ => Some(value * 25.4 / 96.0),
}
}
/// The root mapping: `viewBox` and unit-bearing `width`/`height` become millimetres per user
/// unit, and the page's Y axis is flipped so the drawing keeps its appearance with Y up.
fn viewport(e: &BytesStart) -> Result<Mat3> {
let vb = attr(e, b"viewBox").and_then(|raw| {
let nums: Vec<f64> = Numbers::new(&raw).collect();
match nums.as_slice() {
[x, y, w, h] if w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0 => Some((*x, *y, *w, *h)),
_ => None,
}
});
let w_mm = attr(e, b"width").as_deref().and_then(parse_length_mm);
let h_mm = attr(e, b"height").as_deref().and_then(parse_length_mm);
let unit = match (vb, w_mm, h_mm) {
(Some((_, _, vw, _)), Some(w), _) if w > 0.0 => w / vw,
(Some((_, _, _, vh)), _, Some(h)) if h > 0.0 => h / vh,
(Some(_), _, _) => 25.4 / 96.0,
(None, _, _) => 25.4 / 96.0,
};
let (vx, vy, vh) = match vb {
Some((x, y, _, h)) => (x, y, h),
None => (0.0, 0.0, 0.0),
};
Ok(Mat3 { a: unit, b: 0.0, c: 0.0, d: -unit, e: -vx * unit, f: (vy + vh) * unit })
}
/// `transform="…"`: a list of functions, applied left to right.
fn parse_transform(s: &str) -> Option<Mat3> {
let mut out: Option<Mat3> = None;
let mut rest = s.trim();
while !rest.is_empty() {
let open = rest.find('(')?;
let name = rest[..open].trim();
let close = rest[open..].find(')')? + open;
let args: Vec<f64> = Numbers::new(&rest[open + 1..close]).collect();
let m = match (name, args.as_slice()) {
("matrix", [a, b, c, d, e, f]) => Mat3 { a: *a, b: *b, c: *c, d: *d, e: *e, f: *f },
("translate", [x]) => Mat3::translate(Vec2::new(*x, 0.0)),
("translate", [x, y]) => Mat3::translate(Vec2::new(*x, *y)),
("scale", [s]) => Mat3::scale(*s, *s),
("scale", [sx, sy]) => Mat3::scale(*sx, *sy),
("rotate", [a]) => Mat3::rotate(a.to_radians()),
("rotate", [a, cx, cy]) => Mat3::rotate_about(Vec2::new(*cx, *cy), a.to_radians()),
("skewX", [a]) => Mat3 { a: 1.0, b: 0.0, c: a.to_radians().tan(), d: 1.0, e: 0.0, f: 0.0 },
("skewY", [a]) => Mat3 { a: 1.0, b: a.to_radians().tan(), c: 0.0, d: 1.0, e: 0.0, f: 0.0 },
_ => return None,
};
out = Some(match out {
Some(prev) => prev.then_before(m),
None => m,
});
rest = rest[close + 1..].trim_start_matches(|c: char| c == ',' || c.is_whitespace());
}
out
}
fn sanitize_layer(raw: &str) -> String {
let cleaned: String =
raw.trim().chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '$') { c } else { '_' }).take(128).collect();
let cleaned = cleaned.trim_matches('_').to_string();
if cleaned.is_empty() { "SVG".into() } else { cleaned }
}
/// A scanner over an SVG number list: separators are whitespace and commas, and a sign
/// starts a new number, so `10-20` is two numbers and `1e-5 2` is two numbers.
struct Numbers<'a> {
s: &'a [u8],
i: usize,
}
impl<'a> Numbers<'a> {
fn new(s: &'a str) -> Self {
Numbers { s: s.as_bytes(), i: 0 }
}
fn skip_seps(&mut self) {
while matches!(self.s.get(self.i), Some(b' ' | b'\t' | b'\r' | b'\n' | b',')) {
self.i += 1;
}
}
fn next_num(&mut self) -> Option<f64> {
self.skip_seps();
let start = self.i;
if matches!(self.s.get(self.i), Some(b'+' | b'-')) {
self.i += 1;
}
let mut digits = 0;
let mut dot = false;
let mut exp = false;
while let Some(c) = self.s.get(self.i) {
match c {
b'0'..=b'9' => {
digits += 1;
self.i += 1;
}
b'.' if !dot && !exp => {
dot = true;
self.i += 1;
}
b'e' | b'E' if !exp && digits > 0 => {
exp = true;
self.i += 1;
if matches!(self.s.get(self.i), Some(b'+' | b'-')) {
self.i += 1;
}
}
_ => break,
}
}
if digits == 0 {
self.i = start;
return None;
}
std::str::from_utf8(&self.s[start..self.i]).ok()?.parse::<f64>().ok().filter(|v| v.is_finite())
}
}
impl Iterator for Numbers<'_> {
type Item = f64;
fn next(&mut self) -> Option<f64> {
self.next_num()
}
}
/// One `M …` run of a `d` attribute.
struct Subpath {
start: Vec2,
segs: Vec<Seg>,
closed: bool,
}
enum Seg {
Line(Vec2),
Cubic(Vec2, Vec2, Vec2),
Arc { rx: f64, ry: f64, rot: f64, large: bool, sweep: bool, end: Vec2 },
}
/// Tokens of a `d` attribute: command letters and numbers, in order.
enum Tok {
Cmd(u8),
Num(f64),
}
struct Tokenizer<'a> {
s: &'a [u8],
i: usize,
}
impl<'a> Tokenizer<'a> {
fn new(s: &'a str) -> Self {
Tokenizer { s: s.as_bytes(), i: 0 }
}
fn next_tok(&mut self) -> Option<Tok> {
let mut nums = Numbers { s: self.s, i: self.i };
nums.skip_seps();
self.i = nums.i;
let c = self.s.get(self.i).copied()?;
if c.is_ascii_alphabetic() {
self.i += 1;
return Some(Tok::Cmd(c));
}
let tok = nums.next_num().map(Tok::Num);
self.i = nums.i;
tok
}
}
/// Split `d` into subpaths, resolving relative commands and the S/T control reflections.
fn parse_path(d: &str) -> Result<Vec<Subpath>> {
let mut tk = Tokenizer::new(d);
let mut lookahead: Option<Tok> = None;
let mut cmd = 0u8;
let mut cur = Vec2::ZERO;
let mut start = Vec2::ZERO;
let mut segs: Vec<Seg> = Vec::new();
let mut out: Vec<Subpath> = Vec::new();
let closed = false;
let mut prev_cubic: Option<Vec2> = None;
let mut prev_quad: Option<Vec2> = None;
let mut segments = 0usize;
let next_num = |tk: &mut Tokenizer, look: &mut Option<Tok>| -> Result<f64> {
match look.take() {
Some(Tok::Num(v)) => Ok(v),
Some(other @ Tok::Cmd(_)) => {
*look = Some(other);
Err(IoError::Format("SVG: path command is missing coordinates".into()))
}
None => match tk.next_tok() {
Some(Tok::Num(v)) => Ok(v),
Some(other @ Tok::Cmd(_)) => {
*look = Some(other);
Err(IoError::Format("SVG: path command is missing coordinates".into()))
}
None => Err(IoError::Format("SVG: path command is missing coordinates".into())),
},
}
};
while let Some(tok) = lookahead.take().or_else(|| tk.next_tok()) {
let rel = match tok {
Tok::Cmd(c) => {
if c.eq_ignore_ascii_case(&b'z') {
if !segs.is_empty() {
out.push(Subpath { start, segs, closed: true });
segs = Vec::new();
}
cur = start;
cmd = 0;
continue;
}
cmd = c;
c.is_ascii_lowercase()
}
tok @ Tok::Num(_) => {
if cmd == 0 {
return Err(IoError::Format("SVG: path data does not start with a command".into()));
}
lookahead = Some(tok);
cmd.is_ascii_lowercase()
}
};
let abs = cmd.to_ascii_uppercase();
let count = match abs {
b'M' | b'L' => 2,
b'H' | b'V' | b'T' => 1,
b'C' => 6,
b'S' | b'Q' => 4,
b'A' => 7,
_ => return Err(IoError::Format(format!("SVG: unknown path command `{}`", cmd as char))),
};
let mut args = [0.0f64; 7];
for slot in args.iter_mut().take(count) {
*slot = next_num(&mut tk, &mut lookahead)?;
}
let point = |x: f64, y: f64| if rel { cur + Vec2::new(x, y) } else { Vec2::new(x, y) };
match abs {
b'M' => {
if !segs.is_empty() {
out.push(Subpath { start, segs, closed: false });
segs = Vec::new();
}
cur = point(args[0], args[1]);
start = cur;
cmd = if rel { b'l' } else { b'L' };
prev_cubic = None;
prev_quad = None;
}
b'L' => {
cur = point(args[0], args[1]);
segs.push(Seg::Line(cur));
prev_cubic = None;
prev_quad = None;
}
b'H' => {
cur = Vec2::new(if rel { cur.x + args[0] } else { args[0] }, cur.y);
segs.push(Seg::Line(cur));
prev_cubic = None;
prev_quad = None;
}
b'V' => {
cur = Vec2::new(cur.x, if rel { cur.y + args[0] } else { args[0] });
segs.push(Seg::Line(cur));
prev_cubic = None;
prev_quad = None;
}
b'C' => {
let (c1, c2, p) = (point(args[0], args[1]), point(args[2], args[3]), point(args[4], args[5]));
segs.push(Seg::Cubic(c1, c2, p));
prev_cubic = Some(c2);
prev_quad = None;
cur = p;
}
b'S' => {
let c1 = prev_cubic.map_or(cur, |c2| cur * 2.0 - c2);
let (c2, p) = (point(args[0], args[1]), point(args[2], args[3]));
segs.push(Seg::Cubic(c1, c2, p));
prev_cubic = Some(c2);
prev_quad = None;
cur = p;
}
b'Q' => {
let (q, p) = (point(args[0], args[1]), point(args[2], args[3]));
segs.push(Seg::Cubic(cur + (q - cur) * (2.0 / 3.0), p + (q - p) * (2.0 / 3.0), p));
prev_quad = Some(q);
prev_cubic = None;
cur = p;
}
b'T' => {
let reflect = prev_quad.map_or(cur, |q| cur * 2.0 - q);
let p = point(args[0], args[1]);
segs.push(Seg::Cubic(cur + (reflect - cur) * (2.0 / 3.0), p + (reflect - p) * (2.0 / 3.0), p));
prev_quad = Some(reflect);
prev_cubic = None;
cur = p;
}
b'A' => {
let end = point(args[5], args[6]);
segs.push(Seg::Arc { rx: args[0].abs(), ry: args[1].abs(), rot: args[2], large: args[3] != 0.0, sweep: args[4] != 0.0, end });
cur = end;
prev_cubic = None;
prev_quad = None;
}
_ => return Err(IoError::Format("SVG: unsupported path command".into())),
}
segments += 1;
if segments > MAX_PATH_SEGMENTS {
return Err(IoError::Format("SVG: path has too many segments".into()));
}
if segs.len() > MAX_CURVE_POINTS {
return Err(IoError::Format("SVG: path has too many segments".into()));
}
}
if !segs.is_empty() {
out.push(Subpath { start, segs, closed });
}
Ok(out)
}
/// The centre and signed sweep of a circular arc from `p0` to `p1` in a Y-up frame, per the
/// SVG endpoint parameterisation (F.6.5). Radii that cannot span the chord are scaled up,
/// as the specification requires.
fn arc_center_circular(p0: Vec2, p1: Vec2, r: f64, large: bool, sweep: bool) -> Option<(Vec2, f64)> {
if r <= 0.0 || !r.is_finite() {
return None;
}
let d = p1 - p0;
let dist = d.x.hypot(d.y);
let r = if dist > 2.0 * r { dist / 2.0 } else { r };
if dist <= f64::EPSILON {
return None;
}
let mid = (p0 + p1) * 0.5;
let h = (r * r - (dist * dist) / 4.0).max(0.0).sqrt();
let n = Vec2::new(-d.y / dist, d.x / dist);
let sign = if large != sweep { 1.0 } else { -1.0 };
let center = mid + n * (h * sign);
let a0 = (p0.y - center.y).atan2(p0.x - center.x);
let a1 = (p1.y - center.y).atan2(p1.x - center.x);
let ccw = (a1 - a0).rem_euclid(std::f64::consts::TAU);
Some((center, pick_sweep(ccw, large, sweep)))
}
/// The signed sweep (CCW positive) for a chord sweep `ccw` in `[0, 2π)`: the centre is
/// already chosen so that the magnitude matches `large`, so only the direction is left.
fn pick_sweep(ccw: f64, large: bool, sweep: bool) -> f64 {
let tau = std::f64::consts::TAU;
let eps = f64::EPSILON;
if sweep {
if ccw <= eps { if large { tau } else { eps } } else { ccw }
} else if ccw >= tau - eps {
if large { -tau } else { -eps }
} else {
ccw - tau
}
}
/// Flatten a general elliptical arc (any `rx`/`ry`/rotation) from `p0` to `p1` in user
/// space, then map every sample through `m`.
fn flatten_arc(m: &Mat3, p0: Vec2, p1: Vec2, rx: f64, ry: f64, rot_deg: f64, large: bool, sweep: bool) -> Result<Vec<Vec2>> {
let (c, theta, delta) = elliptical_params(p0, p1, rx, ry, rot_deg.to_radians(), large, sweep)
.ok_or_else(|| IoError::Format("SVG: elliptical arc cannot be converted".into()))?;
let axes = Mat3::rotate(rot_deg.to_radians());
let rmax = rx.max(ry);
let tol = 0.05_f64;
let steps = if rmax > tol {
let step = 2.0 * (1.0 - (tol / rmax).min(1.0)).acos().max(1e-6);
((delta.abs() / step).ceil() as usize).clamp(8, MAX_ARC_CHORDS)
} else {
8
};
let mut out = Vec::with_capacity(steps + 1);
for k in 0..=steps {
let t = theta + delta * (k as f64) / (steps as f64);
let local = Vec2::new(rx * t.cos(), ry * t.sin());
out.push(m.apply(c + axes.apply_vec(local)));
}
Ok(out)
}
/// Endpoint → centre parameterisation for a rotated elliptical arc (SVG F.6.5), in the
/// frame the points are given in. Returns the centre, the start parameter and the signed
/// sweep.
fn elliptical_params(p0: Vec2, p1: Vec2, rx: f64, ry: f64, phi: f64, large: bool, sweep: bool) -> Option<(Vec2, f64, f64)> {
if rx <= 0.0 || ry <= 0.0 {
return None;
}
let (sphi, cphi) = phi.sin_cos();
let dx = (p0.x - p1.x) / 2.0;
let dy = (p0.y - p1.y) / 2.0;
let x1 = cphi * dx + sphi * dy;
let y1 = -sphi * dx + cphi * dy;
let (rx, ry) = {
let lambda = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry);
if lambda > 1.0 {
let s = lambda.sqrt();
(rx * s, ry * s)
} else {
(rx, ry)
}
};
let num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
let den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
let coef = if sweep != large { 1.0 } else { -1.0 } * (num.max(0.0) / den.max(f64::MIN_POSITIVE)).sqrt();
let cxp = coef * rx * y1 / ry;
let cyp = -coef * ry * x1 / rx;
let center = Vec2::new(cphi * cxp - sphi * cyp + (p0.x + p1.x) / 2.0, sphi * cxp + cphi * cyp + (p0.y + p1.y) / 2.0);
let angle = |ux: f64, uy: f64, vx: f64, vy: f64| {
let dot = ux * vx + uy * vy;
let det = ux * vy - uy * vx;
let denom = (ux.hypot(uy) * vx.hypot(vy)).max(f64::MIN_POSITIVE);
(dot / denom).clamp(-1.0, 1.0).acos().copysign(det)
};
let (ux, uy) = ((x1 - cxp) / rx, (y1 - cyp) / ry);
let (vx, vy) = ((-x1 - cxp) / rx, (-y1 - cyp) / ry);
let theta = angle(1.0, 0.0, ux, uy);
let ccw = angle(ux, uy, vx, vy).rem_euclid(std::f64::consts::TAU);
Some((center, theta, pick_sweep(ccw, large, sweep)))
}
+1 -192
View File
@@ -1379,10 +1379,7 @@ fn mleader_is_written_as_multileader() {
block: None,
};
d.add(&Space::Model, Default::default(), EntityKind::MLeader(m.clone())).unwrap();
// MULTILEADER is a 2007 entity (R2000/2004 files get LEADER + MTEXT).
let back =
crate::read(&crate::write_with(&d, "x.dxf", &crate::SaveOptions { version: Some(crate::Version::R2007), binary: false }).unwrap(), "x.dxf")
.unwrap();
let back = roundtrip(&d);
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MLeader(x) if *x == m)));
assert!(!back.model.iter().any(|e| matches!(e.kind, EntityKind::Leader(_) | EntityKind::MText(_))));
}
@@ -1824,191 +1821,3 @@ fn mirrored_mtext_extrusion_keeps_its_footprint() {
let b = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
assert!(b.min.x > -0.2 && b.max.x > 0.5 && b.min.y > -0.2 && b.max.y > 0.5, "box right of and above (0,0): {b:?}");
}
// --- SVG import ---
/// One 20mm×10mm page whose user units are millimetres; the reader flips Y, so a point at
/// SVG y lands at `10 - y` in the drawing.
const SVG_PAGE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10">"#;
fn kinds(d: &Drawing) -> Vec<&'static str> {
d.model.iter().map(|e| e.kind.type_name()).collect()
}
fn gzip_of(data: &[u8]) -> Vec<u8> {
let mut crc: u32 = 0xffff_ffff;
for b in data {
crc ^= u32::from(*b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xedb8_8320u32 & 0u32.wrapping_sub(crc & 1));
}
}
let mut out = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3];
out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(data, 6));
out.extend_from_slice(&(!crc).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out
}
#[test]
fn svg_shapes_become_entities_in_millimetres() {
let svg = format!(
"{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/><circle cx=\"10\" cy=\"5\" r=\"2\"/>\
<ellipse cx=\"10\" cy=\"5\" rx=\"4\" ry=\"2\"/><line x1=\"0\" y1=\"0\" x2=\"20\" y2=\"10\"/>\
<polyline points=\"0,0 5,0 5,5\"/><polygon points=\"0,0 2,0 1,1\"/></svg>"
);
let d = read(svg.as_bytes(), "shapes.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline", "Circle", "Ellipse", "Line", "Polyline", "Polyline"]);
let r = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(r.closed);
let got: Vec<Vec2> = r.vertices.iter().map(|v| v.p).collect();
let want = [Vec2::new(1.0, 9.0), Vec2::new(5.0, 9.0), Vec2::new(5.0, 6.0), Vec2::new(1.0, 6.0)];
for (g, w) in got.iter().zip(want.iter()) {
assert!((*g - *w).len() < 1e-9, "{g:?} vs {w:?}");
}
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
assert!((c.radius - 2.0).abs() < 1e-9 && (c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
let e = first(&d, |k| if let EntityKind::Ellipse(e) = k { Some(e.clone()) } else { None });
let major = Vec2::new(e.major.x - e.center.x, e.major.y - e.center.y);
assert!((major.len() - 4.0).abs() < 1e-9 && (e.ratio - 0.5).abs() < 1e-9, "{e:?}");
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
assert!((l.a.y - 10.0).abs() < 1e-9 && (l.b.y).abs() < 1e-9, "{l:?}");
}
#[test]
fn svg_path_lines_curves_and_arcs() {
let svg = format!("{SVG_PAGE}<path d=\"M1 1 L5 1 C6 1 7 2 8 3\"/><path d=\"M0 0 A5 5 0 0 1 10 0 Z\"/></svg>");
let d = read(svg.as_bytes(), "paths.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline", "Spline", "Polyline"]);
let line_run = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(!line_run.closed && line_run.vertices.len() == 2, "{line_run:?}");
let spline = first(&d, |k| if let EntityKind::Spline(s) = k { Some(s.clone()) } else { None });
assert!(spline.is_valid(), "{spline:?}");
let arc = d
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::LwPolyline(p) if p.vertices.first().is_some_and(|v| v.bulge != 0.0) => Some(p.clone()),
_ => None,
})
.expect("an arc becomes a bulge vertex");
assert!(arc.closed && arc.vertices.len() == 2, "{arc:?}");
let bulge = arc.vertices[0].bulge;
assert!((bulge.abs() - 1.0).abs() < 1e-6, "semicircle bulge is ±1, got {bulge}");
}
#[test]
fn svg_group_transforms_apply() {
let svg = format!("{SVG_PAGE}<g transform=\"translate(5,2)\"><line x1=\"0\" y1=\"0\" x2=\"10\" y2=\"0\"/></g></svg>");
let d = read(svg.as_bytes(), "t.svg").unwrap();
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
assert!((l.a - Vec3::new(5.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
assert!((l.b - Vec3::new(15.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
}
#[test]
fn svg_viewbox_scaling_and_mm_units() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 200 100"><circle cx="100" cy="50" r="50"/></svg>"#;
let d = read(svg.as_bytes(), "vb.svg").unwrap();
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
assert!((c.radius - 5.0).abs() < 1e-9, "{c:?}");
assert!((c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
}
#[test]
fn svg_inkscape_layers_are_kept() {
let svg = format!(
"{SVG_PAGE}<g inkscape:groupmode=\"layer\" inkscape:label=\"Cut\"><rect x=\"0\" y=\"0\" width=\"2\" height=\"2\"/></g>\
<g inkscape:groupmode=\"layer\" inkscape:label=\"Engrave\"><circle cx=\"5\" cy=\"5\" r=\"1\"/></g></svg>",
SVG_PAGE = SVG_PAGE.replace("<svg ", "<svg xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\" ")
);
let d = read(svg.as_bytes(), "layers.svg").unwrap();
let layers: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
assert!(layers.contains(&"Cut") && layers.contains(&"Engrave"), "{layers:?}");
let per_layer = |name: &str| d.model.iter().filter(|e| e.common.layer == name).count();
assert_eq!(per_layer("Cut"), 1);
assert_eq!(per_layer("Engrave"), 1);
}
#[test]
fn svg_hidden_and_defs_subtrees_are_skipped() {
let svg = format!(
"{SVG_PAGE}<defs><rect x=\"0\" y=\"0\" width=\"9\" height=\"9\"/></defs><text x=\"0\" y=\"0\">hi</text>\
<rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" display=\"none\"/><rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" style=\"fill:red;display:none\"/>\
<rect x=\"1\" y=\"1\" width=\"2\" height=\"2\"/></svg>"
);
let d = read(svg.as_bytes(), "skip.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline"]);
}
#[test]
fn svgz_gunzips_to_the_same_drawing() {
let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/></svg>");
let plain = read(svg.as_bytes(), "a.svg").unwrap();
let packed = read(&gzip_of(svg.as_bytes()), "a.svgz").unwrap();
assert_eq!(kinds(&plain), kinds(&packed));
assert_eq!(plain.model.iter().map(|e| &e.kind).collect::<Vec<_>>(), packed.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
}
#[test]
fn svg_read_dispatches_by_extension() {
let svg = format!("{SVG_PAGE}<circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>");
assert!(read(svg.as_bytes(), "a.svg").is_ok());
assert!(read(&gzip_of(svg.as_bytes()), "a.svgz").is_ok());
}
#[test]
fn svg_hostile_input_errors_without_panicking() {
let deep = format!("{SVG_PAGE}{}<line x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"/>{}</svg>", "<g>".repeat(200), "</g>".repeat(200));
for (bytes, name) in [
(b"not xml at all".to_vec(), "a.svg"),
(Vec::new(), "a.svg"),
(b"<svg><rect x=\"1e309\" width=\"NaN\" height=\"inf\"/></svg>".to_vec(), "a.svg"),
(deep.clone().into_bytes(), "a.svg"),
(b"<svg><path d=\"M0 0 X5 5\"/></svg>".to_vec(), "a.svg"),
(b"<svg><path d=\"M0 0 L\"/></svg>".to_vec(), "a.svg"),
(b"<svg><path d=\"M0 0 A1 1 0 5 1 2 2\"/></svg>".to_vec(), "a.svg"),
(b"<svg><polyline points=\"1 2 3\"/></svg>".to_vec(), "a.svg"),
(b"<svg><g transform=\"bogus(1)\"><rect width=\"1\" height=\"1\"/></g></svg>".to_vec(), "a.svg"),
(vec![0x1f, 0x8b, 8, 0, b'j', b'u', b'n', b'k', 0, 0, 0, 0, 0, 0, 0, 0], "a.svgz"),
(vec![0xff, 0xfe, 0x00, 0x01, 0x02, 0x03], "a.svg"),
] {
let _ = read(&bytes, name);
}
let deep = read(deep.as_bytes(), "a.svg");
assert!(deep.is_err(), "200 nested groups are refused, not walked");
}
#[test]
fn svg_no_geometry_yields_an_empty_drawing() {
let svg = format!("{SVG_PAGE}</svg>");
let d = read(svg.as_bytes(), "empty.svg").unwrap();
assert!(kinds(&d).is_empty());
}
/// An application data group inside a kept XRECORD — the AcDs thumbnail schema's
/// `{ATTRRECORD 341 h 2 name 280 1 291 1 ATTRRECORD}` — is written back as read. It used to be
/// collapsed into a handle list: the handle came out under group 291 (a bool) and the name and
/// flags were dropped, which strict DXF readers (ezdxf) reject outright.
#[test]
fn kept_xrecord_application_group_roundtrips_as_read() {
let dxf = "0\nSECTION\n2\nOBJECTS\n\
0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n3\nTD_SCHEMA\n350\n20\n\
0\nXRECORD\n5\n20\n330\nC\n100\nAcDbXrecord\n280\n1\n1\nAcDb_Thumbnail_Schema\n\
102\n{ATTRRECORD\n341\n21\n2\nAcDbDs::TreatedAsObjectData\n280\n1\n291\n1\n102\nATTRRECORD}\n\
0\nXRECORD\n5\n21\n330\n20\n100\nAcDbXrecord\n280\n1\n1\nTarget\n\
0\nENDSEC\n0\nEOF\n";
let d = read_dxf(dxf.as_bytes()).expect("read");
let out = write_dxf(&d);
let lines: Vec<&str> = out.lines().map(str::trim).collect();
let at = lines.iter().position(|l| *l == "{ATTRRECORD").expect("group kept");
// The group keeps its own shape: a reference under 341, then the name, flag and bool.
assert_eq!(lines[at + 1], "341", "reference group code: {:?}", &lines[at..at + 12]);
assert!(lines[at + 2] != "0" && !lines[at + 2].is_empty());
assert_eq!(&lines[at + 3..at + 5], &["2", "AcDbDs::TreatedAsObjectData"]);
assert_eq!(&lines[at + 5..at + 7], &["280", "1"]);
assert_eq!(&lines[at + 7..at + 9], &["291", "1"]);
assert_eq!(&lines[at + 9..at + 11], &["102", "ATTRRECORD}"]);
// And no handle is written under a bool code anywhere.
assert!(!out.contains("291\n2"), "no handle may be written under a bool code");
}
-219
View File
@@ -1,219 +0,0 @@
//! File-format follow-ups (#626): MULTILEADER only from 2007 (older files get LEADER + MTEXT /
//! INSERT, DXF and DWG), post-R2000 DIMSTYLE variables as table groups from 2004, `$DWGCODEPAGE`
//! kept as read in pre-2007 files, and named-dictionary entries the writer writes itself not
//! kept a second time.
use cadcraft_doc::*;
use cadcraft_geom::{Vec2, Vec3};
use cadcraft_io::{SaveOptions, Version};
fn save(d: &Drawing, name: &str, v: Version) -> Vec<u8> {
cadcraft_io::write_with(d, name, &SaveOptions { version: Some(v), binary: false }).unwrap()
}
fn dxf(d: &Drawing, v: Version) -> String {
String::from_utf8(save(d, "x.dxf", v)).unwrap()
}
fn lines(text: &str) -> Vec<&str> {
text.lines().map(str::trim).collect()
}
/// Number of records of `kind` (`0` / `kind` pairs) in DXF text.
fn records(text: &str, kind: &str) -> usize {
lines(text).chunks(2).filter(|p| *p == ["0", kind]).count()
}
fn count(d: &Drawing, f: impl Fn(&EntityKind) -> bool) -> usize {
d.model.iter().filter(|e| f(&e.kind)).count()
}
fn mleader_drawing() -> (Drawing, MLeader) {
let mut d = Drawing::new_metric();
let m = MLeader {
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 3.0, 0.0)], vec![Vec3::new(1.0, -2.0, 0.0)]],
landing: Vec3::new(5.0, 4.0, 0.0),
dogleg: 0.75,
text: Some(MText {
insert: Vec3::new(5.9, 4.0, 0.0),
height: 0.4,
width: 6.0,
attach: 4,
rotation: 0.0,
style: "Standard".into(),
contents: "NOTE".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
style: "Standard".into(),
arrow_size: 1.0,
spline: false,
block: None,
};
d.add(&Space::Model, Common { layer: "0".into(), ..Common::default() }, EntityKind::MLeader(m.clone())).unwrap();
(d, m)
}
#[test]
fn multileader_before_2007_is_leader_and_mtext() {
let (d, _) = mleader_drawing();
for v in [Version::R2000, Version::R2004] {
let text = dxf(&d, v);
assert_eq!(records(&text, "MULTILEADER"), 0, "{v:?}: no MULTILEADER entity");
assert!(!text.contains("AcDbMLeader\n"), "{v:?}: no MULTILEADER class");
assert_eq!(records(&text, "LEADER"), 2, "{v:?}: one LEADER per leader line");
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert_eq!(count(&back, |k| matches!(k, EntityKind::MLeader(_))), 0);
assert_eq!(count(&back, |k| matches!(k, EntityKind::Leader(_))), 2);
assert_eq!(count(&back, |k| matches!(k, EntityKind::MText(t) if t.contents == "NOTE")), 1);
// The leader lines end at the landing.
assert!(back.model.iter().all(|e| match &e.kind {
EntityKind::Leader(l) => l.vertices.last() == Some(&Vec3::new(5.0, 4.0, 0.0)),
_ => true,
}));
}
}
#[test]
fn multileader_from_2007_is_native() {
let (d, m) = mleader_drawing();
for v in [Version::R2007, Version::R2018] {
let text = dxf(&d, v);
assert_eq!(records(&text, "MULTILEADER"), 1, "{v:?}");
assert_eq!(records(&text, "LEADER"), 0, "{v:?}");
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
let got: Vec<&MLeader> = back.model.iter().filter_map(|e| if let EntityKind::MLeader(m) = &e.kind { Some(m) } else { None }).collect();
assert_eq!(got, vec![&m], "{v:?}");
}
}
#[test]
fn multileader_in_dwg_follows_the_dwg_version() {
let (d, _) = mleader_drawing();
let old = cadcraft_io::read(&save(&d, "x.dwg", Version::R2004), "x.dwg").unwrap();
assert_eq!(count(&old, |k| matches!(k, EntityKind::MLeader(_))), 0, "2004 DWG has no MULTILEADER");
assert_eq!(count(&old, |k| matches!(k, EntityKind::Leader(_))), 2);
assert_eq!(count(&old, |k| matches!(k, EntityKind::MText(_))), 1);
let new = cadcraft_io::read(&save(&d, "x.dwg", Version::R2018), "x.dwg").unwrap();
assert_eq!(count(&new, |k| matches!(k, EntityKind::Leader(_))), 0, "2018 DWG keeps the multileader whole");
}
fn dim_drawing() -> (Drawing, DimStyle) {
let mut d = Drawing::new_imperial();
d.linetypes.push(Linetype::simple("DASHED", "Dashed", &[0.5, -0.25]));
d.linetypes.push(Linetype::simple("CENTER", "Center", &[1.25, -0.25, 0.25, -0.25]));
let st = DimStyle {
name: "Detail".into(),
arc_symbol: 1,
dim_linetype: "DASHED".into(),
ext_linetype1: "CENTER".into(),
ext_linetype2: "DASHED".into(),
fixed_ext: true,
fixed_ext_length: 0.75,
jog_angle: 30.0,
..DimStyle::default()
};
d.dim_styles.push(st.clone());
(d, st)
}
/// The DIMSTYLE record named `name`.
fn dimstyle_record<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
let l = lines(text);
let at = l.windows(2).position(|w| w == ["2", name]).unwrap();
let end = l[at..].windows(2).position(|w| w == ["0", "DIMSTYLE"] || w == ["0", "ENDTAB"]).map_or(l.len(), |e| at + e);
l[at..end].to_vec()
}
#[test]
fn newer_dimstyle_variables_are_table_groups_from_2004() {
let (d, st) = dim_drawing();
let r2000 = dxf(&d, Version::R2000);
let rec = dimstyle_record(&r2000, "Detail");
assert!(rec.windows(4).any(|w| w == ["1001", "CADCRAFT", "1000", "DIMVARS"]), "R2000: xdata");
assert!(!rec.chunks(2).any(|p| p[0] == "345"), "R2000: no DIMLTYPE group");
for v in [Version::R2004, Version::R2018] {
let text = dxf(&d, v);
let rec = dimstyle_record(&text, "Detail");
assert!(!rec.contains(&"DIMVARS"), "{v:?}: no DIMVARS xdata");
for code in ["49", "50", "90", "290", "345", "346", "347"] {
assert!(rec.chunks(2).any(|p| p[0] == code), "{v:?}: group {code}");
}
let jog = rec.chunks(2).find(|p| p[0] == "50").unwrap()[1].parse::<f64>().unwrap();
assert!((jog - 30f64.to_radians()).abs() < 1e-9, "DIMJOGANG in radians: {jog}");
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.dim_style("Detail").unwrap(), &st, "{v:?}");
}
assert_eq!(cadcraft_io::read_dxf(r2000.as_bytes()).unwrap().dim_style("Detail").unwrap(), &st);
}
fn code_page(text: &str) -> &str {
let l = lines(text);
let at = l.iter().position(|s| *s == "$DWGCODEPAGE").unwrap();
l[at + 2]
}
#[test]
fn code_page_is_kept_before_2007() {
let mut d = Drawing::new_metric();
d.header.set_str("DWGCODEPAGE", "ANSI_1251");
d.add(
&Space::Model,
Common::default(),
EntityKind::Text(Text {
insert: Vec3::ZERO,
align_pt: None,
height: 2.5,
value: "Привет".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
}),
)
.unwrap();
for v in [Version::R2000, Version::R2004] {
let bytes = save(&d, "x.dxf", v);
assert!(bytes.is_ascii(), "{v:?}: ASCII with escapes");
let text = String::from_utf8(bytes).unwrap();
assert_eq!(code_page(&text), "ANSI_1251", "{v:?}");
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.header.str("DWGCODEPAGE", ""), "ANSI_1251");
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.value == "Привет")));
}
assert_eq!(code_page(&dxf(&d, Version::R2018)), "ANSI_1252", "2007+: UTF-8 text");
// Without one, or with one CADCraft doesn't know: ANSI_1252.
d.header.set_str("DWGCODEPAGE", "NO_SUCH\n0\nEOF");
assert_eq!(code_page(&dxf(&d, Version::R2000)), "ANSI_1252");
assert_eq!(code_page(&dxf(&Drawing::new_metric(), Version::R2000)), "ANSI_1252");
}
#[test]
fn modelled_dictionaries_are_not_kept_twice() {
let mut d = Drawing::new_metric();
let wall = MLineStyle { name: "Wall".into(), ..MLineStyle::default() };
d.mline_styles.push(wall.clone());
d.add(
&Space::Model,
Common::default(),
EntityKind::MLine(MLine::new(&wall, &[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)], false, MLineJust::Zero, 1.0)),
)
.unwrap();
let first = dxf(&d, Version::R2018);
let read = cadcraft_io::read_dxf(first.as_bytes()).unwrap();
let kept: Vec<&str> = read.foreign.objects.iter().map(|o| o.kind.as_str()).collect();
assert!(kept.is_empty(), "nothing CADCraft writes itself is kept raw: {kept:?}");
let again = dxf(&read, Version::R2018);
let third = dxf(&cadcraft_io::read_dxf(again.as_bytes()).unwrap(), Version::R2018);
for kind in ["MLINESTYLE", "SCALE", "MLEADERSTYLE", "TABLESTYLE", "DICTIONARY", "XRECORD", "GROUP", "LAYOUT"] {
assert_eq!(records(&again, kind), records(&first, kind), "{kind} after one round trip");
assert_eq!(records(&third, kind), records(&first, kind), "{kind} after two");
}
let l = lines(&again);
for key in ["ACAD_MLINESTYLE", "ACAD_SCALELIST", "ACAD_GROUP", "ACAD_MLEADERSTYLE", "ACAD_PLOTSTYLENAME", "CADCRAFT_HANDSEED"] {
assert_eq!(l.windows(2).filter(|w| *w == ["3", key]).count(), 1, "{key} once in the named object dictionary");
}
}
+2 -20
View File
@@ -98,27 +98,9 @@ fn custom_pattern_lines_roundtrip_scaled_and_rotated() {
}
#[test]
fn library_named_patterns_keep_the_files_definition() {
// The library's AR-CONC only approximates AutoCAD's: the file's own lines are kept and saved.
let h = first_hatch(&save(hatch("AR-CONC", 2.0, 0.0, Vec2::ZERO, custom_lines())));
assert!(near(&h.pattern_lines, &custom_lines()), "{:?}", h.pattern_lines);
// A hatch without lines of its own saves the library's definition, which reads back as lines.
fn library_patterns_keep_no_definition() {
let h = first_hatch(&save(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, vec![])));
assert_eq!(h.pattern_lines.len(), 1);
}
#[test]
fn file_definition_is_drawn_instead_of_the_librarys() {
let prims = |lines: Vec<PatternLine>| {
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, lines))).expect("add");
cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).prims.len()
};
// One horizontal family 5 units apart from y = 2.5 across the 10 × 10 square: two lines.
let sparse = vec![PatternLine { angle: 0.0, origin: (0.0, 2.5), delta: (0.0, 5.0), dashes: vec![] }];
assert_eq!(prims(sparse), 2);
// Without file lines, the library's ANSI31 (0.125 apart) is drawn.
assert!(prims(vec![]) > 100);
assert!(h.pattern_lines.is_empty());
}
#[test]
+1 -3
View File
@@ -4,10 +4,8 @@
use cadcraft_doc::*;
use cadcraft_geom::Vec3;
/// Saved as 2007 DXF: older versions have no MULTILEADER (they get LEADER + MTEXT / INSERT).
fn roundtrip(d: &Drawing) -> (String, Drawing) {
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2007), binary: false };
let text = String::from_utf8(cadcraft_io::write_with(d, "x.dxf", &opts).unwrap()).unwrap();
let text = cadcraft_io::write_dxf(d);
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
(text, back)
}
-119
View File
@@ -1,119 +0,0 @@
//! Plot follow-ups (#626): model-space plots use the plot device's paper margins, PNG and SVG
//! plots draw plot style ends, joins and fills as PDF does, and hyperlinks a drawing file gives
//! its entities (`PE_URL` extended data) become PDF link annotations.
use cadcraft_doc::{Common, Drawing, EndStyle, EntityKind, Line, PlotStyle, PlotStyleTable, Space};
use cadcraft_geom::Vec3;
use cadcraft_io::{plotter, read_dxf, write_dxf};
use serde_json::{Value, json};
fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) {
let k = EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) });
d.add(&Space::Model, Common::default(), k).unwrap();
}
fn pdf_text(d: &Drawing, opts: Value) -> String {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) {
m.extend(e.clone());
}
String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned()
}
/// The plot area's clip rectangle (`x y w h re W n`) of the first page.
fn clip(pdf: &str) -> Vec<f64> {
let line = pdf.lines().find(|l| l.ends_with(" re W n")).unwrap_or("");
line.split_whitespace().take(4).filter_map(|t| t.parse().ok()).collect()
}
#[test]
fn model_space_pdf_uses_the_device_margins() {
let mut d = Drawing::new_metric();
line(&mut d, (0.0, 0.0), (100.0, 50.0));
let mm = 72.0 / 25.4;
// No device: the page setup's printable area, as before.
let plain = clip(&pdf_text(&d, json!({})));
assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}");
// A device paper with 20 mm margins: the printable area starts 20 mm in.
let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
assert_eq!(r.len(), 4, "{r:?}");
assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}");
let (w, h) = (r[2].min(r[3]), r[2].max(r[3]));
assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}");
// Hostile margins are ignored.
for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] {
assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}");
}
}
/// A drawing with two lines in a 2 mm wide style with round ends, and a plot style table holding it.
fn styled() -> Drawing {
let mut d = Drawing::new_metric();
line(&mut d, (0.0, 0.0), (100.0, 0.0));
line(&mut d, (0.0, 40.0), (100.0, 60.0));
let round = PlotStyle { name: "Round".into(), lineweight: Some(200), end: EndStyle::Round, ..PlotStyle::default() };
d.plot_style_tables.push(PlotStyleTable::named("round.stb", "", vec![round]));
let hs: Vec<_> = d.model.iter().map(|e| e.handle).collect();
for h in hs {
d.modify_entity(h, |e| e.common.plot_style = "Round".into()).unwrap();
}
d
}
#[test]
fn png_and_svg_plots_draw_plot_style_ends() {
let d = styled();
let png = |table: &str| plotter::plot(&d, &Space::Model, &json!({"output": "png", "resolution": 72, "plotStyleTable": table})).unwrap();
assert_ne!(png("round.stb"), png("None"), "round ends change the image");
let svg = |table: &str| String::from_utf8(plotter::plot(&d, &Space::Model, &json!({"output": "svg", "plotStyleTable": table})).unwrap()).unwrap();
let plain = svg("None");
assert!(plain.contains("<polyline") && !plain.contains("<path"), "{plain}");
let round = svg("round.stb");
assert!(round.contains("<path") && !round.contains("<polyline"), "wide lines with round ends are outlines: {round}");
}
/// DXF text of `d` with `PE_URL` extended data on each LINE, one URL per line in order.
fn with_pe_urls(d: &Drawing, urls: &[&str]) -> String {
let text = write_dxf(d);
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let mut out: Vec<String> = Vec::new();
let mut urls = urls.iter();
let mut in_line = false;
// Extended data goes last in an entity, before the next `0` group.
for pair in lines.chunks(2) {
let [code, value] = pair else { continue };
if *code == "0" {
if in_line && let Some(url) = urls.next() {
for (c, v) in [("1001", "PE_URL"), ("1000", url), ("1002", "{"), ("1000", "A link"), ("1002", "}")] {
out.push(c.to_string());
out.push(v.to_string());
}
}
in_line = *value == "LINE";
}
out.push(code.to_string());
out.push(value.to_string());
}
out.join("\n") + "\n"
}
#[test]
fn pe_url_hyperlinks_become_link_annotations() {
let mut d = Drawing::new_metric();
line(&mut d, (0.0, 0.0), (100.0, 0.0));
line(&mut d, (0.0, 20.0), (100.0, 20.0));
line(&mut d, (0.0, 40.0), (100.0, 40.0));
let text = with_pe_urls(&d, &["https://example.com/plan", "javascript:alert(1)", "data:text/html,x"]);
assert_eq!(text.matches("PE_URL").count(), 3, "the test drawing carries the links");
let read = read_dxf(text.as_bytes()).unwrap();
let pdf = pdf_text(&read, json!({}));
assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "only the web link: {pdf}");
assert!(pdf.contains("https://example.com/plan"));
assert!(!pdf.contains("javascript") && !pdf.contains("data:text"));
// Turned off, and given explicitly for the same object (the explicit one wins).
assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0);
let first = read.model.iter().next().unwrap().handle.hex();
let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]}));
assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}");
}
-15
View File
@@ -878,21 +878,6 @@ fn saving_twice_writes_the_same_records() {
assert_eq!(a.counts(), b.counts());
}
#[test]
fn class_records_have_instance_counts_for_generated_and_preserved_records() {
let (_, a, b) = saves(&fixture());
for out in [&a, &b] {
let counts = out.counts();
for class in out.in_section("CLASSES", "CLASS") {
let name = s(class, 1);
let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64);
assert_eq!(instance_count, counts.get(&name).map(|n| *n as i64), "CLASS {name}");
}
assert!(out.in_section("CLASSES", "CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT"));
assert_eq!(counts.get("ACME_OBJECT"), Some(&1));
}
}
#[test]
fn erased_entities_take_their_extension_data_with_them() {
let mut d = read_dxf(fixture().as_bytes()).expect("read");
+1 -5
View File
@@ -73,11 +73,7 @@ fn multileader_styles_and_current_style_round_trip() {
};
d.mleader_styles.push(callout);
d.header.set_str("CMLEADERSTYLE", "Callout");
let text = write_dxf(&d);
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
let header = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
assert!(header.tags.iter().all(|t| t.code != 9 || t.str() != "$CMLEADERSTYLE"));
let r = read_dxf(text.as_bytes()).expect("reopen");
let r = round_trip(&d);
assert_eq!(r.mleader_styles, d.mleader_styles);
assert_eq!(r.header.str("CMLEADERSTYLE", ""), "Callout");
}
-5
View File
@@ -14,11 +14,6 @@ cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
cadcraft-fonts = { workspace = true }
tiny-skia = { workspace = true }
lyon_tessellation = { workspace = true }
# Display lists of large drawings are built on all cores (not on the web, which has no threads).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+70 -303
View File
@@ -1,17 +1,11 @@
//! Hatch drawing: island detection picks the boundary loops that take part, then a solid,
//! gradient or background fill (even-odd triangles) and the pattern lines (each pattern line
//! family swept across the boundary, even-odd clipped, then dashed).
//!
//! On-screen builds get a level of detail (`Options::min_dash` > 0 is two pixels, `Options::view`
//! the area being looked at): a pattern whose lines run together on screen is drawn as a solid
//! fill of its boundary ([`reads_solid`]), a dash or dot repeat under two pixels as a continuous
//! line (as linetypes already are), and nothing is generated outside the view. Plots, exports and
//! picks pass `min_dash = 0` and `view = None` and get every line.
use super::{Builder, Ctx, Ink, fill, ink, max_scale};
use super::{Builder, Ctx, Ink, fill, ink};
use cadcraft_color::{Color, Rgb};
use cadcraft_doc::library::{PatternLine, pattern_for};
use cadcraft_doc::{Drawing, Entity, Gradient, Hatch};
use cadcraft_doc::library::pattern;
use cadcraft_doc::{Entity, Gradient, Hatch};
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
/// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance.
@@ -20,12 +14,7 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink,
// Gradient and background colours given ByLayer follow the hatch's own layer.
let layer = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
let layer_color = ctx.layer_color(layer).unwrap_or(Color::Index(7));
// Level of detail: a pixel in the hatch's own units, along its most stretched axis (so lines
// that show apart in any direction are never merged); 0 = full detail.
let stretch = max_scale(&ctx.xf);
let px = if stretch > 1e-12 { b.opts.min_dash / 2.0 / stretch } else { 0.0 };
let dense = b.opts.fill && reads_solid(ctx.d, h, px);
if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() || dense {
if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() {
if b.opts.fill {
let tris = fill::triangulate_evenodd(&loops);
match &h.gradient {
@@ -40,12 +29,7 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink,
{
b.tris(ctx, ink(bg, layer_color, ctx.block_color), &fill::triangulate_evenodd(&loops));
}
// The area being looked at, in the hatch's units.
let view = b.opts.view.and_then(|v| {
let inv = ctx.xf.inverse()?;
Some(Bounds2::from_points(v.corners().iter().map(|p| inv.apply(*p))))
});
for seg in pattern_lines(ctx.d, h, &loops, px, view.as_ref()) {
for seg in pattern_lines(h, &loops) {
if seg.len() == 1 {
if let Some(p) = seg.first() {
b.point(ctx, rgb, *p);
@@ -60,39 +44,12 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink,
/// every loop with every other); past it all loops are kept (odd parity).
const MAX_ISLAND_WORK: usize = 20_000_000;
/// True when two polygons have the same vertices (ignoring cyclic shift and winding order).
fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool {
if a.len() != b.len() {
return false;
}
let n = a.len();
if n == 0 {
return true;
}
let near = |p1: Vec2, p2: Vec2| p1.near(p2, 1e-4);
(0..n).any(|k| (0..n).all(|i| near(a[i], b[(i + k) % n]))) || (0..n).any(|k| (0..n).all(|i| near(a[i], b[(k + n - (i % n)) % n])))
}
fn dedup_polygons(loops: &mut Vec<Vec<Vec2>>) {
let mut unique: Vec<Vec<Vec2>> = Vec::with_capacity(loops.len());
for l in loops.drain(..) {
if l.len() < 3 {
continue;
}
if !unique.iter().any(|u| same_polygon(u, &l)) {
unique.push(l);
}
}
*loops = unique;
}
/// The tessellated boundary loops that take part in the fill under the hatch's island
/// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop.
pub(crate) fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
let mut loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
dedup_polygons(&mut loops);
let loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
let max_depth = match h.style {
1 => 1,
2 => 0,
@@ -251,86 +208,16 @@ impl Field {
/// Upper bound on the line families drawn from a pattern definition read from a file.
const MAX_PATTERN_LINES: usize = 1024;
/// Upper bound on the runs of one hatch (AutoCAD's HPMAXLINES default), checked as runs are made
/// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the
/// runs of a full build, so it never leaves out runs that a full build draws.
const MAX_RUNS: usize = 1_000_000;
/// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left
/// out (a full-detail build of a pattern far too fine for its area).
const MAX_FAMILY_LINES: i64 = 20_000;
/// The same bound when the sweep is limited to a view: the count then follows the view's size
/// on screen, and lines closer than a pixel already read as solid, so only very large displays
/// come near it.
const MAX_VIEW_FAMILY_LINES: i64 = 100_000;
/// Line families closer than this on screen (pixels) run together: the hatch reads as solid.
pub const SOLID_SPACING_PX: f64 = 1.0;
/// Average marks per pixel from which a pattern reads as solid (scattered marks leave about
/// e^-2 ≈ 14% of the pixels empty there).
pub const SOLID_COVERAGE: f64 = 2.0;
/// The line families a hatch draws with: the lines its file defined (as its author's CAD drew
/// them), else the library's pattern of that name as drawn in `d`, else ANSI31.
fn lines_of(d: &Drawing, h: &Hatch) -> Vec<PatternLine> {
if h.pattern_lines.is_empty() {
pattern_for(d, &h.pattern).or_else(|| pattern_for(d, "ANSI31")).map(|p| p.lines).unwrap_or_default()
} else {
h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect()
}
}
/// The hatch's pattern scale (1 when unusable).
fn scale_of(h: &Hatch) -> f64 {
if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 }
}
/// True when, at `px` hatch units per pixel, the pattern covers its area on screen, so drawing
/// it line by line would fill (nearly) every pixel anyway. Each line, dash or dot is drawn at
/// least a pixel wide, so that is the case when
/// - some line family has its lines closer than [`SOLID_SPACING_PX`] and no gap along them a
/// pixel or longer (continuous lines, or a repeat that reads as a continuous line), or
/// - the families together put on average [`SOLID_COVERAGE`] or more marks on every pixel
/// (dense dot or dash patterns made of several families).
///
/// A sparse dot or dash pattern keeps its look. `px <= 0` (full detail) is never solid.
pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
if !(px.is_finite() && px > 0.0) {
return false;
}
let scale = scale_of(h);
let mut coverage = 0.0;
for f in lines_of(d, h) {
let spacing = (f.delta.1 * scale).abs();
if !(spacing.is_finite() && spacing > 0.0) {
continue;
}
let pat_len: f64 = f.dashes.iter().map(|d| (d * scale).abs()).sum();
// The share of a line inked on screen (a dash or dot is at least a pixel long there).
let ink: f64 = f.dashes.iter().filter(|d| **d >= 0.0).map(|d| (d * scale).max(px)).sum();
let inked = if f.dashes.is_empty() || (ink > 0.0 && pat_len < 2.0 * px) {
1.0
} else if pat_len > 0.0 {
(ink / pat_len).min(1.0)
} else {
0.0
};
let gap = f.dashes.iter().filter(|d| **d < 0.0).map(|d| (d * scale).abs()).fold(0.0, f64::max);
let draws = f.dashes.is_empty() || ink > 0.0;
if spacing < SOLID_SPACING_PX * px && draws && (inked >= 1.0 || gap < px) {
return true;
}
coverage += inked * px / spacing;
}
coverage.is_finite() && coverage >= SOLID_COVERAGE
}
/// Hatch line runs in world coordinates (single-point runs are dots) for the fill `loops`.
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
/// the part of the hatch inside it is generated; `None` = all of it.
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
let lines = lines_of(d, h);
let scale = scale_of(h);
let px = if px.is_finite() && px > 0.0 { px } else { 0.0 };
pub fn pattern_lines(h: &Hatch, loops: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
// A pattern the library doesn't define draws with the lines its file defined, else as ANSI31.
let lines = match pattern(&h.pattern) {
Some(p) => p.lines,
None if !h.pattern_lines.is_empty() => h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect(),
None => pattern("ANSI31").map(|p| p.lines).unwrap_or_default(),
};
let scale = if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 };
let mut out = Vec::new();
for fam in &lines {
let ang = fam.angle.to_radians() + h.angle;
@@ -341,196 +228,76 @@ pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view:
continue;
}
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, &mut out);
if out.len() >= MAX_RUNS {
family(loops, origin, ang, delta, spacing, &dashes, &mut out);
if out.len() > 500_000 {
break;
}
}
out
}
/// Repeats of a pattern across a swatch ([`pattern_swatch`]).
const SWATCH_REPEATS: f64 = 6.0;
/// Most runs a swatch holds.
const MAX_SWATCH_RUNS: usize = 4000;
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
/// pattern (each family's line spacing or dash repeat, whichever is longer). Empty for a pattern
/// with no lines (SOLID).
pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
let repeat =
lines.iter().map(|l| l.delta.1.abs().max(l.dashes.iter().map(|d| d.abs()).sum::<f64>())).filter(|r| r.is_finite()).fold(0.0, f64::max);
if !side.is_finite() || side <= 0.0 || repeat < 1e-9 {
return Vec::new();
fn family(loops: &[Vec<Vec2>], origin: Vec2, ang: f64, delta: Vec2, spacing: f64, dashes: &[f64], out: &mut Vec<Vec<Vec2>>) {
// Work in a frame where the family's lines are horizontal.
let to_local = |p: Vec2| (p - origin).rotate(-ang);
let to_world = |p: Vec2| origin + p.rotate(ang);
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
if bb.is_empty() {
return;
}
let h = Hatch {
pattern: String::new(),
solid: false,
loops: Vec::new(),
scale: side / (SWATCH_REPEATS * repeat),
angle: 0.0,
associative: false,
style: 0,
elevation: 0.0,
gradient: None,
origin: Vec2::ZERO,
background: None,
pattern_lines: lines.to_vec(),
};
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
runs.truncate(MAX_SWATCH_RUNS);
runs
}
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
struct Family<'a> {
origin: Vec2,
ang: f64,
delta: Vec2,
spacing: f64,
dashes: &'a [f64],
}
impl Family<'_> {
/// Into the frame where the family's lines are horizontal.
fn to_local(&self, p: Vec2) -> Vec2 {
(p - self.origin).rotate(-self.ang)
let shift_per_line = delta.rotate(-ang).x;
let k0 = (bb.min.y / spacing).floor() as i64;
let k1 = (bb.max.y / spacing).ceil() as i64;
if k1.saturating_sub(k0) > 20_000 {
return;
}
fn to_world(&self, p: Vec2) -> Vec2 {
self.origin + p.rotate(self.ang)
}
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, out: &mut Vec<Vec<Vec2>>) {
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
if bb.is_empty() {
return;
}
// The lines and spans swept: the boundary's extent, cut to the view's (its corners in
// this frame) when there is one.
let (mut lo, mut hi) = (bb.min, bb.max);
if let Some(v) = view {
let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p)));
if vb.is_empty() || !vb.intersects(&bb) {
return;
let pat_len: f64 = dashes.iter().map(|d| d.abs()).sum();
for k in k0..=k1 {
let y = k as f64 * spacing;
let mut xs: Vec<f64> = Vec::new();
for l in &local {
let n = l.len();
for i in 0..n {
let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
if (a.y > y) != (b.y > y) {
xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));
}
}
lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y));
hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y));
}
let shift_per_line = self.delta.rotate(-self.ang).x;
let k0 = (lo.y / self.spacing).floor() as i64;
let k1 = (hi.y / self.spacing).ceil() as i64;
let max_lines = if view.is_some() { MAX_VIEW_FAMILY_LINES } else { MAX_FAMILY_LINES };
if k1.saturating_sub(k0) > max_lines {
return;
}
let pat_len: f64 = self.dashes.iter().map(|d| d.abs()).sum();
// Level of detail: a whole repeat under two pixels reads as a continuous line (when the
// family draws anything at all).
let draws = self.dashes.iter().any(|d| *d >= 0.0);
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
for k in k0..=k1 {
if out.len() >= MAX_RUNS {
return;
xs.sort_by(f64::total_cmp);
let offset = shift_per_line * k as f64;
for span in xs.chunks(2) {
let [x0, x1] = span else { continue };
if dashes.is_empty() || pat_len < 1e-12 {
out.push(vec![to_world(Vec2::new(*x0, y)), to_world(Vec2::new(*x1, y))]);
continue;
}
let y = k as f64 * self.spacing;
let mut xs: Vec<f64> = Vec::new();
for l in &local {
let n = l.len();
for i in 0..n {
let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
if (a.y > y) != (b.y > y) {
xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));
// Dash phase anchored at x = offset.
let start_rep = ((x0 - offset) / pat_len).floor();
let mut x = offset + start_rep * pat_len;
let mut guard = 0;
'outer: while x < *x1 {
for d in dashes {
guard += 1;
if guard > 100_000 {
break 'outer;
}
let len = d.abs();
if *d > 0.0 {
let a = x.max(*x0);
let b = (x + len).min(*x1);
if b > a {
out.push(vec![to_world(Vec2::new(a, y)), to_world(Vec2::new(b, y))]);
}
} else if *d == 0.0 && x >= *x0 && x <= *x1 {
out.push(vec![to_world(Vec2::new(x, y))]);
}
x += len;
if x >= *x1 {
break 'outer;
}
}
}
xs.sort_by(f64::total_cmp);
let offset = shift_per_line * k as f64;
for span in xs.chunks(2) {
let [x0, x1] = span else { continue };
let (x0, x1) = if view.is_some() { (x0.max(lo.x), x1.min(hi.x)) } else { (*x0, *x1) };
if view.is_some() && x1 <= x0 {
continue;
}
if solid_line {
out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]);
} else {
self.dash(x0, x1, y, offset, pat_len, out);
}
}
}
}
/// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`.
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>) {
let start_rep = ((x0 - offset) / pat_len).floor();
let mut x = offset + start_rep * pat_len;
let mut guard = 0;
'outer: while x < x1 {
for d in self.dashes {
guard += 1;
if guard > 100_000 || out.len() >= MAX_RUNS {
break 'outer;
}
let len = d.abs();
if *d > 0.0 {
let a = x.max(x0);
let b = (x + len).min(x1);
if b > a {
out.push(vec![self.to_world(Vec2::new(a, y)), self.to_world(Vec2::new(b, y))]);
}
} else if *d == 0.0 && x >= x0 && x <= x1 {
out.push(vec![self.to_world(Vec2::new(x, y))]);
}
x += len;
if x >= x1 {
break 'outer;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cadcraft_doc::{Hatch, HatchLoop};
use cadcraft_geom::PolyVertex;
#[test]
fn fill_loops_deduplicates_coincident_loops() {
let loop1 = HatchLoop {
vertices: vec![
PolyVertex::new(Vec2::new(0.0, 0.0)),
PolyVertex::new(Vec2::new(10.0, 0.0)),
PolyVertex::new(Vec2::new(10.0, 10.0)),
PolyVertex::new(Vec2::new(0.0, 10.0)),
],
outer: true,
sources: vec![],
};
let loop2 = loop1.clone();
let hatch = Hatch {
origin: Vec2::ZERO,
pattern: "SOLID".into(),
solid: true,
loops: vec![loop1, loop2],
scale: 1.0,
angle: 0.0,
associative: false,
style: 0,
elevation: 0.0,
gradient: None,
background: None,
pattern_lines: vec![],
};
let loops = fill_loops(&hatch, 1e-4);
assert_eq!(loops.len(), 1, "fill_loops must deduplicate duplicate loops");
let tris = fill::triangulate_evenodd(&loops);
let area: f64 = tris.chunks(3).map(|t| ((t[1] - t[0]).cross(t[2] - t[0]) / 2.0).abs()).sum();
assert!((area - 100.0).abs() < 1e-6, "triangulated area must be 100.0, not zero");
}
}
+13 -113
View File
@@ -19,7 +19,6 @@ pub mod pen;
mod plotstyle;
mod point;
pub mod raster;
mod text;
mod tolerance;
pub mod units;
mod wide;
@@ -31,7 +30,6 @@ use pen::Pen;
pub use dim::{ArrowGeom, Arrowhead, DimGeometry, DimText, LineRole, arrowhead, dimension_geometry, dimension_geometry_with, format_linear_value};
pub use fill::triangulate_evenodd;
pub use hatch::pattern_swatch;
pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet};
pub use tolerance::{gdt_points, tolerance_in};
@@ -174,8 +172,7 @@ impl DisplayList {
pub struct Options {
/// Chord tolerance in world units (about half a pixel at the current zoom).
pub tolerance: f64,
/// Two pixels in world units: shorter dashes are continuous and filled font text uses
/// screen-size detail. Zero keeps full detail (plots and image/vector exports).
/// Dashes shorter than this (world units) are drawn as continuous lines.
pub min_dash: f64,
/// Draw text (off for very coarse previews).
pub text: bool,
@@ -188,28 +185,14 @@ pub struct Options {
/// Plot style table to draw with. When `None`, [`build`] of a layout whose page setup has
/// "Display plot styles" on uses the layout's table; [`build_plot`] uses none.
pub plot_style_table: Option<std::sync::Arc<cadcraft_doc::PlotStyleTable>>,
/// The area being looked at (world units of the space being built), for on-screen builds:
/// hatch patterns are only generated inside it. `None` = everywhere (plots, exports, picks).
pub view: Option<Bounds2>,
}
impl Default for Options {
fn default() -> Self {
Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None, view: None }
Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None }
}
}
/// The largest axis scale of a transform's linear part (its largest singular value): a length
/// `l` in the transform's own units measures at most `l * max_scale` after it, in any direction.
/// A uniform scale gives its factor, where [`Mat3::scale_factor`] gives the mean of the axes.
fn max_scale(m: &Mat3) -> f64 {
let (a, b, c, d) = (m.a, m.b, m.c, m.d);
let e = (a * a + b * b + c * c + d * d) / 2.0;
let f = (((a * a + b * b - c * c - d * d) / 2.0).powi(2) + (a * c + b * d).powi(2)).sqrt();
let s = (e + f).max(0.0).sqrt();
if s.is_finite() { s } else { 0.0 }
}
/// Graphics state inherited through block references.
#[derive(Clone)]
struct Ctx<'a> {
@@ -251,8 +234,6 @@ struct Builder<'a> {
styles: Option<&'a plotstyle::Styler>,
/// Named plot style (table index) of the entity being drawn.
named: Option<usize>,
/// The block-expansion budget shared by the parts of a list built in parallel.
shared_budget: Option<&'a std::sync::atomic::AtomicUsize>,
}
/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy
@@ -294,9 +275,6 @@ impl Builder<'_> {
self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw, kind: Kind::Polyline, start, len: pts.len() as u32 });
}
fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) {
self.tris_at(ctx, color, tris, ctx.xf);
}
fn tris_at(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2], xf: Mat3) {
if tris.len() < 3 {
return;
}
@@ -304,7 +282,7 @@ impl Builder<'_> {
let (color, _) = self.styled(color, 0.0);
let start = self.list.tris.len() as u32;
for p in tris {
let q = xf.apply(*p);
let q = ctx.xf.apply(*p);
self.list.bounds.add(q);
self.list.tris.push(q);
}
@@ -320,26 +298,16 @@ impl Builder<'_> {
}
let fill = self.opts.fill && ctx.d.header.i64("TEXTFILL", 1) != 0;
// Plots note which filled outlines are font glyphs (see `DisplayList::texts`).
let mut text = sh.text.iter().peekable();
let pixel = if self.plotting { 0.0 } else { self.opts.min_dash * 0.5 };
let mut text = if self.plotting && fill { sh.text.iter().peekable() } else { [].iter().peekable() };
for (i, g) in sh.glyphs.iter().enumerate() {
if fill {
while text.next_if(|t| t.outline < i).is_some() {}
let glyph = text.next_if(|t| t.outline == i);
let tris = fill::triangulate_evenodd(g);
let at = self.list.prims.len();
let len = if let Some(t) = glyph {
let xf = crate::text::placement(t, ctx.xf);
let mesh = crate::text::mesh(t, xf, pixel);
self.tris_at(ctx, color, &mesh.triangles, xf);
mesh.triangles.len()
} else {
let tris = fill::triangulate_evenodd(g);
self.tris(ctx, color, &tris);
tris.len()
};
if let Some(t) = glyph.filter(|_| self.plotting)
self.tris(ctx, color, &tris);
while text.next_if(|t| t.outline < i).is_some() {}
if let Some(t) = text.next_if(|t| t.outline == i)
&& self.list.prims.len() > at
&& let (Ok(prim), Ok(verts)) = (u32::try_from(at), u32::try_from(len))
&& let (Ok(prim), Ok(verts)) = (u32::try_from(at), u32::try_from(tris.len()))
{
let xf = &ctx.xf;
let glyph = cadcraft_fonts::TextGlyph { origin: xf.apply(t.origin), x: xf.apply_vec(t.x), y: xf.apply_vec(t.y), ..t.clone() };
@@ -371,10 +339,7 @@ impl Builder<'_> {
/// Take one unit of the block-expansion budget; false once it is spent.
fn expand(&mut self) -> bool {
self.list.expanded = self.list.expanded.saturating_add(1);
match self.shared_budget {
Some(spent) => spent.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < MAX_BLOCK_EXPANSION,
None => self.list.expanded <= MAX_BLOCK_EXPANSION,
}
self.list.expanded <= MAX_BLOCK_EXPANSION
}
}
@@ -417,17 +382,12 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
_ => None,
});
let styler = table.map(|t| plotstyle::Styler::new(d, t));
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None };
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None };
if let Space::Paper(name) = space {
b.list.sheet = paper::sheet(d, name);
}
if let Some(store) = d.space(space) {
let paper = matches!(space, Space::Paper(_));
#[cfg(not(target_arch = "wasm32"))]
if !paper && !plotting && b.styles.is_none() && store.len() >= 2 * PARALLEL_CHUNK {
build_parallel(&mut b, d, store);
return b.list;
}
let mut viewports = 0usize;
for e in store.iter() {
if paper && let EntityKind::Viewport(vp) = &e.kind {
@@ -450,66 +410,6 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
b.list
}
/// Top-level entities per task when a display list is built on all cores.
#[cfg(not(target_arch = "wasm32"))]
const PARALLEL_CHUNK: usize = 256;
/// Build model space for the screen on all cores: the entities are split into chunks built
/// side by side, then joined in drawing order, so the list is the same as a one-thread build.
/// The canvas rebuilds the whole list at every 2x zoom step, on the UI thread; one core made
/// that a visible stall on real drawings. Plots, layouts and plot styles keep the one-thread
/// path (they run once, and their pens and per-primitive layers are built along the way).
#[cfg(not(target_arch = "wasm32"))]
fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStore) {
use rayon::prelude::*;
let ents: Vec<&std::sync::Arc<Entity>> = store.iter().collect();
let budget = std::sync::atomic::AtomicUsize::new(0);
let (opts, plotting) = (b.opts, b.plotting);
let parts: Vec<DisplayList> = ents
.par_chunks(PARALLEL_CHUNK)
.map(|chunk| {
let mut part = Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget) };
for e in chunk {
entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
part.list
})
.collect();
// One allocation per array for the joined list.
b.list.prims.reserve(parts.iter().map(|p| p.prims.len()).sum());
b.list.verts.reserve(parts.iter().map(|p| p.verts.len()).sum());
b.list.tris.reserve(parts.iter().map(|p| p.tris.len()).sum());
for part in parts {
append(&mut b.list, part);
}
}
/// Append `src` to `dst`: its primitives index into `dst`'s vertex and triangle arrays.
#[cfg(not(target_arch = "wasm32"))]
fn append(dst: &mut DisplayList, src: DisplayList) {
let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32);
// Pens are parallel to the primitives, with missing entries meaning "use object".
if !src.pens.is_empty() {
dst.pens.resize(dst.prims.len(), Pen::default());
}
dst.prims.extend(src.prims.into_iter().map(|mut p| {
p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts };
p
}));
dst.verts.extend(src.verts);
dst.tris.extend(src.tris);
dst.prim_layers.extend(src.prim_layers);
dst.texts.extend(src.texts.into_iter().map(|mut t| {
if t.prim < PlotText::CLIPPED {
t.prim += prims;
}
t
}));
dst.pens.extend(src.pens);
dst.bounds = dst.bounds.union(&src.bounds);
dst.expanded = dst.expanded.saturating_add(src.expanded);
}
/// `opts` with a view height for relative point sizes: the extents height when none is given
/// and points draw as figures sized relative to the view.
fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options {
@@ -564,7 +464,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
let mcenter = vp.view_center + (rect.center() - center) / s;
// Model window with slack: entity bounds are approximate (text, dimensions).
let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, shared_budget: None };
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None };
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
// paper space, whatever the viewport scale.
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
@@ -702,7 +602,7 @@ pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents:
} else {
opts
};
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None };
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None };
for e in ents {
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
-198
View File
@@ -385,201 +385,3 @@ fn table_title_is_centred_and_merged() {
let b = Bounds2::from_points(text);
assert!((b.center().x - 3.0).abs() < 0.05, "{b:?}");
}
// ---------- hatch level of detail (on-screen builds, issue #544) ----------
fn pattern_hatch(pattern: &str, scale: f64, (x0, y0, x1, y1): (f64, f64, f64, f64)) -> Hatch {
let sq = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| PolyVertex::new(Vec2::new(x, y)));
Hatch {
pattern: pattern.into(),
solid: false,
loops: vec![HatchLoop { vertices: sq.to_vec(), outer: true, sources: Vec::new() }],
scale,
angle: 0.0,
associative: false,
style: 0,
elevation: 0.0,
gradient: None,
origin: Vec2::ZERO,
background: None,
pattern_lines: Vec::new(),
}
}
fn drawing_with(h: Hatch) -> Drawing {
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap();
d
}
fn loops_of(h: &Hatch) -> Vec<Vec<Vec2>> {
crate::hatch::fill_loops(h, 0.001)
}
/// On-screen options for a view where one pixel is `px` drawing units.
fn screen(px: f64, view: Option<Bounds2>) -> Options {
Options { tolerance: px / 2.0, min_dash: px * 2.0, view, ..Default::default() }
}
#[test]
fn dense_hatch_on_screen_is_one_solid_fill() {
// ANSI31 at scale 0.01: lines 0.00125 apart over a 4x4 square. On a 1000 px view of it a
// pixel is 0.004, so the lines run together: one solid fill. Plots keep every line.
let d = drawing_with(pattern_hatch("ANSI31", 0.01, (0.0, 0.0, 4.0, 4.0)));
let plot = build(&d, &Space::Model, &Options::default());
assert!(plot.prims.len() > 4000, "plots keep every line: {}", plot.prims.len());
let l = build(&d, &Space::Model, &screen(0.004, None));
assert_eq!(l.prims.len(), 1);
assert_eq!(l.prims[0].kind, Kind::Tris);
}
#[test]
fn sparse_dots_keep_their_look_when_lines_are_close() {
// Dot rows half a pixel apart but dots five pixels apart along them: not solid colour.
let fam = |dashes: &[f64]| cadcraft_doc::library::PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 0.5), dashes: dashes.to_vec() };
let sparse = Hatch { pattern_lines: vec![fam(&[0.0, -5.0])], ..pattern_hatch("MYDOTS", 1.0, (0.0, 0.0, 40.0, 40.0)) };
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &sparse, 1.0));
// Dots closer than a pixel along rows closer than a pixel: solid.
let dense = Hatch { pattern_lines: vec![fam(&[0.0, -0.5])], ..sparse.clone() };
assert!(crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &dense, 1.0));
// A family with only gaps draws nothing, so it never reads as solid.
let blank = Hatch { pattern_lines: vec![fam(&[-0.5])], ..sparse.clone() };
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &blank, 1.0));
assert!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &blank, &loops_of(&blank), 1.0, None).is_empty());
// Full detail is never solid.
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &dense, 0.0));
}
#[test]
fn scattered_dot_pattern_reads_solid_once_dots_fill_the_pixels() {
// AR-SAND at scale 3/16 (the library pattern is drawn 16x coarser since #643, so this is the
// geometry of the old scale 3): three dot families, no single one dense enough on its own at
// 0.5 per pixel, but together about four dots on every pixel. Zoomed in 4x the dots stand apart.
let h = pattern_hatch("AR-SAND", 3.0 / 16.0, (0.0, 0.0, 100.0, 100.0));
assert!(crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, 0.5));
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, 0.125));
}
#[test]
fn normal_zoom_pattern_is_unchanged() {
// Lines and dash repeats a few pixels long or more: the level of detail changes nothing.
for (pattern, px) in [("ANSI31", 0.05), ("ANSI33", 0.05), ("AR-CONC", 0.02), ("AR-SAND", 0.01), ("DOTS", 0.01)] {
let h = pattern_hatch(pattern, 1.0, (0.0, 0.0, 6.0, 4.0));
let loops = loops_of(&h);
let full = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.0, None);
assert!(!full.is_empty(), "{pattern}");
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, px), "{pattern}");
assert_eq!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, px, None), full, "{pattern}");
// A view covering the whole hatch changes nothing either.
let all = Bounds2::new(Vec2::new(-1.0, -1.0), Vec2::new(7.0, 5.0));
assert_eq!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, px, Some(&all)), full, "{pattern}");
}
}
#[test]
fn dash_repeat_under_two_pixels_is_one_run() {
// ANSI33's dashed family repeats every 0.1875: at 0.1 per pixel it reads as a continuous line,
// drawn as one run per span covering the same length as the full-detail span.
let h = pattern_hatch("ANSI33", 1.0, (0.0, 0.0, 6.0, 4.0));
let loops = loops_of(&h);
let full = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.0, None);
let lod = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.1, None);
assert!(lod.len() < full.len(), "{} vs {}", lod.len(), full.len());
assert!(lod.iter().all(|r| r.len() == 2));
// The continuous family: identical runs in both.
let solid_runs = |v: &[Vec<Vec2>]| v.iter().filter(|r| r.len() == 2).count();
assert!(solid_runs(&lod) > 0 && solid_runs(&full) > solid_runs(&lod));
}
#[test]
fn hatch_pattern_only_inside_the_view() {
// A 40x40 ANSI31 hatch looked at through a 2x2 view in a corner, at full pixel detail.
let d = drawing_with(pattern_hatch("ANSI31", 1.0, (0.0, 0.0, 40.0, 40.0)));
let full = build(&d, &Space::Model, &Options::default());
let far = Some(Bounds2::new(Vec2::new(100.0, 100.0), Vec2::new(110.0, 110.0)));
assert_eq!(build(&d, &Space::Model, &Options { view: far, ..Default::default() }).prims.len(), 0);
let corner = Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(2.0, 2.0));
let part = build(&d, &Space::Model, &Options { view: Some(corner), ..Default::default() });
assert!(!part.prims.is_empty() && part.prims.len() * 10 < full.prims.len(), "{} of {}", part.prims.len(), full.prims.len());
// Runs are cut in the family's rotated frame: a 2x2 view at 45° reaches 1 unit out, plus one
// line spacing (0.125 × √2 along an axis) from rounding the line range outwards.
let near = corner.expand(1.0 + 0.125 * 2f64.sqrt() + 1e-9);
assert!(part.verts.iter().all(|p| near.contains(*p)));
// Nothing is lost inside the view: the drawn length within an inner box is the same.
let inner = Bounds2::new(Vec2::new(0.2, 0.2), Vec2::new(1.8, 1.8));
let (a, b) = (length_inside(&full, &inner), length_inside(&part, &inner));
assert!(a > 1.0 && (a - b).abs() < 1e-9, "{a} vs {b}");
}
/// Total length of a display list's polylines inside an axis-aligned box (Liang–Barsky).
fn length_inside(l: &DisplayList, bx: &Bounds2) -> f64 {
let mut total = 0.0;
for p in l.prims.iter().filter(|p| p.kind == Kind::Polyline) {
for w in l.points(p).windows(2) {
let (p0, d) = (w[0], w[1] - w[0]);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
let mut ok = true;
for (pp, q) in [(-d.x, p0.x - bx.min.x), (d.x, bx.max.x - p0.x), (-d.y, p0.y - bx.min.y), (d.y, bx.max.y - p0.y)] {
if pp == 0.0 {
ok &= q >= 0.0;
} else if pp < 0.0 {
t0 = t0.max(q / pp);
} else {
t1 = t1.min(q / pp);
}
}
if ok && t1 > t0 {
total += d.len() * (t1 - t0);
}
}
}
total
}
#[test]
fn big_hatch_seen_through_a_wide_view_is_drawn() {
// ANSI31 (0.125 apart) over ±1e5 seen through a 3000-unit view: about 34,000 lines in the
// family's rotated frame, past the 20,000-line guard of a whole-hatch sweep, but still drawn
// when the sweep is limited to the view.
let d = drawing_with(pattern_hatch("ANSI31", 1.0, (-1e5, -1e5, 1e5, 1e5)));
let view = Some(Bounds2::new(Vec2::new(-1500.0, -1500.0), Vec2::new(1500.0, 1500.0)));
let l = build(&d, &Space::Model, &Options { view, ..Default::default() });
assert!(l.prims.len() > 20_000, "{}", l.prims.len());
}
#[test]
fn hatch_in_a_squashed_block_keeps_lines_that_show() {
// A block hatch inserted 100x wider than tall: along x its lines are spread far apart on
// screen, so it is drawn with lines, not merged into a fill.
let mut d = Drawing::new_imperial();
let mut blk = cadcraft_doc::Block::new("B");
let mut e = cadcraft_doc::Entity::new(cadcraft_doc::Handle(1), EntityKind::Hatch(pattern_hatch("ANSI31", 1.0, (0.0, 0.0, 4.0, 4.0))));
e.common = Common::default();
blk.entities.push(e);
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
let ins = cadcraft_doc::Insert {
block: "B".into(),
insert: Vec3::new(0.0, 0.0, 0.0),
scale: Vec3::new(100.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
// One pixel = 1 unit: 0.125 spacing × 100 along x is far more than a pixel.
let l = build(&d, &Space::Model, &screen(1.0, None));
assert!(l.prims.iter().all(|p| p.kind != Kind::Tris), "merged into a fill");
assert!(l.prims.len() > 10, "{}", l.prims.len());
}
#[test]
fn max_scale_is_the_most_stretched_axis() {
let m = Mat3::scale(1.0, 0.1);
assert!((max_scale(&m) - 1.0).abs() < 1e-12);
let r = Mat3::rotate(0.7).then(Mat3::scale(2.0, 2.0));
assert!((max_scale(&r) - 2.0).abs() < 1e-12);
assert!((max_scale(&Mat3::IDENTITY) - 1.0).abs() < 1e-12);
}
-192
View File
@@ -1,192 +0,0 @@
//! Filled font glyphs: bounded font-unit meshes shared across placements and zoom rebuilds.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
use cadcraft_fonts::{TextGlyph, ttf};
use cadcraft_geom::{Bounds2, Mat3, Vec2};
use lyon_tessellation::geometry_builder::{BuffersBuilder, VertexBuffers};
use lyon_tessellation::math::point;
use lyon_tessellation::path::Path;
use lyon_tessellation::{FillOptions, FillTessellator, FillVertex};
const MAX_POINTS: usize = 16_384;
const MAX_ENTRIES: usize = 8192;
const MAX_CACHE_BYTES: usize = 64 << 20;
const MAX_SIMPLIFY_STEPS: usize = 1_000_000;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Detail {
Full,
/// A power-of-two tolerance in font units, rounded toward finer detail.
Reduced(i16),
/// An em occupies at most two pixels in every direction: show the ink's extent.
Box,
}
type Key = (usize, usize, u32, u16, Detail);
pub(super) struct Mesh {
contours: Arc<[Vec<Vec2>]>,
pub triangles: Vec<Vec2>,
}
struct Entry {
// Keep the bytes alive: registering a replacement font must never reuse this key's address.
_face: ttf::Face,
mesh: Arc<Mesh>,
}
#[derive(Default)]
struct Cache {
entries: HashMap<Key, Entry>,
bytes: usize,
}
/// A read-write lock: display lists are built on all cores and nearly every lookup is a hit.
fn cache() -> &'static RwLock<Cache> {
static CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();
CACHE.get_or_init(|| RwLock::new(Cache::default()))
}
/// World-space placement of font units, including the block/viewport transform.
pub(super) fn placement(g: &TextGlyph, xf: Mat3) -> Mat3 {
xf.then_before(Mat3 { a: g.x.x / 1000.0, b: g.x.y / 1000.0, c: g.y.x / 1000.0, d: g.y.y / 1000.0, e: g.origin.x, f: g.origin.y })
}
fn detail(xf: Mat3, pixel: f64) -> Detail {
// The Frobenius norm bounds the largest stretch, including shear, mirroring and unequal
// block scales. A font-unit error below pixel / stretch is below a pixel in every direction.
let stretch = xf.a.hypot(xf.b).hypot(xf.c.hypot(xf.d));
if !(pixel.is_finite() && pixel > 0.0 && stretch.is_finite() && stretch > 0.0) {
return Detail::Full;
}
if stretch * 1000.0 <= pixel * 2.0 {
return Detail::Box;
}
let tolerance = pixel * 0.25 / stretch;
if !tolerance.is_finite() || tolerance < 0.0625 { Detail::Full } else { Detail::Reduced(tolerance.log2().floor().clamp(-4.0, 16.0) as i16) }
}
pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc<Mesh> {
let detail = detail(xf, pixel);
let key = (g.face.bytes.as_ptr() as usize, g.face.bytes.len(), g.face.index, g.gid, detail);
if let Some(entry) = cache().read().unwrap_or_else(PoisonError::into_inner).entries.get(&key) {
return entry.mesh.clone();
}
let contours = ttf::glyph_contours(&g.face, g.gid);
let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add);
// Font data is untrusted; don't hand unbounded or invalid paths to the tessellator.
if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(|p| !p.is_finite() || p.x.abs().max(p.y.abs()) > 1.0e6) {
return Arc::new(Mesh { contours: Arc::default(), triangles: Vec::new() });
}
let contours = match detail {
Detail::Full => contours,
Detail::Reduced(band) => contours.iter().map(|c| simplify(c, 2f64.powi(i32::from(band)))).collect::<Vec<_>>().into(),
Detail::Box => {
let bb = Bounds2::from_points(contours.iter().flatten().copied());
if bb.is_empty() {
Arc::default()
} else {
vec![vec![bb.min, Vec2::new(bb.max.x, bb.min.y), bb.max, Vec2::new(bb.min.x, bb.max.y), bb.min]].into()
}
}
};
let mesh = Arc::new(Mesh { triangles: triangulate(&contours), contours });
let points = mesh.contours.iter().map(Vec::capacity).sum::<usize>().saturating_add(mesh.triangles.capacity());
let bytes = points
.saturating_mul(std::mem::size_of::<Vec2>())
.saturating_add(mesh.contours.len().saturating_mul(std::mem::size_of::<Vec<Vec2>>()))
.saturating_add(std::mem::size_of::<Entry>() + std::mem::size_of::<Key>() + std::mem::size_of::<Mesh>());
if bytes <= MAX_CACHE_BYTES {
let mut c = cache().write().unwrap_or_else(PoisonError::into_inner);
if c.entries.len() >= MAX_ENTRIES || c.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
c.entries.clear();
c.bytes = 0;
}
if !c.entries.contains_key(&key) {
c.bytes = c.bytes.saturating_add(bytes);
c.entries.insert(key, Entry { _face: g.face.clone(), mesh: mesh.clone() });
}
}
mesh
}
/// Iterative Douglas-Peucker on a closed contour. Never collapse a hole to a line or recurse
/// on font data; preserve the original if the work budget is exhausted.
fn simplify(contour: &[Vec2], tolerance: f64) -> Vec<Vec2> {
if contour.len() < 5 {
return contour.to_vec();
}
let mut points = contour.to_vec();
if let (Some(&first), Some(&last)) = (points.first(), points.last())
&& first != last
{
points.push(first);
}
let last = points.len().saturating_sub(1);
let mut keep = vec![false; points.len()];
if let Some(k) = keep.first_mut() {
*k = true;
}
if let Some(k) = keep.last_mut() {
*k = true;
}
let mut pending = vec![(0usize, last)];
let mut steps = 0usize;
while let Some((start, end)) = pending.pop() {
let (Some(&a), Some(&b)) = (points.get(start), points.get(end)) else { continue };
let ab = b - a;
let len2 = ab.len2();
let mut furthest = None;
let mut distance = tolerance;
for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) {
steps += 1;
if steps > MAX_SIMPLIFY_STEPS {
return contour.to_vec();
}
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
let d = p.dist(a + ab * t);
if d > distance {
distance = d;
furthest = Some(i);
}
}
if let Some(i) = furthest {
if let Some(k) = keep.get_mut(i) {
*k = true;
}
pending.extend([(start, i), (i, end)]);
}
}
let result: Vec<_> = points.into_iter().zip(keep).filter_map(|(p, keep)| keep.then_some(p)).collect();
if result.len() >= 4 { result } else { contour.to_vec() }
}
/// Monotone tessellation with even-odd holes and intersections. Work in font units so f32
/// precision is independent of drawing coordinates; the placement stays f64. The existing
/// slab filler remains the fallback when tessellation fails (including its u16 vertex limit).
fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
let mut path = Path::builder();
for contour in contours {
let Some(&first) = contour.first().filter(|_| contour.len() >= 3) else { continue };
path.begin(point(first.x as f32, first.y as f32));
for p in contour.iter().skip(1) {
path.line_to(point(p.x as f32, p.y as f32));
}
path.end(true);
}
let mut buffers: VertexBuffers<Vec2, u16> = VertexBuffers::new();
let result = FillTessellator::new().tessellate_path(
&path.build(),
&FillOptions::even_odd(),
&mut BuffersBuilder::new(&mut buffers, |v: FillVertex<'_>| {
let p = v.position();
Vec2::new(f64::from(p.x), f64::from(p.y))
}),
);
if result.is_err() {
return crate::fill::triangulate_evenodd(contours);
}
buffers.indices.into_iter().filter_map(|i| buffers.vertices.get(usize::from(i)).copied()).collect()
}
-47
View File
@@ -164,50 +164,3 @@ fn pattern_lines_from_a_file_are_drawn_for_unknown_names() {
let p = l.prims.iter().find(|p| p.kind == Kind::Polyline).map(|p| l.points(p)).expect("lines");
assert!(((p[1] - p[0]).angle().to_degrees().rem_euclid(180.0) - 45.0).abs() < 1e-6);
}
#[test]
fn pattern_swatches_stay_in_their_square_and_show_a_few_repeats() {
for p in cadcraft_doc::library::standard_patterns() {
let runs = cadcraft_render::pattern_swatch(&p.lines, 1.0);
if p.lines.is_empty() {
assert!(runs.is_empty(), "{}", p.name);
continue;
}
assert!(runs.len() >= 3, "{} has {} runs", p.name, runs.len());
assert!(runs.len() <= 4000, "{} has {} runs", p.name, runs.len());
for v in runs.iter().flatten() {
assert!((-1e-9..=1.0 + 1e-9).contains(&v.x) && (-1e-9..=1.0 + 1e-9).contains(&v.y), "{} at {v:?}", p.name);
}
}
assert!(cadcraft_render::pattern_swatch(&[], 1.0).is_empty());
}
#[test]
fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
let l = draw(hatch("BRICK", vec![square(0.0, 2.0)]), "0");
// Vertical joints: (x, course) for every vertical segment, rounded.
let mut joints: Vec<(i64, i64)> = l
.prims
.iter()
.filter(|p| p.kind == Kind::Polyline)
.filter_map(|p| match l.points(p) {
[a, b] if (a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() > 1e-9 => {
Some(((a.x * 1000.0).round() as i64, ((a.y + b.y) / 2.0 / 0.25).floor() as i64))
}
_ => None,
})
.collect();
let n = joints.len();
joints.sort_unstable();
joints.dedup();
assert_eq!(joints.len(), n, "a joint drawn twice");
// Within a course joints are a brick (0.5) apart; the next course is offset half a brick.
let xs = |course: i64| joints.iter().filter(|j| j.1 == course).map(|j| j.0).collect::<Vec<_>>();
for course in 0..4 {
let x = xs(course);
assert!(x.len() >= 3, "course {course}: {x:?}");
assert!(x.windows(2).all(|w| w[1] - w[0] == 500), "course {course}: {x:?}");
let next = xs(course + 1);
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
}
}
-63
View File
@@ -29,14 +29,6 @@ pub fn load_prefs(json: &str) {
}
}
/// What the GPU error notice says (see [`gpu_notice`]).
#[derive(Clone, Debug, PartialEq)]
pub struct GpuNotice {
pub fault: crate::gpu::GpuFault,
/// Drawings autosaved because of it (0: nothing unsaved, or autosave is off).
pub autosaved: usize,
}
/// Drawing Recovery: autosave files left behind by an app that stopped, listed once at start.
#[derive(Clone, Debug, Default)]
pub struct Recovery {
@@ -89,19 +81,6 @@ impl CadApp {
}
}
/// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the
/// session may not survive it) and show [`gpu_notice`].
pub fn gpu_fault_tick(&mut self, ctx: &egui::Context) {
let Some(fault) = self.gpu_faults.take() else { return };
#[cfg(not(target_arch = "wasm32"))]
let saved = self.autosave.as_mut().map_or(0, |a| a.save_now(&self.session));
#[cfg(target_arch = "wasm32")]
let saved = 0;
let autosaved = self.gpu_notice.as_ref().map_or(0, |n| n.autosaved) + saved;
self.gpu_notice = Some(GpuNotice { fault, autosaved });
ctx.request_repaint();
}
/// The app closes normally: this app's autosave files are not needed any more.
pub fn shutdown_autosave(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
@@ -184,48 +163,6 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
}
}
/// The notice after a GPU error or device loss: what happened, what was autosaved, and what to
/// do. Not modal, so drawings can still be saved while the device works.
pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) {
let Some(n) = &app.gpu_notice else { return };
let t = Tokens::get();
let mut dismiss = false;
egui::Window::new("Graphics error")
.id(egui::Id::new("cc_gpu_notice"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0))
.show(ctx, |ui| {
ui.set_width(440.0);
if n.fault.lost {
ui.label("The graphics device stopped working (a driver reset or a GPU that stopped responding). CADCraft can't draw again until it restarts.");
} else {
let times = if n.fault.errors == 1 { String::new() } else { format!(" ({} times)", n.fault.errors) };
ui.label(format!("The graphics driver reported an error{times}. CADCraft kept running, but the drawing may not display correctly."));
}
ui.add_space(4.0);
match n.autosaved {
0 => ui.label("Nothing needed autosaving."),
1 => ui.label("A recovery copy of the open drawing with unsaved changes was autosaved."),
k => ui.label(format!("Recovery copies of {k} drawings with unsaved changes were autosaved.")),
};
ui.label(RichText::new("Save your work and restart CADCraft. Recovery copies are offered at the next start if needed.").strong());
ui.add_space(4.0);
ui.collapsing("Details", |ui| {
ui.label(RichText::new(&n.fault.message).color(t.text_dim).monospace());
});
// A row just as tall as the button (a plain right-to-left layout takes the rest).
ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Dismiss").clicked() {
dismiss = true;
}
});
});
if dismiss {
app.gpu_notice = None;
}
}
#[cfg(test)]
mod tests {
#[test]
+40 -327
View File
@@ -38,10 +38,7 @@ impl Xf {
#[derive(Default)]
pub struct CanvasState {
pub list: Option<DisplayList>,
key: (u64, u64, i32, i32, bool, usize),
/// The area `list` was built for (hatch patterns exist only inside it); a view leaving it
/// rebuilds the list.
window: Option<Bounds2>,
key: (u64, u64, i32, bool, usize),
/// Bumped whenever `list` is rebuilt (identifies it for [`PrimIndex`]).
list_gen: u64,
prim_index: PrimIndex,
@@ -51,20 +48,6 @@ pub struct CanvasState {
mesh_key: Option<u64>,
mesh_slot: crate::gpu::MeshSlot,
mesh_origin: Vec2,
/// The dragged objects of a MOVE/COPY/ROTATE/SCALE preview, uploaded once per drag (see
/// [`draw_drag_gpu`]).
drag_key: Option<u64>,
drag_slot: crate::gpu::MeshSlot,
drag_origin: Vec2,
/// The selection highlight on the GPU (see [`draw_highlight_gpu`]).
hl_key: Option<u64>,
hl_slot: crate::gpu::MeshSlot,
hl_origin: Vec2,
/// Selected infinite lines (clipped and drawn on the CPU each frame).
hl_infinite: Vec<usize>,
/// The highlight geometry of the active viewport's model space, with the key it was built for
/// (see [`viewport_highlight_tolerance`]).
vp_hl: Option<(u64, DisplayList)>,
/// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode).
infinite: Vec<usize>,
/// Milliseconds spent converting the display list to vertex data (last rebuild).
@@ -159,65 +142,32 @@ fn color32(c: Rgb) -> Color32 {
Color32::from_rgb(c.0, c.1, c.2)
}
/// Rebuild the display list when needed. `px`: drawing units per pixel in the current view;
/// `views`: every area on screen that shows the list (the current view, other model tiles) with
/// its drawing units per pixel.
fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) {
fn ensure_list(app: &mut CadApp, px: f64) {
let Ok(st) = app.session.state() else { return };
// Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a
// view leaves the area the list was built for.
let band = |px: f64| px.max(1e-300).log2().floor() as i32;
// Hatch level of detail follows the finest view showing the list.
let lod_band = views.iter().map(|v| band(v.1)).fold(band(px), i32::min);
let key = (st.uid, st.revision, band(px), lod_band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize);
let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0));
let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible));
if app.canvas.list.is_some() && app.canvas.key == key && inside {
// Rebuild when the drawing changes or the zoom moves by more than 2x (tessellation band).
let band = px.max(1e-300).log2().floor() as i32;
let key = (st.uid, st.revision, band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize);
if app.canvas.list.is_some() && app.canvas.key == key {
return;
}
let t0 = crate::now_ms();
let opts = cadcraft_render::Options {
tolerance: 2f64.powi(band(px)) * 0.5,
// Two pixels at the band's finest zoom: the list serves every zoom in the band, so its
// level of detail must hold at the finest one.
min_dash: 2f64.powi(lod_band) * 2.0,
tolerance: px * 0.5 * 2f64.powi(band) / px.max(1e-300) * px.min(1.0e300),
min_dash: px * 2.0,
text: true,
fill: true,
lineweights: app.session.settings.lwdisplay,
view_height: st.paper_view().height,
plot_style_table: None,
view: build_window(views),
};
let opts = cadcraft_render::Options { tolerance: 2f64.powi(band) * 0.5, ..opts };
let space = st.space.clone();
app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts));
app.canvas.key = key;
app.canvas.window = opts.view;
app.canvas.list_gen = app.canvas.list_gen.wrapping_add(1);
app.canvas.build_ms = crate::now_ms() - t0;
}
/// Tessellation tolerance for the highlights inside the active viewport: `scale` (pixels per
/// drawing unit) snapped to its 2x zoom band, as in [`ensure_list`], so zooming and panning
/// inside the band doesn't rebuild the geometry.
fn viewport_highlight_tolerance(scale: f64) -> f64 {
2f64.powi((1.0 / scale.max(1e-300)).log2().floor() as i32) * 0.5
}
/// The area a display list is built for: each view with half its larger side of margin all
/// round (panning by up to that much doesn't rebuild), joined. `None` (everything) when a view
/// isn't usable.
fn build_window(views: &[(Bounds2, f64)]) -> Option<Bounds2> {
let mut w = Bounds2::EMPTY;
for (v, _) in views {
let side = v.width().max(v.height());
if v.is_empty() || !side.is_finite() {
return None;
}
w = w.union(&v.expand(side * 0.5));
}
(!w.is_empty()).then_some(w)
}
/// Grid spacing adapted to the zoom: multiply the unit until lines are at least `min_px` apart.
fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 {
let mut u = unit.max(1e-12);
@@ -372,117 +322,6 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay:
p.extend(shapes);
}
/// Draw the selection highlight (dashed, `width` points) through the GPU: its vertex data is
/// rebuilt only when the display list, the selection or the vertex origin changes. Dashing every
/// selected line and tessellating it as egui shapes each frame made large selections sluggish.
#[allow(clippy::too_many_arguments)]
fn draw_highlight_gpu(
p: &egui::Painter,
xf: &Xf,
list: &DisplayList,
list_gen: u64,
prim_index: &mut PrimIndex,
sel: &[Handle],
color: Color32,
width: f32,
hl: (&mut Option<u64>, &crate::gpu::MeshSlot, &mut Vec2, &mut Vec<usize>),
) {
let (hl_key, hl_slot, hl_origin, hl_infinite) = hl;
let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300));
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(list_gen, sel, origin.x.to_bits(), origin.y.to_bits(), color.to_array(), width.to_bits()).hash(&mut h);
h.finish()
};
if *hl_key != Some(key) {
let prims = prim_index.prims(list_gen, list, sel);
let mesh = crate::gpu::build_highlight_mesh(list, &prims, origin, color.to_array(), width);
*hl_infinite = prims.into_iter().filter(|&i| list.prims.get(i).is_some_and(|p| matches!(p.kind, Kind::Infinite { .. }))).collect();
if let Ok(mut slot) = hl_slot.lock() {
*slot = Some(mesh);
}
*hl_key = Some(key);
*hl_origin = origin;
}
let center = xf.rect.center();
p.add(egui_wgpu::Callback::new_paint_callback(
xf.rect,
crate::gpu::CanvasCallback {
key,
slot: hl_slot.clone(),
offset: [(hl_origin.x - xf.center.x) as f32, (hl_origin.y - xf.center.y) as f32],
scale: xf.scale as f32,
center: [center.x, center.y],
layer: crate::gpu::LAYER_HIGHLIGHT,
xform: crate::gpu::IDENTITY_XFORM,
dash: 10.0,
},
));
}
/// Draw a MOVE/COPY/ROTATE/SCALE preview the way the drawing itself is drawn: the dragged
/// objects are tessellated and uploaded once (again only when the drawing, the selection or the
/// zoom band changes), and each frame only the transform in the view uniform moves. Rebuilding
/// the transformed copies as egui shapes every frame was slow with heavy selections and could
/// overflow the GPU buffer limit.
/// `clipboard` is the session's clipboard and its serial (dragged when `drag.clipboard`, by PASTE).
fn draw_drag_gpu(
c: &mut CanvasState,
p: &egui::Painter,
xf: &Xf,
doc: &cadcraft_doc::Drawing,
drag: &cadcraft_engine::Drag,
clipboard: (&[cadcraft_doc::Entity], u64),
bg: Rgb,
) {
// Tessellate for a power-of-two zoom band so zooming mid-drag rebuilds only every 2x.
let band = xf.scale.max(1e-300).log2().round() as i32;
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(c.key, band, bg.0, bg.1, bg.2, &drag.handles).hash(&mut h);
if drag.clipboard {
clipboard.1.hash(&mut h);
}
h.finish()
};
if c.drag_key != Some(key) {
let band_scale = 2f64.powi(band);
let opts = cadcraft_render::Options { tolerance: 0.5 / band_scale, min_dash: 2.0 / band_scale, ..Default::default() };
let list = if drag.clipboard {
cadcraft_render::build_entities(doc, clipboard.0.iter(), &opts)
} else {
cadcraft_render::build_entities(doc, drag.handles.iter().filter_map(|h| doc.entity(*h)).map(|e| e.as_ref()), &opts)
};
let origin = if list.bounds.is_empty() { Vec2::ZERO } else { list.bounds.center() };
let mesh = crate::gpu::build_mesh(&list, origin, bg, false);
if let Ok(mut slot) = c.drag_slot.lock() {
*slot = Some(mesh);
}
c.drag_key = Some(key);
c.drag_origin = origin;
}
// Positions are relative to the origin o: M(o + p) = L·p + (M(o) − o).
let m = drag.matrix;
let o = c.drag_origin;
let t = m.apply(o) - o;
let center = xf.rect.center();
p.add(egui_wgpu::Callback::new_paint_callback(
xf.rect,
crate::gpu::CanvasCallback {
key,
slot: c.drag_slot.clone(),
offset: [(o.x - xf.center.x) as f32, (o.y - xf.center.y) as f32],
scale: xf.scale as f32,
center: [center.x, center.y],
layer: crate::gpu::LAYER_DRAG,
xform: [m.a as f32, m.b as f32, m.c as f32, m.d as f32, t.x as f32, t.y as f32],
dash: 0.0,
},
));
}
/// Draw the display list through the GPU paint callback (uploading it when it changed), plus
/// the infinite lines, which are clipped to the view on the CPU every frame.
fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) {
@@ -491,7 +330,7 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(c.key, c.list_gen, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h);
(c.key, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h);
h.finish()
};
if c.mesh_key != Some(key) {
@@ -514,9 +353,6 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis
offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32],
scale: xf.scale as f32,
center: [center.x, center.y],
layer: crate::gpu::LAYER_DRAWING,
xform: crate::gpu::IDENTITY_XFORM,
dash: 0.0,
},
));
let mut shapes = Vec::new();
@@ -720,31 +556,6 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf, ctx: &egui::Context) ->
r.point
}
/// The vertical wheel scroll this frame that zooms, in points, read from the raw wheel events so
/// each notch zooms by its whole step at once, as in standard CAD (#660). egui's smoothed delta
/// spreads a notch over several frames, so quick notches lag behind the wheel. Units convert to
/// points as egui does (a line is `line_scroll_speed` points, a page the view's height), and
/// events egui turns into a pinch zoom (⌘/Ctrl) or a sideways scroll (⇧) are left out, as it does.
fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> f32 {
let points: f32 = events
.iter()
.filter_map(|e| match e {
egui::Event::MouseWheel { unit, delta, modifiers, .. }
if !modifiers.matches_any(opts.zoom_modifier) && !modifiers.matches_any(opts.horizontal_scroll_modifier) =>
{
let per = match unit {
egui::MouseWheelUnit::Point => 1.0,
egui::MouseWheelUnit::Line => opts.line_scroll_speed,
egui::MouseWheelUnit::Page => page,
};
Some(delta.y * per)
}
_ => None,
})
.sum();
if points.is_finite() { points } else { 0.0 }
}
pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let full = ui.available_rect_before_wrap();
@@ -791,29 +602,26 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
}
}
});
if inside && app.canvas.scroll_pans && scroll != egui::Vec2::ZERO {
// Two-finger swipe pans: the drawing follows the fingers, like a middle-drag.
// Pinch (and Cmd+scroll) still zoom via `zoom_delta` below.
if let Ok(st) = app.session.state_mut() {
let v = st.view();
st.set_view_quiet(cadcraft_engine::View {
center: v.center - Vec2::new(f64::from(scroll.x) / scale, -f64::from(scroll.y) / scale),
height: v.height,
});
if inside && scroll != egui::Vec2::ZERO {
if app.canvas.scroll_pans {
// Two-finger swipe pans: the drawing follows the fingers, like a middle-drag.
// Pinch (and Cmd+scroll) still zoom via `zoom_delta` below.
if let Ok(st) = app.session.state_mut() {
let v = st.view();
st.set_view_quiet(cadcraft_engine::View {
center: v.center - Vec2::new(f64::from(scroll.x) / scale, -f64::from(scroll.y) / scale),
height: v.height,
});
}
} else if scroll.y.abs() > 0.0
&& let Some(hp) = hover_pos
{
// Zoom with the mouse wheel about the cursor.
let f = app.session.wheel_zoom_factor(f64::from(scroll.y));
let about = xf.to_world(hp);
let _ = app.session.zoom_about(f, about);
}
}
let opts = ui.ctx().options(|o| o.input_options);
let wheel = ui.input(|i| wheel_zoom_points(&i.raw.events, &opts, rect.height()));
if inside
&& !app.canvas.scroll_pans
&& wheel != 0.0
&& let Some(hp) = hover_pos
{
// Zoom with the mouse wheel about the cursor, each notch at once.
let f = app.session.wheel_zoom_factor(f64::from(wheel));
let about = xf.to_world(hp);
let _ = app.session.zoom_about(f, about);
}
let zoom_pinch = ui.input(|i| i.zoom_delta());
if inside
&& (zoom_pinch - 1.0).abs() > 1e-4
@@ -1044,12 +852,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
// ---------- paint ----------
painter.rect_filled(rect, 0.0, t.canvas);
let t0 = crate::now_ms();
let mut views = vec![(xf_paper.world_bounds(), 1.0 / xf_paper.scale)];
views.extend(tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0).map(|(r, tile)| {
let xf = tile_xf(*r, tile);
(xf.world_bounds(), 1.0 / xf.scale)
}));
ensure_list(app, 1.0 / xf_paper.scale, &views);
ensure_list(app, 1.0 / xf_paper.scale);
let sheet = app.canvas.list.as_ref().and_then(|l| l.sheet);
let bg = match sheet {
Some(sh) => {
@@ -1101,40 +904,23 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
hs.extend(app.canvas.hover.filter(|h| !sel.contains(h)));
let ents: Vec<_> = hs.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref()).collect();
if !ents.is_empty() {
let tolerance = viewport_highlight_tolerance(scale);
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), &hs).hash(&mut h);
h.finish()
};
if app.canvas.vp_hl.as_ref().is_none_or(|(k, _)| *k != key) {
let built = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() });
app.canvas.vp_hl = Some((key, built));
}
if let Some((_, list)) = &app.canvas.vp_hl {
let clipped = painter.with_clip_rect(r.intersect(rect));
if let Some(h) = app.canvas.hover
&& !sel.contains(&h)
{
draw_highlight(&clipped, &xf, list, &prims_of(list, &[h]), t.hover, 2.0, false);
}
draw_highlight(&clipped, &xf, list, &prims_of(list, &sel), t.selection, 1.5, true);
let list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance: 0.5 / scale, ..Default::default() });
let clipped = painter.with_clip_rect(r.intersect(rect));
if let Some(h) = app.canvas.hover
&& !sel.contains(&h)
{
draw_highlight(&clipped, &xf, &list, &prims_of(&list, &[h]), t.hover, 2.0, false);
}
draw_highlight(&clipped, &xf, &list, &prims_of(&list, &sel), t.selection, 1.5, true);
}
}
} else if let CanvasState { list: Some(list), list_gen, prim_index, hover, gpu, hl_key, hl_slot, hl_origin, hl_infinite, .. } = &mut app.canvas {
} else if let CanvasState { list: Some(list), list_gen, prim_index, hover, .. } = &mut app.canvas {
if let Some(h) = *hover
&& !sel.contains(&h)
{
draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &[h]), t.hover, 2.0, false);
}
if gpu.is_some() && !sel.is_empty() {
draw_highlight_gpu(&painter, &xf, list, *list_gen, prim_index, &sel, t.selection, 1.5, (hl_key, hl_slot, hl_origin, &mut *hl_infinite));
draw_highlight(&painter, &xf, list, hl_infinite, t.selection, 1.5, true);
} else {
draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true);
}
draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true);
}
app.canvas.draw_ms = crate::now_ms() - t0;
compare::paint(app, ui.ctx(), &painter, &xf_paper, rect);
@@ -1187,23 +973,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
}
}
// Rubber band preview of the active command.
// `xf` is the space being edited: paper space, or model space inside the active viewport.
let drag = match (app.canvas.cursor, app.canvas.gpu.is_some()) {
(Some(c), true) => app.session.drag(c),
_ => None,
};
if let Some((drag, extra)) = drag {
if let Ok(st) = app.session.state() {
let serial = app.session.clipboard_source.as_ref().map_or(0, |c| c.serial);
draw_drag_gpu(&mut app.canvas, &painter, &xf, &st.doc, &drag, (&app.session.clipboard, serial), bg);
let list = cadcraft_render::build_entities(
&st.doc,
extra.iter(),
&cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() },
);
draw_list(&painter, &xf, &list, bg, false);
}
} else if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) {
if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) {
let ents = app.session.preview(c);
if !ents.is_empty()
&& let Ok(st) = app.session.state()
@@ -1286,11 +1056,6 @@ fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine::ModelTile
.collect()
}
/// The world ↔ screen transform of a model tile drawn in `r`.
fn tile_xf(r: Rect, tile: &cadcraft_engine::ModelTile) -> Xf {
Xf { rect: r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) }
}
/// The tiles other than the current one (each with its own view, clipped to it), then the
/// tile borders: thin, and heavy around the current tile.
fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::ModelTile)]) {
@@ -1298,7 +1063,7 @@ fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::Mode
let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b());
for (r, tile) in tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0) {
let p = ui.painter_at(*r);
let xf = tile_xf(*r, tile);
let xf = Xf { rect: *r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) };
p.rect_filled(*r, 0.0, t.canvas);
draw_grid(app, &p, &xf);
if let Some(list) = &app.canvas.list {
@@ -1755,15 +1520,6 @@ mod tests {
v.iter().map(|&h| Handle(h)).collect()
}
#[test]
fn viewport_highlight_tolerance_is_constant_within_a_zoom_band() {
assert_eq!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(3.0));
assert_ne!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(1.5));
for s in [0.3, 1.0, 3.0, 4.0, 250.0] {
assert!(viewport_highlight_tolerance(s) <= 0.5 / s);
}
}
#[test]
fn prim_index_matches_a_scan_and_follows_list_rebuilds() {
let a = list(&[3, 1, 3, 2, 7, 3]);
@@ -1777,49 +1533,6 @@ mod tests {
let b = list(&[3, 9]);
assert_eq!(ix.prims(2, &b, &hs(&[9, 3])), vec![0, 1]);
}
fn wheel(unit: egui::MouseWheelUnit, dy: f32, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }
}
/// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways
/// scroll) don't zoom here, and every notch of the frame counts (#660).
#[test]
fn wheel_zoom_reads_every_notch_of_the_frame() {
use egui::{Modifiers, MouseWheelUnit as U};
let o = egui::InputOptions::default();
let none = Modifiers::NONE;
assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, none)], &o, 500.0), o.line_scroll_speed);
assert_eq!(wheel_zoom_points(&[wheel(U::Line, -2.0, none)], &o, 500.0), -2.0 * o.line_scroll_speed);
assert_eq!(wheel_zoom_points(&[wheel(U::Point, 7.5, none)], &o, 500.0), 7.5);
assert_eq!(wheel_zoom_points(&[wheel(U::Page, 1.0, none)], &o, 500.0), 500.0);
// Three quick notches in one frame zoom three steps at once.
let three = [wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none)];
assert_eq!(wheel_zoom_points(&three, &o, 500.0), 3.0 * o.line_scroll_speed);
assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::COMMAND)], &o, 500.0), 0.0);
assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::SHIFT)], &o, 500.0), 0.0);
assert_eq!(wheel_zoom_points(&[wheel(U::Line, f32::NAN, none)], &o, 500.0), 0.0);
assert_eq!(wheel_zoom_points(&[egui::Event::PointerGone], &o, 500.0), 0.0);
}
/// The frame a notch arrives, egui's smoothed scroll holds only part of it (the rest trickles
/// in over later frames, which made quick notches lag), while the raw reading zooms the
/// whole notch at once (#660).
#[test]
fn a_wheel_notch_zooms_whole_in_its_own_frame() {
let ctx = egui::Context::default();
let o = ctx.options(|o| o.input_options);
let input = egui::RawInput { events: vec![wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE)], ..Default::default() };
let mut seen = (0.0, 0.0);
ctx.run_ui(input, |ui| {
seen = ui.input(|i| (i.smooth_scroll_delta.y, wheel_zoom_points(&i.raw.events, &o, 500.0)));
})
.textures_delta
.clear();
let (smoothed, raw) = seen;
assert_eq!(raw, o.line_scroll_speed, "the whole notch");
assert!(smoothed < raw, "egui's smoothed delta lags behind: {smoothed} of {raw}");
}
}
/// The COMPARE overlay.
+45 -59
View File
@@ -165,7 +165,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
}
}
});
response.on_hover_text(crate::tl!("More toolbar commands"));
response.on_hover_text("More toolbar commands");
}
if let Some(c) = clicked {
app.start(c);
@@ -192,59 +192,55 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
app.ui.start_tab = false;
}
x += 24.0;
// `label` is the drawing's name; `dirty` adds the unsaved-changes mark, kept visible when a
// long name is cut short.
let mut tab =
|ui: &mut egui::Ui, label: &str, dirty: bool, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) {
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
let resp = ui.interact(tr, id, Sense::click());
ui.painter().rect_filled(
tr,
0.0,
if active {
t.tab_active
} else if resp.hovered() {
t.chrome
} else {
t.chrome_dark
},
);
// The name ends in "…" when it doesn't fit before the close button (#513).
let color = if active { t.text } else { t.text_dim };
let mark = if dirty { "*" } else { "" };
let text_w = (w - 8.0 - if closable { 24.0 } else { 8.0 }).max(0.0);
let mark_w = ui.painter().layout_no_wrap(mark.to_string(), crate::theme::body(), color).size().x;
let (galley, cut) = elided(ui.painter(), label, crate::theme::body(), color, text_w - mark_w);
let pos = pos2(tr.left() + 8.0, tr.center().y - galley.size().y / 2.0);
let name_w = galley.size().x;
ui.painter().galley(pos, galley, color);
if dirty {
ui.painter().text(pos2(pos.x + name_w, tr.center().y), egui::Align2::LEFT_CENTER, mark, crate::theme::body(), color);
}
let resp = if cut { resp.on_hover_text(format!("{label}{mark}")) } else { resp };
let mut close = false;
if closable && (active || resp.hovered()) {
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
let cresp = ui.interact(cr, id.with("x"), Sense::click());
icons::paint(ui.painter(), cr, Icon::Close, false);
icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false);
close = cresp.clicked();
}
icons::describe_control(ui, &resp, label, Some(active), true);
ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border));
x += w;
(resp.clicked(), close, resp)
};
let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) {
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
let resp = ui.interact(tr, id, Sense::click());
ui.painter().rect_filled(
tr,
0.0,
if active {
t.tab_active
} else if resp.hovered() {
t.chrome
} else {
t.chrome_dark
},
);
ui.painter().text(
pos2(tr.left() + 8.0, tr.center().y),
egui::Align2::LEFT_CENTER,
label,
crate::theme::body(),
if active { t.text } else { t.text_dim },
);
let mut close = false;
if closable && (active || resp.hovered()) {
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
let cresp = ui.interact(cr, id.with("x"), Sense::click());
icons::paint(ui.painter(), cr, Icon::Close, false);
icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false);
close = cresp.clicked();
}
icons::describe_control(ui, &resp, label, Some(active), true);
ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border));
x += w;
(resp.clicked(), close, resp)
};
let (c, _, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
if c {
app.ui.start_tab = true;
}
let mut switch_to = None;
let mut close = None;
let titles: Vec<(usize, String, bool, bool)> =
app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect();
for (i, title, dirty, active) in titles {
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
let titles: Vec<(usize, String, bool)> = app
.session
.docs
.iter()
.enumerate()
.map(|(i, d)| (i, format!("{}{}", d.title, if d.is_dirty() { "*" } else { "" }), i == app.session.active))
.collect();
for (i, title, active) in titles {
let (c, x, resp) = tab(ui, &title, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i));
if c {
switch_to = Some(i);
@@ -263,16 +259,6 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
});
}
/// `text` laid out on one line no wider than `max_width`, ending in "…" when it had to be cut;
/// and whether it was.
fn elided(p: &egui::Painter, text: &str, font: egui::FontId, color: egui::Color32, max_width: f32) -> (std::sync::Arc<egui::Galley>, bool) {
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);
job.wrap = egui::text::TextWrapping { max_width: max_width.max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
let galley = p.layout_job(job);
let cut = galley.elided;
(galley, cut)
}
/// Status bar: layout tabs on the left, coordinates and drafting toggles on the right.
pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
+15 -117
View File
@@ -1,8 +1,6 @@
//! The floating command line: history, the active prompt with clickable keywords, typed input
//! (keyboard focus stays on the drawing — typing anywhere goes here) and AutoComplete.
use std::collections::HashSet;
use cadcraft_engine::Input;
use egui::{Key, Pos2, Rect, Sense, Stroke, pos2, vec2};
@@ -92,76 +90,25 @@ fn tab(app: &mut CadApp) {
{
// Both values typed: Tab locks the second one as well.
app.cmd.locked_second = Some(app.cmd.buffer.clone());
} else if let Some(s) = suggestions(&app.cmd.buffer).first() {
app.cmd.buffer = s.id.clone();
} else if let Some((id, _)) = suggestions(&app.cmd.buffer).first() {
app.cmd.buffer = id.clone();
}
}
/// An AutoComplete suggestion for the command line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Suggestion {
/// Command identifier (uppercase, e.g. "RECTANG"), used for buffer completion and execution.
pub id: String,
/// Display text shown in the AutoComplete list (e.g. "REC (RECTANG)" or "RECOVER").
pub display: String,
/// Human-readable command label (e.g. "Rectangle").
pub label: &'static str,
}
/// Matching command names/aliases for AutoComplete.
fn suggestions(prefix: &str) -> Vec<Suggestion> {
fn suggestions(prefix: &str) -> Vec<(String, &'static str)> {
if prefix.is_empty() || prefix.contains(' ') {
return Vec::new();
}
let p = prefix.to_ascii_lowercase();
let mut out: Vec<Suggestion> = Vec::new();
let mut seen_ids: HashSet<&'static str> = HashSet::new();
// 1. Exact match: alias resolution or exact command name match.
let resolved = cadcraft_engine::cmd::resolve_alias(&p);
if let Some(spec) = cadcraft_engine::cmd::find_command(&resolved)
&& !spec.id.contains('.')
{
let display =
if spec.id == p { spec.id.to_ascii_uppercase() } else { format!("{} ({})", p.to_ascii_uppercase(), spec.id.to_ascii_uppercase()) };
out.push(Suggestion { id: spec.id.to_ascii_uppercase(), display, label: spec.label });
seen_ids.insert(spec.id);
}
// 2. Command ID prefix matches (excluding already matched exact command).
let mut id_matches: Vec<&'static cadcraft_engine::CommandSpec> =
cadcraft_engine::command_specs().iter().filter(|c| !c.id.contains('.') && !seen_ids.contains(c.id) && c.id.starts_with(&p)).collect();
id_matches.sort_by_key(|c| (c.id.len(), c.id));
for c in id_matches {
out.push(Suggestion { id: c.id.to_ascii_uppercase(), display: c.id.to_ascii_uppercase(), label: c.label });
seen_ids.insert(c.id);
}
// 3. Command alias prefix matches (for commands not yet added).
let mut alias_matches: Vec<(&'static str, &'static cadcraft_engine::CommandSpec)> = Vec::new();
for c in cadcraft_engine::command_specs() {
if c.id.contains('.') || seen_ids.contains(c.id) {
continue;
}
let matching_alias = c.aliases.iter().copied().filter(|a| a.starts_with(&p)).min_by_key(|a| (a.len(), *a));
if let Some(alias) = matching_alias {
alias_matches.push((alias, c));
}
}
alias_matches.sort_by_key(|(alias, c)| (alias.len(), *alias, c.id));
for (alias, c) in alias_matches {
if seen_ids.insert(c.id) {
out.push(Suggestion {
id: c.id.to_ascii_uppercase(),
display: format!("{} ({})", alias.to_ascii_uppercase(), c.id.to_ascii_uppercase()),
label: c.label,
});
}
}
out.truncate(8);
out
let mut v: Vec<(String, &'static str)> = cadcraft_engine::command_specs()
.iter()
.filter(|c| !c.id.contains('.') && (c.id.starts_with(&p) || c.aliases.iter().any(|a| *a == p)))
.map(|c| (c.id.to_ascii_uppercase(), c.label))
.collect();
v.sort_by_key(|(id, _)| (id.len(), id.clone()));
v.truncate(8);
v
}
impl CmdLine {
@@ -515,23 +462,23 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
);
p.rect_filled(lr, 3.0, t.list_bg);
p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
for (i, s) in sug.iter().enumerate() {
for (i, (id, label)) in sug.iter().enumerate() {
let r = Rect::from_min_size(pos2(lr.left(), lr.top() + rh * i as f32), vec2(lr.width(), rh));
let resp = ui.interact(r, ui.id().with(("sug", i)), Sense::click());
if resp.hovered() || i == 0 {
p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row });
}
p.text(Pos2::new(r.left() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, &s.display, crate::theme::body(), t.text);
p.text(Pos2::new(r.left() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, id, crate::theme::body(), t.text);
p.text(
Pos2::new(r.right() - 8.0, r.center().y),
egui::Align2::RIGHT_CENTER,
crate::i18n::t(s.label),
crate::i18n::t(label),
crate::theme::small(),
t.text_faint,
);
if resp.clicked() {
app.cmd.buffer.clear();
app.start(&s.id.to_ascii_lowercase());
app.start(&id.to_ascii_lowercase());
}
}
}
@@ -768,52 +715,3 @@ mod delete_key_tests {
assert!(!c.typed_since_selection);
}
}
#[cfg(test)]
mod autocomplete_tests {
use super::*;
#[test]
fn exact_alias_is_first_suggestion_with_alias_display() {
let rec = suggestions("rec");
assert!(!rec.is_empty());
assert_eq!(rec[0].id, "RECTANG");
assert_eq!(rec[0].display, "REC (RECTANG)");
assert_eq!(rec[0].label, "Rectangle");
// RECOVER should follow RECTANG, not precede it
assert!(rec.iter().any(|s| s.id == "RECOVER"));
assert_eq!(rec[1].id, "RECOVER");
assert_eq!(rec[1].display, "RECOVER");
let l = suggestions("l");
assert_eq!(l[0].id, "LINE");
assert_eq!(l[0].display, "L (LINE)");
let c = suggestions("c");
assert_eq!(c[0].id, "CIRCLE");
assert_eq!(c[0].display, "C (CIRCLE)");
let co = suggestions("co");
assert_eq!(co[0].id, "COPY");
assert_eq!(co[0].display, "CO (COPY)");
}
#[test]
fn prefix_without_alias_shows_command_id() {
let rect = suggestions("rect");
assert_eq!(rect[0].id, "RECTANG");
assert_eq!(rect[0].display, "RECTANG");
let line = suggestions("line");
assert_eq!(line[0].id, "LINE");
assert_eq!(line[0].display, "LINE");
}
#[test]
fn tab_autocompletes_alias_to_resolved_command() {
let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default());
app.cmd.buffer = "rec".to_string();
tab(&mut app);
assert_eq!(app.cmd.buffer, "RECTANG");
}
}
+4 -7
View File
@@ -6,8 +6,7 @@
//! Pan/Zoom, Quick Select, Find, Properties and Options.
//! - **While a command runs** ([`command_entries`]): a quick right-click is Enter; holding the
//! button for [`HOLD_SECS`] opens Enter, Cancel, Recent Input, the prompt's options, Snap
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). With that turned off
//! in OPTIONS (`timeSensitiveRightClick`), every right-click opens the menu.
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click).
//! - **Shift/Ctrl+right-click** ([`snap_entries`]): the object snap menu. Its items type the point
//! modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the current point prompt.
//!
@@ -303,8 +302,7 @@ struct Press(f64, egui::Pos2);
/// The canvas's right button while a command runs (Shift/Ctrl+right-click is the snap menu's): a
/// quick click is Enter — returns `true` when the caller should send it — and a click held for
/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer (any click does with
/// the time-sensitive right-click turned off). Call it every frame
/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer. Call it every frame
/// with whether the pointer is on the canvas and whether a right-click ended there; it also shows
/// the menu while it is open.
pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool {
@@ -325,10 +323,9 @@ pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked:
if released && let Some(Press(t0, p0)) = ctx.data_mut(|d| d.remove_temp::<Press>(press_id)) {
// A drag isn't a click; a click held long enough asks for the menu.
let still = pos.is_some_and(|p| p.distance(p0) <= 6.0);
let held = now - t0 >= HOLD_SECS || !app.options.time_sensitive_right_click;
open = running && still && held;
open = running && still && now - t0 >= HOLD_SECS;
}
let enter = running && inside && right_clicked && !open && app.options.time_sensitive_right_click;
let enter = running && inside && right_clicked && !open;
if !running {
return false;
}
+1 -7
View File
@@ -14,7 +14,6 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
let Some(d) = app.ui.dialog.clone() else { return };
let mut open = true;
match d.as_str() {
"spacemouse" => crate::spacemouse::dialog(app, ctx, &mut open),
"language" => language(app, ctx, &mut open),
"layers" => crate::layers::dialog(app, ctx, &mut open),
"qselect" => crate::quick::qselect_dialog(app, ctx, &mut open),
@@ -29,8 +28,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
o if o == "options" || o.starts_with("options:") => crate::options::dialog(app, ctx, o, &mut open),
other => crate::managers::dialog(app, ctx, other, &mut open),
}
// A dialog that closed by opening another (Plot Preview back to Plot) keeps the new one.
if !open && app.ui.dialog.as_deref() == Some(d.as_str()) {
if !open {
app.ui.dialog = None;
}
}
@@ -321,10 +319,6 @@ fn about(ctx: &egui::Context, open: &mut bool) {
ui.label(crate::tf!("Version {version}", version = env!("CARGO_PKG_VERSION")));
ui.label(crate::tl!("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust."));
ui.add_space(6.0);
ui.label(RichText::new(crate::tl!("One of the Crafting Apps by the ArtCraft team and community.")).color(t.text_dim));
ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft");
ui.hyperlink_to(crate::tl!("Join us on Discord"), "https://discord.gg/artcraft");
ui.add_space(6.0);
ui.label(RichText::new(crate::tl!("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.")).small().color(t.text_faint));
}
}
+3 -40
View File
@@ -59,34 +59,8 @@ struct Preview {
shown: Result<(TextureHandle, String), String>,
}
/// A preview the Plot dialog asked for: its PLOT parameters (PNG output) and a caption.
#[derive(Clone)]
struct FromPlot {
params: Value,
info: String,
}
const FROM_PLOT: &str = "cc_plot_preview_from_plot";
/// Show the Plot dialog's settings as they plot (PLOT with `params`, a PNG); Print... and Close
/// return to the dialog.
pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, info: String) {
PREVIEW_GEN.fetch_add(1, Ordering::Relaxed);
ctx.data_mut(|d| d.insert_temp(egui::Id::new(FROM_PLOT), FromPlot { params, info }));
app.ui.dialog = Some("preview".into());
}
/// Render the plot preview of the current layout (or model), or of the Plot dialog's settings,
/// as a texture.
/// Render the plot preview of the current layout (or model) as a texture.
fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> {
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))) {
let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?;
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
let size = [img.width() as usize, img.height() as usize];
let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR);
return Ok((tex, f.info));
}
let r = app.session.execute("preview", &json!({ "width": 1600 })).map_err(|e| e.to_string())?;
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
@@ -137,18 +111,10 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if close || print {
*open = false;
}
let from_plot = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))).is_some();
if !*open {
ctx.data_mut(|d| {
d.remove::<Preview>(id);
d.remove::<FromPlot>(egui::Id::new(FROM_PLOT));
});
ctx.data_mut(|d| d.remove::<Preview>(id));
}
if from_plot && !*open {
// Back to the Plot dialog with its settings; Print... plots them.
crate::managers::set_flag(ctx, crate::managers::PLOT_NOW, print);
app.ui.dialog = Some("plot".into());
} else if print {
if print {
app.start("plot");
}
}
@@ -191,9 +157,6 @@ mod tests {
app.cmdline("pre");
assert_eq!(app.ui.dialog.as_deref(), Some("preview"));
app.ui.dialog = None;
app.start("preview");
assert_eq!(app.ui.dialog.as_deref(), Some("preview"));
app.ui.dialog = None;
let _ = app.run("export", json!({ "format": "svg" }));
let _ = app.run("import", json!({}));
+41 -206
View File
@@ -25,9 +25,8 @@ use egui_wgpu::wgpu::util::DeviceExt;
/// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8).
const VERTEX_SIZE: usize = 12;
/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32) +
/// length along its polyline before `a`, in world units (f32; dashing).
const INSTANCE_SIZE: usize = 28;
/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32).
const INSTANCE_SIZE: usize = 24;
/// Vertices (or instances) per GPU buffer; divisible by 2 and 3 so no primitive straddles two.
const CHUNK: usize = 4_194_300;
/// Point markers are 1.5 × 1.5 points (as on the CPU path).
@@ -128,13 +127,12 @@ fn push_vertex(out: &mut Vec<u8>, p: [f32; 2], c: [u8; 4]) {
out.extend_from_slice(&c);
}
fn push_instance(out: &mut Vec<u8>, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32, start: f32) {
fn push_instance(out: &mut Vec<u8>, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32) {
for v in [a[0], a[1], b[0], b[1]] {
out.extend_from_slice(&v.to_le_bytes());
}
out.extend_from_slice(&c);
out.extend_from_slice(&width.to_le_bytes());
out.extend_from_slice(&start.to_le_bytes());
}
/// Lineweight in screen points for a primitive (as on the CPU path), or `None` for a hairline.
@@ -163,7 +161,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
Some(w) => {
for s in pts.windows(2) {
if let [a, b] = s {
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w, 0.0);
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w);
}
}
}
@@ -194,7 +192,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
Kind::Point => {
if let Some(q) = pts.first() {
let p = rel(*q, origin);
push_instance(&mut m.quads, p, p, col, POINT_SIZE, 0.0);
push_instance(&mut m.quads, p, p, col, POINT_SIZE);
}
}
// Drawn on the CPU each frame (clipped to the view).
@@ -204,48 +202,6 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
m
}
/// Vertex data drawing primitives `prims` of `list` as `width`-point lines in `color` (the
/// selection highlight; dashed by the callback's `dash`). Fills are outlined by their bounding
/// box, as on the CPU path; infinite lines are left to the CPU (they are clipped per frame).
pub fn build_highlight_mesh(list: &DisplayList, prims: &[usize], origin: Vec2, color: [u8; 4], width: f32) -> MeshData {
let mut m = MeshData::default();
for prim in prims.iter().filter_map(|&i| list.prims.get(i)) {
let pts = list.points(prim);
match prim.kind {
Kind::Polyline => {
let mut run = 0.0;
for s in pts.windows(2) {
if let [a, b] = s {
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, width, run as f32);
run += a.dist(*b);
}
}
}
Kind::Point => {
if let Some(q) = pts.first() {
let p = rel(*q, origin);
push_instance(&mut m.quads, p, p, color, 3.0, 0.0);
}
}
Kind::Tris | Kind::Mask => {
let bb = Bounds2::from_points(pts.iter().copied());
if !bb.is_empty() {
let c = [bb.min, Vec2::new(bb.max.x, bb.min.y), bb.max, Vec2::new(bb.min.x, bb.max.y), bb.min];
let mut run = 0.0;
for s in c.windows(2) {
if let [a, b] = s {
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, 1.0, run as f32);
run += a.dist(*b);
}
}
}
}
Kind::Infinite { .. } => {}
}
}
m
}
/// The vertex origin for a display list seen at `view_center` / `view_height`: the list's centre
/// normally; at deep zoom (the view much smaller than the drawing) a point near the view, snapped
/// to a coarse grid so panning re-uploads only every few dozen screens.
@@ -276,32 +232,20 @@ pub struct CanvasCallback {
pub scale: f32,
/// Canvas rect centre in points.
pub center: [f32; 2],
/// Which buffers to draw: [`LAYER_DRAWING`] or [`LAYER_DRAG`].
pub layer: usize,
/// Affine transform applied to the origin-relative positions before `offset`: the linear
/// part (a, b, c, d as in `Mat3`) and the translation. [`IDENTITY_XFORM`] for the drawing.
pub xform: [f32; 6],
/// Dash period in points for wide lines (0 = solid): the selection highlight.
pub dash: f32,
}
/// The drawing's display list.
pub const LAYER_DRAWING: usize = 0;
/// The objects a MOVE/COPY/ROTATE/SCALE preview drags, uploaded once per drag.
pub const LAYER_DRAG: usize = 1;
/// The selection highlight, rebuilt only when the selection or the drawing changes.
pub const LAYER_HIGHLIGHT: usize = 2;
pub const IDENTITY_XFORM: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
struct Batch {
buffer: wgpu::Buffer,
count: u32,
}
/// One uploaded mesh with its own view uniform (each callback's transform differs).
struct Layer {
struct Resources {
line_pipeline: wgpu::RenderPipeline,
tri_pipeline: wgpu::RenderPipeline,
quad_pipeline: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind_group: wgpu::BindGroup,
linear_out: bool,
key: Option<u64>,
lines: Vec<Batch>,
tris: Vec<Batch>,
@@ -309,36 +253,23 @@ struct Layer {
breaks: Vec<[usize; 3]>,
}
struct Resources {
line_pipeline: wgpu::RenderPipeline,
tri_pipeline: wgpu::RenderPipeline,
quad_pipeline: wgpu::RenderPipeline,
linear_out: bool,
layers: Vec<Layer>,
}
const UNIFORM_SIZE: u64 = 80;
const UNIFORM_SIZE: u64 = 48;
const SHADER: &str = r#"
struct View {
a: vec4<f32>, // offset x, offset y (world), scale (device px per unit), 1 = linear output
b: vec4<f32>, // canvas centre x, y (device px), screen w, h (device px)
c: vec4<f32>, // pixels per point, dash period in points (0 = solid)
d: vec4<f32>, // transform linear part: a, b, c, d (x' = a x + c y, y' = b x + d y)
e: vec4<f32>, // transform translation x, y (world, origin-relative)
c: vec4<f32>, // pixels per point
};
@group(0) @binding(0) var<uniform> view: View;
struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec4<f32>,
// Device pixels along a wide line from its start (dashing).
@location(1) along: f32,
};
fn to_px(p: vec2<f32>) -> vec2<f32> {
let q = vec2<f32>(view.d.x * p.x + view.d.z * p.y, view.d.y * p.x + view.d.w * p.y) + view.e.xy;
let w = (q + view.a.xy) * view.a.z;
let w = (p + view.a.xy) * view.a.z;
return vec2<f32>(view.b.x + w.x, view.b.y - w.y);
}
@@ -360,7 +291,6 @@ fn vs_line(@location(0) pos: vec2<f32>, @location(1) color: vec4<f32>) -> VOut {
var o: VOut;
o.pos = px_to_clip(to_px(pos));
o.color = out_color(color);
o.along = 0.0;
return o;
}
@@ -371,7 +301,6 @@ fn vs_quad(
@location(1) b: vec2<f32>,
@location(2) color: vec4<f32>,
@location(3) width: f32,
@location(4) start: f32,
) -> VOut {
let pa = to_px(a);
let pb = to_px(b);
@@ -390,18 +319,11 @@ fn vs_quad(
var o: VOut;
o.pos = px_to_clip(along + n * hw * s);
o.color = out_color(color);
o.along = start * view.a.z + mix(-hw, len + hw, t);
return o;
}
@fragment
fn fs_main(v: VOut) -> @location(0) vec4<f32> {
if (view.c.y > 0.0) {
// 6 on, 4 off (as the CPU selection highlight's dashes).
if (fract(v.along / (view.c.y * view.c.x)) > 0.6) {
discard;
}
}
return v.color;
}
"#;
@@ -412,7 +334,7 @@ fn vertex_layout() -> wgpu::VertexBufferLayout<'static> {
}
fn instance_layout() -> wgpu::VertexBufferLayout<'static> {
const ATTRS: [wgpu::VertexAttribute; 5] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32, 4 => Float32];
const ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32];
wgpu::VertexBufferLayout { array_stride: INSTANCE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &ATTRS }
}
@@ -422,7 +344,7 @@ impl Resources {
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("cad_canvas"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
};
@@ -432,20 +354,17 @@ impl Resources {
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let layer = || {
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("cad_canvas_view"),
size: UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("cad_canvas"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
});
Layer { uniform, bind_group, key: None, lines: Vec::new(), tris: Vec::new(), quads: Vec::new(), breaks: Vec::new() }
};
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("cad_canvas_view"),
size: UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("cad_canvas"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
});
let pipeline = |label: &str, vs: &str, topology: wgpu::PrimitiveTopology, buffer: wgpu::VertexBufferLayout<'static>| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -477,8 +396,14 @@ impl Resources {
line_pipeline: pipeline("cad_lines", "vs_line", wgpu::PrimitiveTopology::LineList, vertex_layout()),
tri_pipeline: pipeline("cad_fills", "vs_line", wgpu::PrimitiveTopology::TriangleList, vertex_layout()),
quad_pipeline: pipeline("cad_quads", "vs_quad", wgpu::PrimitiveTopology::TriangleList, instance_layout()),
uniform,
bind_group,
linear_out: target.format.is_srgb(),
layers: vec![layer(), layer(), layer()],
key: None,
lines: Vec::new(),
tris: Vec::new(),
quads: Vec::new(),
breaks: Vec::new(),
}
}
}
@@ -494,79 +419,9 @@ fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage
.collect()
}
/// GPU trouble wgpu reported: uncaptured errors (validation, out of memory) and the device being
/// lost (a driver reset or a hung GPU). wgpu's default handler panics on the first uncaptured
/// error, closing the app with any unsaved work (#602); the handlers [`install`] registers record
/// them here instead, and the app autosaves and tells the user (see `CadApp::gpu_fault_tick`).
#[derive(Debug, Default)]
pub struct GpuFaults {
state: Mutex<FaultState>,
}
#[derive(Debug, Default)]
struct FaultState {
errors: u64,
errors_seen: u64,
lost: bool,
lost_seen: bool,
last: String,
}
/// What [`GpuFaults::take`] reports.
#[derive(Clone, Debug, PartialEq)]
pub struct GpuFault {
/// Uncaptured errors so far (an invalid resource can fail again every frame).
pub errors: u64,
/// The device was lost: nothing more can be drawn until the app restarts.
pub lost: bool,
/// The latest error or device-lost message.
pub message: String,
}
impl GpuFaults {
/// Errors logged in full before only every 1000th is (a repeating error would flood the log).
const LOGGED: u64 = 10;
pub fn error(&self, message: String) {
let Ok(mut s) = self.state.lock() else { return };
s.errors += 1;
if s.errors <= Self::LOGGED || s.errors % 1000 == 0 {
log::error!("wgpu error #{}: {message}", s.errors);
}
s.last = message;
}
pub fn device_lost(&self, reason: wgpu::DeviceLostReason, message: String) {
// `Destroyed` is the device being dropped on purpose (the app closing), not a failure.
if reason == wgpu::DeviceLostReason::Destroyed {
return;
}
log::error!("wgpu device lost ({reason:?}): {message}");
let Ok(mut s) = self.state.lock() else { return };
s.lost = true;
s.last = message;
}
/// Faults reported since the last call, if any.
pub fn take(&self) -> Option<GpuFault> {
let Ok(mut s) = self.state.lock() else { return None };
if s.errors == s.errors_seen && s.lost == s.lost_seen {
return None;
}
s.errors_seen = s.errors;
s.lost_seen = s.lost;
Some(GpuFault { errors: s.errors, lost: s.lost, message: s.last.clone() })
}
}
/// Create the canvas pipelines in the renderer's callback resources, and report wgpu errors and
/// device loss to `faults` instead of panicking. Call once with the app's render state (eframe
/// `CreationContext::wgpu_render_state`).
pub fn install(rs: &egui_wgpu::RenderState, faults: &Arc<GpuFaults>) -> GpuTarget {
let f = Arc::clone(faults);
rs.device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| f.error(e.to_string())));
let f = Arc::clone(faults);
rs.device.set_device_lost_callback(move |reason, message| f.device_lost(reason, message));
/// Create the canvas pipelines in the renderer's callback resources. Call once with the app's
/// render state (eframe `CreationContext::wgpu_render_state`).
pub fn install(rs: &egui_wgpu::RenderState) -> GpuTarget {
let target = GpuTarget { format: rs.target_format };
let res = Resources::new(&rs.device, target);
rs.renderer.write().callback_resources.insert(res);
@@ -584,8 +439,6 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
let linear_out = res.linear_out;
let Some(res) = res.layers.get_mut(self.layer) else { return Vec::new() };
if res.key != Some(self.key) {
let pending = self.slot.lock().ok().and_then(|mut s| s.take());
if let Some(m) = pending {
@@ -599,20 +452,16 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
}
let ppp = screen.pixels_per_point;
let [w, h] = screen.size_in_pixels;
let a = [self.offset[0], self.offset[1], self.scale * ppp, if linear_out { 1.0 } else { 0.0 }];
let a = [self.offset[0], self.offset[1], self.scale * ppp, if res.linear_out { 1.0 } else { 0.0 }];
let b = [self.center[0] * ppp, self.center[1] * ppp, w.max(1) as f32, h.max(1) as f32];
let c = [ppp, self.dash, 0.0, 0.0];
let [ma, mb, mc, md, tx, ty] = self.xform;
let d = [ma, mb, mc, md];
let e = [tx, ty, 0.0, 0.0];
let bytes: Vec<u8> = a.iter().chain(&b).chain(&c).chain(&d).chain(&e).flat_map(|v| v.to_le_bytes()).collect();
let c = [ppp, 0.0, 0.0, 0.0];
let bytes: Vec<u8> = a.iter().chain(&b).chain(&c).flat_map(|v| v.to_le_bytes()).collect();
queue.write_buffer(&res.uniform, 0, &bytes);
Vec::new()
}
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) {
let Some(shared) = resources.get::<Resources>() else { return };
let Some(res) = shared.layers.get(self.layer) else { return };
let Some(res) = resources.get::<Resources>() else { return };
if res.key != Some(self.key) {
return;
}
@@ -629,7 +478,7 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
let mut from = [0usize; 3];
for to in res.breaks.iter().copied().chain(std::iter::once(all)) {
for (i, (pipeline, batches)) in
[(&shared.tri_pipeline, &res.tris), (&shared.line_pipeline, &res.lines), (&shared.quad_pipeline, &res.quads)].into_iter().enumerate()
[(&res.tri_pipeline, &res.tris), (&res.line_pipeline, &res.lines), (&res.quad_pipeline, &res.quads)].into_iter().enumerate()
{
let parts = batch_ranges(batches.iter().map(|b| b.count), from[i]..to[i]);
if parts.is_empty() {
@@ -715,20 +564,6 @@ mod tests {
l
}
#[test]
fn highlight_dashes_run_on_along_each_polyline() {
let l = list();
// The 3-point polyline (two unit segments) and the point marker.
let m = build_highlight_mesh(&l, &[0, 2], Vec2::new(1e6, 1e6), [1, 2, 3, 255], 1.5);
assert_eq!(m.quad_instances(), 3);
let start = |i: usize| {
let o = i * INSTANCE_SIZE + 24;
f32::from_le_bytes([m.quads[o], m.quads[o + 1], m.quads[o + 2], m.quads[o + 3]])
};
assert_eq!([start(0), start(1), start(2)], [0.0, 1.0, 0.0]);
assert!(m.lines.is_empty() && m.tris.is_empty());
}
#[test]
fn mesh_counts_and_precision() {
let l = list();
File diff suppressed because it is too large Load Diff
+4 -25
View File
@@ -86,6 +86,10 @@
CADCraft Help CADCraft-Hilfe
Cancel Abbrechen
Cartesian (x, y) Kartesisch (x, y)
Catppuccin Latte Catppuccin Latte
Catppuccin Frappé Catppuccin Frappé
Catppuccin Macchiato Catppuccin Macchiato
Catppuccin Mocha Catppuccin Mocha
Center Zentrum
Center Line Mittellinie
Center Mark Zentrumsmarke
@@ -108,7 +112,6 @@
Close All Alle schließen
Closed Geschlossen
Coincident Koinzident
Collapse Palettes Paletten einklappen
Collapse Tool Sets Werkzeuggruppen einklappen
Collinear Kollinear
Color Farbe
@@ -357,8 +360,6 @@
Isolate Layer Layer isolieren
Isometric Drafting Isometrisches Zeichnen
Join Verbinden
Join the ArtCraft Discord… Dem ArtCraft-Discord beitreten…
Join us on Discord Treten Sie uns auf Discord bei
Language Sprache
Last Letzter
Last commit Letzter Commit
@@ -486,7 +487,6 @@
Offset Versatz
On Ein
On/Off Ein/Aus
One of the Crafting Apps by the ArtCraft team and community. Eine der Crafting Apps vom ArtCraft-Team und seiner Community.
Open (⌘O) Öffnen (⌘O)
Open Sample Drawing Beispielzeichnung öffnen
Open sample drawing Beispielzeichnung öffnen
@@ -524,7 +524,6 @@
Plot Plotten
Plot (click: do not plot) Plotten (Klick: nicht plotten)
Plot Preview Plotvorschau
Plot Window Plotfenster
Plot style Plotstil
Plot style attached to Plotstil zugeordnet zu
Plot style table Plotstiltabelle
@@ -995,23 +994,3 @@
Hide Others Andere ausblenden
Accessibility Bedienungshilfen
Show System Cursor Systemzeiger anzeigen
3D Mouse... 3D-Maus...
3D Mouse 3D-Maus
Calibrate 3D Mouse 3D-Maus kalibrieren
3D Mouse Navigation Navigation mit der 3D-Maus
3D mouse input is unavailable on this platform 3D-Mauseingabe ist auf dieser Plattform nicht verfügbar
No supported 3D mouse found Keine unterstützte 3D-Maus gefunden
Release the 3D mouse to calibrate its resting position 3D-Maus loslassen, um ihre Ruheposition zu kalibrieren
3D mouse connected 3D-Maus verbunden
3D mouse busy, possibly 3DxWare or missing device permissions 3D-Maus belegt, möglicherweise durch 3DxWare oder fehlende Geräteberechtigungen
3D mouse disconnected 3D-Maus getrennt
3D mouse input could not be started 3D-Mauseingabe konnte nicht gestartet werden
Enable 3D mouse navigation Navigation mit der 3D-Maus aktivieren
Pan sensitivity Empfindlichkeit beim Verschieben
Zoom sensitivity Zoom-Empfindlichkeit
Reverse zoom direction Zoomrichtung umkehren
Calibrate Kalibrieren
Move sideways to pan; push/pull or twist to zoom. Seitlich bewegen zum Verschieben; drücken, ziehen oder drehen zum Zoomen.
Left device button: zoom extents. Right device button: calibrate. Linke Gerätetaste: auf Grenzen zoomen. Rechte Gerätetaste: kalibrieren.
Hatch Pattern Schraffurmuster
Search for pattern Muster suchen
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
86
87
88
89
90
91
92
93
94
95
112
113
114
115
116
117
360
361
362
363
364
365
487
488
489
490
491
492
524
525
526
527
528
529
994
995
996
+22 -262
View File
@@ -27,34 +27,22 @@
<> Not Equal <> Distinto de
= Equals = Igual a
> Greater than > Mayor que
A drawing opened from a file keeps that file's format. Un dibujo abierto desde un archivo conserva el formato de ese archivo.
A variable name starts with a letter and has only letters, digits and _ El nombre de una variable empieza por una letra y solo lleva letras, dígitos y _
About Acerca de
About CADCraft Acerca de CADCraft
Absolute Absolutas
Add Current Scale Añadir escala actual
Add Leader Añadir directriz
Add Vertex Añadir vértice
Add/Delete Scales... Añadir/eliminar escalas...
Additional angles Ángulos adicionales
Align Alinear
Align Text Alinear texto
Aligned Alineada
All Todas
All Annotation Objects Todos los objetos de anotación
All Scales Todas las escalas
All Used Layers Todas las capas usadas
All files Todos los archivos
An array that would create more objects than this is refused. Se rechaza una matriz que crearía más objetos que este límite.
Angle Ángulo
Angular Angular
Annotation Visibility Visibilidad de anotaciones
Annotation scale Escala de anotación
Annotative Object Scale Escala de objeto anotativo
Annotative Update Actualizar anotativos
Apparent Intersection Intersección aparente
Append to current selection set Añadir al conjunto de selección actual
Apply Aplicar
Apply to: Aplicar a:
Apply true color Aplicar color verdadero
Arc Arco
@@ -63,7 +51,6 @@
Arrange Panel Organizar panel
Array Matriz
Attach Adjuntar
Attach DWG... Adjuntar DWG...
Attribute Atributo
Attribute Definition Definición de atributo
Attribute Display Visualización de atributos
@@ -71,18 +58,12 @@
Auto (system language) Automático (idioma del sistema)
Auto Constrain Restricciones automáticas
AutoConstrain Restricciones automáticas
Automatic save every Guardado automático cada
Automatic save file location Ubicación de los archivos de guardado automático
Axis, End Eje, extremo
B N
Base Base
Baseline Línea base
Batch Publish Publicar en lote
Batch Publish... Publicar en lote...
Binary assets added Recursos binarios añadidos
Binary assets removed Recursos binarios eliminados
Bind Unir
Bind... Unir...
Blend Fusión de curvas
Block Bloque
Block Attribute Manager... Administrador de atributos de bloque...
@@ -98,18 +79,17 @@
Box Prisma rectangular
Break Partir
Break At Point Partir en un punto
Bring Above Objects Traer por encima de objetos
Bring Annotations to Front Traer las anotaciones al frente
Bring to Front Traer al frente
Bulleted list Lista con viñetas
ByBlock PorBloque
ByColor PorColor
ByLayer PorCapa
CADCraft CADCraft
CADCraft Help Ayuda de CADCraft
CADCraft keeps these preferences, object snap modes and drafting toggles for your user account and restores them when it starts. CADCraft guarda estas preferencias, los modos de referencia a objetos y los ajustes de dibujo para tu cuenta de usuario y los restaura al iniciarse.
Cancel Cancelar
Cartesian (x, y) Cartesianas (x, y)
Catppuccin Latte Catppuccin Latte
Catppuccin Frappé Catppuccin Frappé
Catppuccin Macchiato Catppuccin Macchiato
Catppuccin Mocha Catppuccin Mocha
Center Centro
Center Line Línea de centro
Center Mark Marca de centro
@@ -124,41 +104,28 @@
Chamfer Chaflán
Change Space Cambiar espacio
Change to Current Layer Cambiar a la capa actual
Changed: {n} Modificados: {n}
Changes apply at once and are kept for next time. Los cambios se aplican al instante y se conservan para la próxima vez.
Circle Círculo
Circumference Circunferencia
Clear Limpiar
Clear Command History Borrar el historial de comandos
Clear History Borrar historial
Clip Delimitar
Clip Viewport Delimitar ventana gráfica
Clipboard Portapapeles
Close Cerrar
Close All Cerrar todo
Close All Other Drawings Cerrar todos los demás dibujos
Close Array Editing Cerrar la edición de matriz
Close Comparison Cerrar comparación
Closed Cerrada
Coincident Coincidente
Collapse Palettes Contraer paletas
Collapse Tool Sets Contraer conjuntos de herramientas
Collapse Tool Sets Contraer herramientas de dibujo
Collinear Colineal
Color Color
Color 8 Color 8
Color 9 Color 9
Color Index Índice de color
Color... Color...
Command Aliases Alias de comandos
Command History Historial de comandos
Command Line Línea de comandos
Command Line History Lines Líneas del historial de comandos
Command Reference Referencia de comandos
Command line history Historial de la línea de comandos
Commits Commits
Company Empresa
Compare Drawings... Comparar dibujos...
Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Diseño y dibujo asistidos por computadora: una aplicación CAD de código abierto, desarrollada de forma independiente y escrita íntegramente en Rust.
Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Diseño y dibujo asistidos por computadora: una aplicación CAD de código abierto, desarrollada de forma independiente en Rust.
Concentric Concéntrica
Cone Cono
Constraint Bars Barras de restricciones
@@ -168,25 +135,15 @@
Continue Continuar
Contributors Colaboradores
Control Vertices Vértices de control
Convert Convertir
Convert to Arc Convertir en arco
Convert to Line Convertir en línea
Copy Copiar
Copy Full File Path Copiar la ruta completa del archivo
Copy History Copiar historial
Copy Layout Copiar presentación
Copy Nested Objects Copiar objetos anidados
Copy Selection Copiar selección
Copy as JSON Copiar como JSON
Copy with Base Point Copiar con punto base
Count Contar
Count List Lista de recuento
Count Table Tabla de recuento
Create Block Crear bloque
Creates a user parameter Crea un parámetro de usuario
Crosshair size Tamaño del cursor en cruz
Ctrl+Enter to finish Ctrl+Intro para terminar
Current Scale Only Solo la escala actual
Ctrl+Enter to finish Ctrl+Enter para terminar
Current layer Capa actual
Current selection Selección actual
Current: {color} Actual: {color}
@@ -194,25 +151,17 @@
Cyan Cian
Cylinder Cilindro
DWG DWG
DWG Reference... Referencia DWG...
DXF DXF
Dark Oscuro
Data Extraction... Extracción de datos...
Data Link... Vínculo de datos...
Default Predeterminado
Default Save Format Formato de guardado predeterminado
Default Save Version Versión de guardado predeterminada
Default save version Versión de guardado predeterminada
Define Attributes Definir atributos
Define Attributes... Definir atributos...
Defined width Anchura definida
Degree Grado
Degrees of Freedom Grados de libertad
Degrees of freedom: {n} Grados de libertad: {n}
Delete Eliminar
Delete Column Eliminar columna
Delete Constraints Eliminar restricciones
Delete Current Scale Eliminar escala actual
Delete Dimension Style Eliminar estilo de cota
Delete Duplicate Objects Eliminar objetos duplicados
Delete Layer Eliminar capa
@@ -224,13 +173,9 @@
Delta Y Incremento Y
Description Descripción
Deselect All Deseleccionar todo
Detach Desenlazar
Diameter Diámetro
Dim style Estilo de cota
Dimension Acotación
Dimension Break Partir cota
Dimension Edit Editar cota
Dimension Objects Only Solo objetos de cota
Dimension Space Espaciado de cotas
Dimension Style Override Modificar estilo de cota
Dimension Style... Estilo de cota...
@@ -240,7 +185,6 @@
Dimensional Constraint Parameters Parámetros de restricciones dimensionales
Dimensional Constraints Restricciones dimensionales
Disassociate Dimensions Desasociar cotas
Display Visualización
Display name Nombre visible
Distance Distancia
Divide Dividir
@@ -256,32 +200,23 @@
Drawing (DWG) Dibujo (DWG)
Drawing (DXF) Dibujo (DXF)
Drawing Limits Límites del dibujo
Drawing Recovery Manager... Administrador de recuperación de dibujos...
Drawing Template (DWT) Plantilla de dibujo (DWT)
Drawing Utilities Utilidades de dibujo
Drawings (DWG, DXF) Dibujos (DWG, DXF)
Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Los dibujos que aún no tienen archivo se guardan en la versión predeterminada. 0 minutos desactiva el guardado automático.
Dynamic Dinámico
Dynamic Dimensions Cotas dinámicas
Dynamic Input Entrada dinámica
Dynamic Input (F12) Entrada dinámica (F12)
E E
Edit Edición
Edit Array Editar matriz
Edit Attributes... Editar atributos...
Edit Table Cell Editar celda de tabla
Edit Text... Editar texto...
Elevation Elevación
Ellipse Elipse
Elliptical Arc Arco elíptico
Enable Dynamic Input (F12) Activar entrada dinámica (F12)
End Object Isolation Terminar aislamiento de objetos
End Width Anchura final
End X X final
End Y Y final
End angle Ángulo final
Endpoint Punto final
Enter Intro
Enter Top, Zero or Bottom. Introduce Top, Zero o Bottom.
Enter a fit data option Introduce una opción de datos de ajuste
Enter a pattern name Introduce un nombre de patrón
@@ -328,19 +263,14 @@
Entire drawing Todo el dibujo
Equal Igual
Erase Borrar
Evaluate Evaluar
Exclude from new selection set Excluir del nuevo conjunto de selección
Explode Descomponer
Export Exportar
Export Table Exportar tabla
Export to PDF... Exportar a PDF...
Export... Exportar...
Expression Expresión
Extend Alargar
Extension Extensión
Extents Extensión
External Reference Referencia externa
External References Referencias externas
Extrude Extruir
File Archivo
File Tab Pestañas de archivos
@@ -363,7 +293,6 @@
Freeze/Thaw in all viewports Inutilizar/reutilizar en todas las ventanas gráficas
Freeze/Thaw in the current viewport Inutilizar/reutilizar en la ventana gráfica actual
From Desde
From Template... Desde plantilla...
General General
Geometric Center Centro geométrico
Geometric Constraint Restricción geométrica
@@ -371,32 +300,24 @@
Geometry Geometría
Get Variable Consultar variable
Get View Consultar vista
Get from the Drawing Obtener del dibujo
Global Global
Global width Anchura global
Grab bag Miscelánea
Grab bag Lista
Gradient Degradado
Gradient... Degradado...
Green Verde
Grid (F7) Rejilla (F7)
Grid Display Mostrar rejilla
Grid On (F7) Activar rejilla (F7)
Grid snap Forzar cursor a la rejilla
Grid spacing Espaciado de rejilla
Grip Edit Editar con pinzamientos
Grip Mirror Simetría con pinzamientos
Grip Option Opción de pinzamiento
Grip Rotate Girar con pinzamientos
Grip Scale Escalar con pinzamientos
Grip Stretch Estirar con pinzamientos
Grip size Tamaño de los pinzamientos
Group Agrupar
Group Edit Editar grupo
Group Options Opciones de grupo
Group Selection Selección de grupo
Hatch Sombreado
Hatch Edit Editar sombreado
Hatch Patterns Patrones de sombreado
Hatch... Sombreado...
Height Altura
Height: {height} Altura: {height}
@@ -406,32 +327,24 @@
Hide All Constraint Bars Ocultar todas las barras de restricciones
Hide All Dynamic Dimensions Ocultar todas las cotas dinámicas
Hide Layer List Ocultar lista de capas
Hide Objects Ocultar objetos
Hide Palette Ocultar paleta
Hide Palettes Ocultar paletas
History Historial
Hold Shift to add to the selection Mantener pulsada Mayús para añadir a la selección
Home Inicio
Horizontal Horizontal
HorizontalDistance Distancia horizontal
How to apply: Cómo aplicar:
ID Point Coordenadas de un punto
Import Importar
Import... Importar...
In Acercar
In-window Menu Bar Barra de menús en la ventana
Include in new selection set Incluir en el nuevo conjunto de selección
Increment angle Incremento angular
Index Color Color indexado
Index color: {index} Color indexado: {index}
Infer Constraints Inferir restricciones
Index color: {index} Color de índice: {index}
Inquiry Consultar
Insert Insertar
Insert Block Insertar bloque
Insert Column Insertar columna
Insert Row Insertar fila
Insert Table Formula Insertar fórmula en tabla
Insert tab Insertar tabulación
Insert this block Insertar este bloque
Insertion Inserción
Insertion X Inserción X
@@ -439,27 +352,15 @@
Insertion Z Inserción Z
Inspect Constraints Inspeccionar restricciones
Inspect Drawing Inspeccionar dibujo
Inspection Inspección
Interface Language Idioma de la interfaz
Interface Theme Tema de la interfaz
Interface language Idioma de la interfaz
Interface theme Tema de la interfaz
Intersect Intersección
Intersection Intersección
Isolate Aislar
Isolate Layer Aislar capa
Isolate Objects Aislar objetos
Isometric Drafting Dibujo isométrico
Isometric Plane Plano isométrico
Isometric snap Forzar cursor isométrico
Jogged Con recodo
Jogged Linear Lineal con recodo
Join Unir
Join the ArtCraft Discord… Únete al Discord de ArtCraft…
Join us on Discord Únete a nosotros en Discord
Keep the system cursor visible over the drawing area Mantener visible el cursor del sistema sobre el área de dibujo
Language Idioma
Largest array Matriz más grande
Last Último
Last commit Último commit
Layer Capa
@@ -468,8 +369,8 @@
Layer Merge Fusionar capas
Layer Off Desactivar capa
Layer Properties Propiedades de capa
Layer Properties Manager Administrador de propiedades de capas
Layer Properties Manager — current viewport Administrador de propiedades de capas — ventana gráfica actual
Layer Properties Manager Administrador de capas
Layer Properties Manager — current viewport Administrador de capas — ventana gráfica actual
Layer State Estado de capas
Layer States Manager... Administrador de estados de capas...
Layer Tools Herramientas de capas
@@ -480,9 +381,7 @@
Layers displayed: {shown} of {total}. Current layer: {layer} Capas mostradas: {shown} de {total}. Capa actual: {layer}
Layout Presentación
Layout list Lista de presentaciones
Layout paper Papel de la presentación
Leader Directriz
Leaders Only Solo directrices
Left Izquierda
Length Longitud
Length X Longitud X
@@ -495,12 +394,9 @@
Lines +/− Líneas +/−
Lines added Líneas añadidas
Lines deleted Líneas eliminadas
Lines of Command History Líneas del historial de comandos
Linetype Tipo de línea
Linetype Scale Escala del tipo de línea
Linetype scale Escala del tipo de línea
Loaded Cargada
Lock viewport Bloquear ventana gráfica
Ltscale Escala de tipo de línea
Linetype... Tipo de línea...
Lineweight Grosor de línea
@@ -520,15 +416,13 @@
Lock Layer Bloquear capa
Lock/Unlock Bloquear/desbloquear
Loft Solevación
MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Sin afiliación con Autodesk, Inc.
MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT O Apache-2.0. Sin afiliación con Autodesk, Inc.
MODEL MODELO
MText Texto multilínea
Magenta Magenta
Major line every Línea principal cada
Make Current Establecer actual
Make Object's Layer Current Usar como actual la capa del objeto
Make it a block of this drawing, keeping its layer names Convertirla en un bloque de este dibujo, conservando los nombres de sus capas
Make it a block of this drawing; its layers become NAME$0$LAYER Convertirla en un bloque de este dibujo; sus capas pasan a ser NAME$0$LAYER
Make... Crear...
Match Layer Igualar capa
Match Properties Igualar propiedades
@@ -542,20 +436,16 @@
Mirror Simetría
Model Modelo
Model Space (in viewport) Espacio modelo (en ventana gráfica)
Model background Fondo del modelo
Modeling Modelado
Models Modelos
Modify Modificar
More toolbar commands Más comandos de la barra de herramientas
Move Desplazar
Multileader Directriz múltiple
Multileader Style... Estilo de directriz múltiple...
Multileader style Estilo de directriz múltiple
Multiline Línea múltiple
Multiline Style... Estilo de línea múltiple...
Multiline Text Texto multilínea
Multiline Text... Texto multilínea...
Multiline... Línea múltiple...
Multiple Varios
Multiple Point Puntos múltiples
My Mías
@@ -563,7 +453,6 @@
N N
Name Nombre
Name (A–Z) Nombre (A–Z)
Named Views... Vistas con nombre...
Nearest Cercano
New Nuevo
New (metric) Nuevo (métrico)
@@ -572,26 +461,20 @@
New Drawing... Nuevo dibujo...
New Layer Nueva capa
New Layout Nueva presentación
New VP Freeze Inutilizar en nuevas ventanas gráficas
New VP Freeze Inutilizar en nuevas ventanas
New Viewports... Nuevas ventanas gráficas...
New from Template... Nuevo desde plantilla...
New layout Nueva presentación
New state name Nombre del nuevo estado
Next Change Cambio siguiente
No No
No blocks defined in this drawing. No hay bloques definidos en este dibujo.
No contributor data was built into this copy. Esta copia no incluye datos de colaboradores.
No external references are attached. No hay referencias externas adjuntas.
No model credits were built into this copy. Esta copia no incluye créditos de modelos.
No saved layer states No hay estados de capas guardados
No selection Sin selección
Node Nodo
None Ninguno
Not Found No encontrada
Not available No disponible
Not available yet Aún no disponible
Not loaded No cargada
Numbered list Lista numerada
OK Aceptar
Object Objeto
Object Snap Referencia a objetos
@@ -599,28 +482,16 @@
Object Snap On (F3) Activar referencia a objetos (F3)
Object Snap Tracking Rastreo de referencia a objetos
Object Snap Tracking (F11) Rastreo de referencia a objetos (F11)
Object Snap Tracking Settings Configuración del rastreo de referencia a objetos
Object snap aperture Apertura de referencia a objetos
Object snap marker size Tamaño del marcador de referencia a objetos
Object type: Tipo de objeto:
Oblique Oblicua
Off Desactivado
Offset Desfase
On Activado
On/Off Activar/desactivar
One of the Crafting Apps by the ArtCraft team and community. Una de las Crafting Apps del equipo y la comunidad de ArtCraft.
Only in this drawing: {n} Solo en este dibujo: {n}
Only in {name}: {n} Solo en {name}: {n}
Open Abrir
Open (⌘O) Abrir (⌘O)
Open Sample Drawing Abrir dibujo de ejemplo
Open Xref Abrir referencia externa
Open and Save Abrir y guardar
Open sample drawing Abrir dibujo de ejemplo
Open... Abrir...
Operator: Operador:
Options Opciones
Options... Opciones...
Orbit Órbita
Ordinate Coordenadas
Ortho (F8) Ortogonal (F8)
@@ -628,15 +499,9 @@
Ortho Mode (F8) Modo ortogonal (F8)
Osnap Settings... Configuración de referencia a objetos...
Out Alejar
Over-constrained Restricciones excesivas
Overlay Superposición
Override Modificar
PAPER PAPEL
Paste to Command Line Pegar en la línea de comandos
Percent Porcentaje
Pickbox size Tamaño de la caja de selección
Plotter Manager Administrador de trazadores
Plotter Manager... Administrador de trazadores...
Polar (distance < angle) Polares (distancia < ángulo)
PRs PRs
Page Setup Manager Administrador de configuración de página
@@ -658,8 +523,7 @@
Perpendicular Perpendicular
Plot Trazar
Plot (click: do not plot) Se trazará (clic: no trazar)
Plot Preview Vista previa de trazado
Plot Window Ventana de trazado
Plot Preview Vista previa de impresión
Plot style Estilo de trazado
Plot style attached to Estilo de trazado asociado a
Plot style table Tabla de estilos de trazado
@@ -670,74 +534,7 @@
New CTB Nueva CTB
New STB Nueva STB
New style Nuevo estilo
Polar angle measurement Medición del ángulo polar
Polar distance Distancia polar
PolarSnap Forzar cursor polar
Polygonal Viewport Ventana gráfica poligonal
Polyline Edit Editar polilínea
Previous Change Cambio anterior
Profiles Perfiles
QuickCalc Pick Designar para la calculadora rápida
Recalculate Table Formulas Recalcular fórmulas de tabla
Recent Commands Comandos recientes
Recent Input Entradas recientes
Rectangular snap Forzar cursor rectangular
Reference Manager Administrador de referencias
Reference Name Nombre de la referencia
References: {n} Referencias: {n}
Reinitialize Reinicializar
Relative to last segment Relativo al último segmento
Reload Volver a cargar
Reload All Volver a cargar todas
Remove Vertex Eliminar vértice
Reset Panel Layout Restablecer disposición de paneles
Reset keeps the interface language. Restablecer conserva el idioma de la interfaz.
Reset to Defaults Restablecer valores predeterminados
SVG drawings (SVG, SVGZ) Dibujos SVG (SVG, SVGZ)
Save new drawings as Guardar los dibujos nuevos como
Saved Path Ruta guardada
Scale List... Lista de escalas...
Screen magnifiers, screen readers and recorders follow the system cursor. Las lupas, los lectores de pantalla y las grabadoras siguen el cursor del sistema.
Select Similar Seleccionar similares
Select objects first, then choose a command Seleccionar primero los objetos y luego elegir un comando
Selection Selección
Send Hatches to Back Enviar sombreados al fondo
Send Under Objects Enviar por debajo de objetos
Set Current Viewport Establecer ventana gráfica actual
Set Table Cell Style Establecer estilo de celda de tabla
Settings... Configuración...
Show Annotative Objects Mostrar objetos anotativos
Show Quick Properties for selected objects Mostrar las propiedades rápidas de los objetos seleccionados
Show and hide the tools of this category Mostrar y ocultar las herramientas de esta categoría
Show command prompting near the crosshairs Mostrar las solicitudes de comandos junto al cursor en cruz
Show in Tool Sets Mostrar en los conjuntos de herramientas
Snap Overrides Anulaciones de referencia a objetos
Snap type Tipo de forzado de cursor
Snap, grid, polar tracking and Dynamic Input: Forzar cursor, rejilla, rastreo polar y entrada dinámica:
Solid Editing Edición de sólidos
Spline Edit Editar spline
Stretch Vertex Estirar vértice
Synchronize Multiple-scale Positions Sincronizar posiciones de varias escalas
Table Breaks Divisiones de tabla
Table Cell Style Estilo de celda de tabla
Temporary track point Punto de rastreo temporal
Text Objects Only Solo objetos de texto
Thaw Layer Reutilizar capa
The aperture is how near the cursor must come to a point for an object snap to find it. La apertura indica cuánto debe acercarse el cursor a un punto para que la referencia a objetos lo encuentre.
Time-sensitive right-click: a quick click is Enter, holding opens the menu Clic derecho según duración: un clic rápido equivale a Intro; mantenerlo abre el menú
To A
Tolerance... Tolerancia...
Track orthogonally only Rastrear solo ortogonalmente
Track using all polar angle settings Rastrear con todos los ángulos polares configurados
Turn On Layer Activar capa
Turned off, a right-click while a command runs always opens the shortcut menu. Desactivado, un clic derecho durante un comando siempre abre el menú contextual.
Units Conversion Conversión de unidades
Unload Descargar
Unloaded Descargada
Unlock viewport Desbloquear ventana gráfica
Unreferenced Sin referencias
Unresolved Sin resolver
Update Data Links Actualizar vínculos de datos
Use for this layout Usar en esta presentación
Display plot styles Mostrar estilos de trazado
No plot style tables. No hay tablas de estilos de trazado.
@@ -898,7 +695,7 @@
Select objects to array. Selecciona objetos para crear la matriz.
Select objects to stretch by crossing-window or crossing-polygon... Selecciona objetos para estirar mediante ventana o polígono de captura...
Select objects: Selecciona objetos:
Select one polyline (Multiple is available through the JSON form). Selecciona una polilínea (la opción Multiple está disponible en la forma JSON).
Select one polyline (Multiple is available through the JSON form). Selecciona una polilínea (Múltiple está disponible mediante JSON).
Select path curve Selecciona la curva del camino
Select point or object Selecciona un punto u objeto
Select polyline or Selecciona una polilínea o
@@ -935,9 +732,9 @@
Snap Referencia
Snap Mode Forzar cursor
Snap Mode (F9) Forzar cursor (F9)
Snap On (F9) Activar forzar cursor (F9)
Snap X spacing Intervalo X de forzado de cursor
Snap and Grid Forzar cursor y rejilla
Snap On (F9) Forzar cursor (F9)
Snap X spacing Intervalo de resolución X
Snap and Grid Resolución y rejilla
Solid Sólido
Sort Ordenar
Specify a point Indica un punto
@@ -1108,7 +905,7 @@
Text Height Altura del texto
Text Style... Estilo de texto...
Text height Altura del texto
Text override Sustitución de texto
Text override Texto alternativo
Text style Estilo de texto
Thaw All Layers Reutilizar todas las capas
There is nothing here yet. Todavía no hay dibujos aquí.
@@ -1116,7 +913,7 @@
Time Tiempo
To get started, create or open a drawing. Para empezar, crea o abre un dibujo.
Tool Bar Barra de herramientas
Tool Sets Conjuntos de herramientas
Tool Sets Herramientas de dibujo
Tools Herramientas
Torus Toroide
Trace Trazo
@@ -1144,20 +941,12 @@
Use System Setting Usar configuración del sistema
User Usuario
User Parameters Parámetros de usuario
User Preferences Preferencias de usuario
Username Nombre de usuario
VP Color Color en ventana gráfica
VP Freeze Inutilizar en ventana gráfica
VP Color Color en ventana
VP Freeze Inutilizar en ventana
Value Valor
Value: Valor:
Variables Variables
Version Versión
Version 2000 Versión 2000
Version 2004 Versión 2004
Version 2007 Versión 2007
Version 2010 Versión 2010
Version 2013 Versión 2013
Version 2018 Versión 2018
Version {version} Versión {version}
Vertical Vertical
VerticalDistance Distancia vertical
@@ -1166,11 +955,8 @@
Viewport Ventana gráfica
Viewport Layer Freeze Inutilizar capas en ventana gráfica
Viewport Properties Propiedades de ventana gráfica
Viewport is locked La ventana gráfica está bloqueada
Viewport scale Escala de la ventana gráfica
Viewport {id} Ventana gráfica {id}
Viewports Ventanas gráficas
Volume Volumen
W O
WCS ⌄ SCU ⌄
White Blanco
@@ -1179,24 +965,18 @@
Wipeout Cobertura
Workspace Switching Cambiar espacio de trabajo
Write Block Escribir bloque
X X
Xline Línea auxiliar
Y Y
Yellow Amarillo
Yes Sí
Zoom Zoom
Zoom All Zoom a todo
Zoom Dynamic Zoom dinámico
Zoom Extents Zoom a extensión
Zoom In Acercar
Zoom Object Zoom a objeto
Zoom Out Alejar
Zoom Previous Zoom anterior
Zoom Realtime Zoom en tiempo real
Zoom Window Zoom por ventana
error error
min min
objects objetos
{contributors} · {commits} {contributors} · {commits}
{kind} (constraint {id}) {kind} (restricción {id})
{kind} constraint {name} = {expression} (double-click to edit) Restricción {kind} {name} = {expression} (doble clic para editar)
@@ -1214,23 +994,3 @@
Hide Others Ocultar otros
Accessibility Accesibilidad
Show System Cursor Mostrar cursor del sistema
3D Mouse... Ratón 3D...
3D Mouse Ratón 3D
Calibrate 3D Mouse Calibrar ratón 3D
3D Mouse Navigation Navegación con ratón 3D
3D mouse input is unavailable on this platform El ratón 3D no está disponible en esta plataforma
No supported 3D mouse found No se encontró un ratón 3D compatible
Release the 3D mouse to calibrate its resting position Suelta el ratón 3D para calibrar su posición de reposo
3D mouse connected Ratón 3D conectado
3D mouse busy, possibly 3DxWare or missing device permissions Ratón 3D ocupado, posiblemente por 3DxWare o por falta de permisos del dispositivo
3D mouse disconnected Ratón 3D desconectado
3D mouse input could not be started No se pudo iniciar el ratón 3D
Enable 3D mouse navigation Activar navegación con ratón 3D
Pan sensitivity Sensibilidad del encuadre
Zoom sensitivity Sensibilidad de zoom
Reverse zoom direction Invertir dirección del zoom
Calibrate Calibrar
Move sideways to pan; push/pull or twist to zoom. Mueve el mando lateralmente para encuadrar; empuja, tira o gira para hacer zoom.
Left device button: zoom extents. Right device button: calibrate. Botón izquierdo del dispositivo: zoom a la extensión. Botón derecho: calibrar.
Hatch Pattern Patrón de sombreado
Search for pattern Buscar patrón
1 # CADCraft Spanish interface catalog — original translations.
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
51
52
53
54
55
56
58
59
60
61
62
63
64
65
66
67
68
69
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
173
174
175
176
177
178
179
180
181
185
186
187
188
189
190
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
263
264
265
266
267
268
269
270
271
272
273
274
275
276
293
294
295
296
297
298
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
369
370
371
372
373
374
375
376
381
382
383
384
385
386
387
394
395
396
397
398
399
400
401
402
416
417
418
419
420
421
422
423
424
425
426
427
428
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
453
454
455
456
457
458
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
499
500
501
502
503
504
505
506
507
523
524
525
526
527
528
529
534
535
536
537
538
539
540
695
696
697
698
699
700
701
732
733
734
735
736
737
738
739
740
905
906
907
908
909
910
911
913
914
915
916
917
918
919
941
942
943
944
945
946
947
948
949
950
951
952
955
956
957
958
959
960
961
962
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
994
995
996
File diff suppressed because it is too large Load Diff
+2 -60
View File
@@ -23,29 +23,10 @@ pub enum Preference {
German,
#[serde(rename = "es")]
Spanish,
#[serde(rename = "cs")]
Czech,
#[serde(rename = "ru")]
Russian,
#[serde(rename = "fr")]
French,
#[serde(rename = "zh")]
Chinese,
}
impl Preference {
pub const ALL: [Self; 10] = [
Self::Auto,
Self::English,
Self::Ukrainian,
Self::BrazilianPortuguese,
Self::German,
Self::Spanish,
Self::Czech,
Self::Russian,
Self::French,
Self::Chinese,
];
pub const ALL: [Self; 6] = [Self::Auto, Self::English, Self::Ukrainian, Self::BrazilianPortuguese, Self::German, Self::Spanish];
/// The codes a preference can be given as, for messages ("auto, en or uk").
pub fn codes() -> String {
@@ -64,10 +45,6 @@ impl Preference {
Self::BrazilianPortuguese => "pt-BR",
Self::German => "de",
Self::Spanish => "es",
Self::Czech => "cs",
Self::Russian => "ru",
Self::French => "fr",
Self::Chinese => "zh",
}
}
@@ -79,10 +56,6 @@ impl Preference {
Self::BrazilianPortuguese => "Português (Brasil)",
Self::German => "Deutsch",
Self::Spanish => "Español",
Self::Czech => "Čeština",
Self::Russian => "Русский",
Self::French => "Français",
Self::Chinese => "简体中文",
}
}
@@ -102,10 +75,6 @@ impl Preference {
Self::BrazilianPortuguese => "pt-BR",
Self::German => "de",
Self::Spanish => "es",
Self::Czech => "cs",
Self::Russian => "ru",
Self::French => "fr",
Self::Chinese => "zh",
Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"),
}
}
@@ -122,7 +91,7 @@ pub struct Language {
catalog: OnceLock<Catalog<'static>>,
}
pub static LANGUAGES: [Language; 9] = [
pub static LANGUAGES: [Language; 5] = [
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
Language {
code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new()
@@ -139,14 +108,6 @@ pub static LANGUAGES: [Language; 9] = [
Language { code: "de", name: "Deutsch", source: include_str!("de.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
// Spanish integer plurals follow the English rule too.
Language { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
Language { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_cs, plural_forms: 3, catalog: OnceLock::new() },
// Russian integer plurals follow the Ukrainian rule: one (1, 21), few (2–4), many.
Language { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() },
// French integer plurals (CLDR `fr`): one (0, 1), other, the Portuguese rule.
Language { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_pt, plural_forms: 2, catalog: OnceLock::new() },
// Chinese has no grammatical plural: both `@plural` forms read the same. Its glyphs come from
// an installed CJK font (`theme::cjk_font`); without one the interface stays English.
Language { code: "zh", name: "简体中文", source: include_str!("zh.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
];
/// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`.
@@ -207,16 +168,6 @@ pub fn t(source: &str) -> &str {
CURRENT.with(|l| tr(l.get(), source))
}
/// Menu headings can differ from command or object labels (e.g. Czech Kótování / Kóta).
/// Catalogs without a `menu` entry retain their ordinary translation.
pub fn tr_menu<'a>(language: &str, source: &'a str) -> &'a str {
catalog(language).and_then(|c| c.get(&("menu", source))).copied().unwrap_or_else(|| tr(language, source))
}
pub fn menu(source: &str) -> &str {
CURRENT.with(|l| tr_menu(l.get(), source))
}
/// Named placeholders allow translations to reorder values. Inserted values are never looked up
/// or substituted again, so a user's name containing `{n}` is preserved verbatim.
pub fn format(source: &str, values: &[(&str, String)]) -> String {
@@ -262,15 +213,6 @@ fn plural_en(n: u64) -> usize {
usize::from(n != 1)
}
/// Czech integer plurals: one (1), few (2–4), other (0 and 5 or more).
pub fn plural_cs(n: u64) -> usize {
match n {
1 => 0,
2..=4 => 1,
_ => 2,
}
}
pub fn count(n: u64, one: &str, other: &str) -> String {
let language = CURRENT.get();
let source = catalog(language).and_then(|c| c.get(&("@plural", one))).copied();
+4 -7
View File
@@ -86,6 +86,10 @@
CADCraft Help Ajuda do CADCraft
Cancel Cancelar
Cartesian (x, y) Cartesiano (x, y)
Catppuccin Latte Catppuccin Latte
Catppuccin Frappé Catppuccin Frappé
Catppuccin Macchiato Catppuccin Macchiato
Catppuccin Mocha Catppuccin Mocha
Center Centro
Center Line Linha de centro
Center Mark Marca de centro
@@ -108,7 +112,6 @@
Close All Fechar tudo
Closed Fechada
Coincident Coincidente
Collapse Palettes Recolher paletas
Collapse Tool Sets Recolher conjuntos de ferramentas
Collinear Colinear
Color Cor
@@ -357,8 +360,6 @@
Isolate Layer Isolar camada
Isometric Drafting Desenho isométrico
Join Unir
Join the ArtCraft Discord… Entrar no Discord do ArtCraft…
Join us on Discord Junte-se a nós no Discord
Language Idioma
Last Último
Last commit Último commit
@@ -486,7 +487,6 @@
Offset Deslocar
On Ativado
On/Off Ativar/desativar
One of the Crafting Apps by the ArtCraft team and community. Um dos Crafting Apps da equipe e da comunidade ArtCraft.
Open (⌘O) Abrir (⌘O)
Open Sample Drawing Abrir desenho de exemplo
Open sample drawing Abrir desenho de exemplo
@@ -524,7 +524,6 @@
Plot Plotar
Plot (click: do not plot) Plotar (clique para não plotar)
Plot Preview Visualizar plotagem
Plot Window Janela de plotagem
Plot style Estilo de plotagem
Plot style attached to Estilo de plotagem anexado a
Plot style table Tabela de estilos de plotagem
@@ -995,5 +994,3 @@
Hide Others Ocultar outros
Accessibility Acessibilidade
Show System Cursor Exibir cursor do sistema
Hatch Pattern Padrão de hachura
Search for pattern Pesquisar padrão
1 # CADCraft Brazilian Portuguese (pt-BR) interface catalog.
86
87
88
89
90
91
92
93
94
95
112
113
114
115
116
117
360
361
362
363
364
365
487
488
489
490
491
492
524
525
526
527
528
529
994
995
996
File diff suppressed because it is too large Load Diff
+4 -157
View File
@@ -15,7 +15,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {
let (catalog, errors) = parse(language.source);
assert!(errors.is_empty(), "{}: {errors:?}", language.code);
for ((context, key), value) in catalog {
assert!(["", "menu", "@plural"].contains(&context), "unknown context: {context}");
assert!(["", "@plural"].contains(&context), "unknown context: {context}");
let forms: Vec<_> = if context == "@plural" { value.split('|').collect() } else { vec![value] };
if context == "@plural" {
assert_eq!(forms.len(), language.plural_forms, "{}: {key}", language.code);
@@ -39,7 +39,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {
/// to English for what they don't cover yet; their gaps are reported, not enforced, so adding a
/// command doesn't require a row in every language.
const REQUIRED: &[&str] = &["uk"];
const REPORTED: &[&str] = &["pt-BR", "de", "es", "cs", "ru", "fr", "zh"];
const REPORTED: &[&str] = &["pt-BR", "de", "es"];
fn check(language: &str, what: &str, missing: Vec<String>) {
if REQUIRED.contains(&language) {
@@ -94,7 +94,6 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
include_str!("../cmdline.rs"),
include_str!("../credits.rs"),
include_str!("../palettes/quickcalc.rs"),
include_str!("../palettes/patterns.rs"),
include_str!("../dialogs.rs"),
include_str!("../layers.rs"),
include_str!("../canvas/compare.rs"),
@@ -102,7 +101,6 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
include_str!("../parametric.rs"),
include_str!("../quick.rs"),
include_str!("../vpscale.rs"),
include_str!("../options/dialog.rs"),
];
let mut missing = BTreeSet::new();
for source in sources {
@@ -147,18 +145,14 @@ fn locale_and_saved_preference_resolution() {
for tag in ["de", "de-DE", "de_DE.UTF-8", "DE-at", "de-CH"] {
assert_eq!(locale_language(tag), Some("de"), "{tag}");
}
for (tag, code) in [("ru", "ru"), ("ru-RU", "ru"), ("ru_RU.UTF-8", "ru"), ("fr", "fr"), ("fr-FR", "fr"), ("fr_CA.UTF-8", "fr")] {
assert_eq!(locale_language(tag), Some(code), "{tag}");
}
for tag in ["ua", "ukulele", "pl-PL", "", "garbage", "💙"] {
for tag in ["ua", "ukulele", "ru-RU", "", "garbage", "💙"] {
assert_eq!(locale_language(tag), None, "{tag}");
}
for tag in ["pt", "pt-BR", "pt_BR.UTF-8", "PT-br", "pt-PT"] {
assert_eq!(locale_language(tag), Some("pt-BR"), "{tag}");
}
assert_eq!(locale_language("ptolemy"), None);
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "pt-BR".into()]), "pt-BR");
assert_eq!(Preference::Auto.resolve(&["fr-FR".into(), "pt-BR".into()]), "fr");
assert_eq!(Preference::Auto.resolve(&["fr-FR".into(), "pt-BR".into()]), "pt-BR");
for tag in ["es", "es-ES", "es_MX.UTF-8", "ES-ar"] {
assert_eq!(locale_language(tag), Some("es"), "{tag}");
}
@@ -394,150 +388,3 @@ fn default_fonts_cover_every_ukrainian_letter_in_the_catalog() {
}
});
}
#[test]
fn czech_locales_preferences_and_plural_forms() {
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS-cz", " cs-CZ ", "cs_CZ@euro"] {
assert_eq!(locale_language(tag), Some("cs"), "{tag}");
}
for tag in ["cz", "csharp", "csomething"] {
assert_eq!(locale_language(tag), None, "{tag}");
}
assert_eq!(Preference::parse("cs"), Some(Preference::Czech));
assert_eq!(Preference::Czech.name(), "Čeština");
assert_eq!(Preference::Czech.resolve(&["en-US".into()]), "cs");
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "cs-CZ".into(), "en-US".into()]), "cs");
let mut app = CadApp::new(Session::new(), Services::default());
app.ui.interface_language = Preference::Czech;
let mut restored = CadApp::new(Session::new(), Services::default());
restored.load_prefs(&app.prefs_json());
assert_eq!(restored.ui.interface_language, Preference::Czech);
with_language("cs", || {
for (n, form) in [(0, 2), (1, 0), (2, 1), (4, 1), (5, 2), (11, 2), (21, 2), (22, 2), (25, 2), (u64::MAX, 2)] {
assert_eq!(plural_cs(n), form, "{n}");
let contributor = ["přispěvatel", "přispěvatelé", "přispěvatelů"][form];
let commit = ["commit", "commity", "commitů"][form];
assert_eq!(count(n, "{n} contributor", "{n} contributors"), format!("{n} {contributor}"));
assert_eq!(count(n, "{n} commit", "{n} commits"), format!("{n} {commit}"));
}
assert_eq!(format("Current: {color}", &[("color", "{n} Open".into())]), "Aktuální: {n} Open");
});
}
#[test]
fn czech_menu_headings_keep_object_labels_and_other_catalogs() {
with_language("cs", || {
assert_eq!(menu("View"), "Zobrazení");
assert_eq!(t("View"), "Pohled");
assert_eq!(menu("Dimension"), "Kótování");
assert_eq!(t("Dimension"), "Kóta");
assert_eq!(menu("Draw"), "Kreslení");
assert_eq!(menu("Modify"), "Modifikace");
assert_eq!(menu("Edit"), "Úpravy");
assert_eq!(menu("File"), "Soubor");
});
for language in ["en", "uk", "de", "es", "pt-BR", "unknown"] {
for source in crate::menus::MENUS {
assert_eq!(tr_menu(language, source), tr(language, source));
}
}
}
#[test]
fn live_czech_switch_preserves_drawing_command_and_saved_choice() {
let mut app = CadApp::new(Session::new(), Services::default());
app.ui.in_window_menu = true;
app.session.state_mut().unwrap().title = "Open".into();
let ctx = egui::Context::default();
app.logic(&ctx);
assert!(render(&mut app, &ctx).contains("Properties"));
app.start("line");
let prompt = crate::control::cmdline_state(&app);
let commands = crate::control::all_commands(&app);
app.run("ui.language", json!({"lang":"cs"})).unwrap();
let text = render(&mut app, &ctx);
for label in ["Vlastnosti", "Soubor", "Zobrazení", "Kreslení", "Kótování", "Určete první bod"] {
assert!(text.contains(label), "missing {label}: {text}");
}
assert_eq!(app.language(), "cs");
assert_eq!(app.session.state().unwrap().title, "Open");
assert_eq!(crate::control::cmdline_state(&app), prompt);
assert_eq!(crate::control::all_commands(&app), commands);
assert_eq!(t("Properties"), "Properties");
app.run("ui.resetpalettes", json!({})).unwrap();
assert_eq!(app.language(), "cs");
app.run("ui.language", json!({"lang":"en"})).unwrap();
assert!(render(&mut app, &ctx).contains("Properties"));
}
#[test]
fn czech_dialogs_render_with_stable_ids() {
for (dialog, heading) in [
("dsettings", "Nastavení kreslení"),
("about", "O aplikaci CADCraft"),
("commands", "Přehled příkazů"),
("blocks", "Bloky"),
("layers", "Správce vlastností hladin"),
("qselect", "Rychlý výběr"),
("parameters", "Správce parametrů"),
("language", "Čeština"),
("options:preferences", "Uživatelské předvolby"),
] {
let mut app = CadApp::new(Session::new(), Services::default());
app.ui.interface_language = Preference::Czech;
app.ui.dialog = Some(dialog.into());
let ctx = egui::Context::default();
app.logic(&ctx);
let text = render(&mut app, &ctx);
assert!(text.contains(heading), "{dialog}: {text}");
assert_eq!(app.ui.dialog.as_deref(), Some(dialog));
}
}
#[test]
fn default_fonts_cover_czech_letters_and_typographic_quotes() {
let ctx = egui::Context::default();
let mut output = ctx.run_ui(Default::default(), |_| {});
output.textures_delta.clear();
let letters: BTreeSet<_> = "ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽáčďéěíňóřšťúůýž„“".chars().collect();
ctx.fonts_mut(|view| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
let mut font = view.fonts.font(&family);
for c in &letters {
assert!(font.characters().contains_key(c), "missing {c} in {family:?}");
}
}
});
}
#[test]
fn russian_and_french_plurals() {
with_language("ru", || {
for (n, word) in [(1, "участник"), (2, "участника"), (5, "участников"), (21, "участник"), (22, "участника"), (11, "участников")]
{
assert_eq!(count(n, "{n} contributor", "{n} contributors"), format!("{n} {word}"));
}
});
with_language("fr", || {
for (n, word) in [(0, "contributeur"), (1, "contributeur"), (2, "contributeurs"), (10, "contributeurs")] {
assert_eq!(count(n, "{n} contributor", "{n} contributors"), format!("{n} {word}"));
}
});
}
/// Chinese shows only with an installed CJK font (none is bundled); without one the interface
/// stays English instead of drawing boxes, and choosing it says why.
#[test]
fn chinese_needs_an_installed_cjk_font() {
assert_eq!(locale_language("zh-CN"), Some("zh"));
let mut app = CadApp::new(Session::new(), Services::default());
let r = app.run("ui.language", json!({"lang": "zh"})).unwrap();
assert_eq!(r["interfaceLanguage"], "zh");
let font = crate::theme::cjk_font();
assert_eq!(app.language(), if font.is_some() { "zh" } else { "en" });
assert_eq!(r["shown"], app.language());
assert!(crate::theme::can_show("uk") && crate::theme::can_show("en"));
if font.is_none() {
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("No Chinese font")));
}
}
+4 -37
View File
@@ -8,6 +8,10 @@
Apply Застосувати
Attach DWG... Приєднати DWG...
Bind Вбудувати
Catppuccin Latte Catppuccin Латте
Catppuccin Frappé Catppuccin Фраппе
Catppuccin Macchiato Catppuccin Макіато
Catppuccin Mocha Catppuccin Мока
Detach Від'єднати
Loaded Завантажено
Make it a block of this drawing, keeping its layer names Зробити блоком цього креслення зі збереженням назв шарів
@@ -144,7 +148,6 @@
Closed Замкнений
Close Comparison Закрити порівняння
Coincident Збіг
Collapse Palettes Згорнути палітри
Collapse Tool Sets Згорнути набори інструментів
Collinear Колінеарність
Color Колір
@@ -253,7 +256,6 @@
Drawing Template (DWT) Шаблон креслення (DWT)
Drawing Utilities Утиліти креслення
Drawings (DWG, DXF) Креслення (DWG, DXF)
SVG drawings (SVG, SVGZ) Креслення SVG (SVG, SVGZ)
Dynamic Динамічно
Dynamic Dimensions Динамічні розміри
Dynamic Input Динамічне введення
@@ -438,8 +440,6 @@
Jogged Зі зламом
Jogged Linear Лінійний зі зламом
Join Об'єднати
Join the ArtCraft Discord… Приєднатися до Discord ArtCraft…
Join us on Discord Приєднуйтеся до нас у Discord
Isometric Plane Ізометрична площина
Isometric snap Ізометрична крокова прив'язка
Language Мова
@@ -579,7 +579,6 @@
Offset Зміщення
On Увімкнено
On/Off Увімкнути/вимкнути
One of the Crafting Apps by the ArtCraft team and community. Одна з програм Crafting Apps від команди та спільноти ArtCraft.
Only in this drawing: {n} Лише в цьому кресленні: {n}
Only in {name}: {n} Лише в {name}: {n}
Open (⌘O) Відкрити (⌘O)
@@ -622,7 +621,6 @@
Plot Друк креслення
Plot (click: do not plot) Друкувати (натисніть, щоб вимкнути друк)
Plot Preview Попередній перегляд друку
Plot Window Вікно друку
Plot style Стиль друку
Plot style attached to Стиль друку застосовано до
Plot style table Таблиця стилів друку
@@ -842,8 +840,6 @@
Show All Показати всі
Show All Constraint Bars Показати всі панелі обмежень
Show All Dynamic Dimensions Показати всі динамічні розміри
Show and hide the tools of this category Показати й сховати інструменти цієї категорії
Show in Tool Sets Показувати в наборах інструментів
Show command prompting near the crosshairs Показувати запити команд біля перехрестя
Show Layer List Показати список шарів
Show/Hide Lineweight Показати/приховати товщину ліній
@@ -1204,32 +1200,3 @@
Multiline... Мультилінія...
Multiline Style... Стиль мультилінії...
Solid Editing Редагування тіл
3D Mouse... 3D-миша...
3D Mouse 3D-миша
Calibrate 3D Mouse Калібрувати 3D-мишу
3D Mouse Navigation Навігація за допомогою 3D-миші
3D mouse input is unavailable on this platform Введення з 3D-миші недоступне на цій платформі
No supported 3D mouse found Сумісну 3D-мишу не знайдено
Release the 3D mouse to calibrate its resting position Відпустіть 3D-мишу, щоб відкалібрувати її положення спокою
3D mouse connected 3D-мишу підключено
3D mouse busy, possibly 3DxWare or missing device permissions 3D-миша зайнята, можливо, через 3DxWare або недостатні права доступу до пристрою
3D mouse disconnected 3D-мишу відключено
3D mouse input could not be started Не вдалося запустити введення з 3D-миші
Enable 3D mouse navigation Увімкнути навігацію 3D-мишею
Pan sensitivity Чутливість панорамування
Zoom sensitivity Чутливість масштабування
Reverse zoom direction Змінити напрямок масштабування
Calibrate Калібрувати
Move sideways to pan; push/pull or twist to zoom. Рухайте вбік для панорамування; натискайте, тягніть або обертайте для масштабування.
Left device button: zoom extents. Right device button: calibrate. Ліва кнопка пристрою: масштабування до меж. Права кнопка: калібрування.
More toolbar commands Інші команди панелі інструментів
Default save version Типова версія збереження
Automatic save every Автозбереження кожні
min хв
Automatic save file location Папка файлів автозбереження
Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Креслення, що ще не мають файлу, зберігаються в типовій версії. 0 хвилин вимикає автозбереження.
Time-sensitive right-click: a quick click is Enter, holding opens the menu Клік правою кнопкою з урахуванням часу: швидкий клік — Enter, утримання відкриває меню
Turned off, a right-click while a command runs always opens the shortcut menu. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню.
Hatch Pattern Зразок штрихування
Search for pattern Пошук зразка
Run Script... Виконати сценарій...
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
8
9
10
11
12
13
14
15
16
17
148
149
150
151
152
153
256
257
258
259
260
261
440
441
442
443
444
445
579
580
581
582
583
584
621
622
623
624
625
626
840
841
842
843
844
845
1200
1201
1202
File diff suppressed because it is too large Load Diff
+16 -40
View File
@@ -19,7 +19,6 @@ pub mod control;
pub mod credits;
pub mod dialogs;
pub mod docking;
pub mod spacemouse;
// The External References palette.
pub mod xrefs;
@@ -78,9 +77,6 @@ pub struct UiState {
/// "Drafting" or "Modeling".
pub toolset_tab: String,
pub collapsed_groups: Vec<String>,
/// Tools the user took out of the Tool Sets palette (command ids); each category's gear
/// menu puts them back.
pub hidden_tools: Vec<String>,
pub properties_all: bool,
/// Show an in-window menu bar (platforms without a native menu bar, or on request).
pub in_window_menu: bool,
@@ -154,7 +150,6 @@ impl Default for UiState {
system_cursor: std::env::var("CADCRAFT_SYSTEM_CURSOR").is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false" | "off")),
toolset_tab: "Drafting".into(),
collapsed_groups: Vec::new(),
hidden_tools: Vec::new(),
properties_all: true,
in_window_menu: !cfg!(target_os = "macos"),
start_tab: false,
@@ -182,8 +177,6 @@ pub struct CadApp {
pub options: options::UiOptions,
pub services: Services,
pub canvas: canvas::CanvasState,
/// Desktop HID status and navigation preferences; the web host supplies no HID backend.
pub spacemouse: spacemouse::State,
pub cmd: cmdline::CmdLine,
pub status: Option<(String, f64)>,
pub integrated_titlebar: bool,
@@ -195,12 +188,12 @@ pub struct CadApp {
/// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]).
#[cfg(not(target_arch = "wasm32"))]
pub frame_cap: Option<gpu::FrameCap>,
/// Interface fonts installed, and whether with a CJK font (`None` until the first frame).
fonts_cjk: Option<bool>,
styled: bool,
/// The theme last installed into egui (`None` until the first frame).
shown_theme: Option<egui::Theme>,
/// Whether the last applied theme was handed to egui as the System preference.
shown_follows_system: bool,
shown_flavor: Option<craft_ui::theme::catppuccin::Flavor>,
pub frame_ms: f64,
pub quit_requested: bool,
/// The app may close although drawings have unsaved changes (they were dealt with, or the
@@ -212,10 +205,6 @@ pub struct CadApp {
pub autosave: Option<cadcraft_engine::autosave::Autosaver>,
/// Drawing Recovery: autosave files a crashed app left behind.
pub recovery: autosave::Recovery,
/// GPU errors and device loss reported by wgpu ([`gpu::GpuFaults`]).
pub gpu_faults: std::sync::Arc<gpu::GpuFaults>,
/// The notice shown after one (see [`autosave::gpu_notice`]).
pub gpu_notice: Option<autosave::GpuNotice>,
}
impl CadApp {
@@ -227,7 +216,6 @@ impl CadApp {
options: options::UiOptions::default(),
services,
canvas: canvas::CanvasState::default(),
spacemouse: spacemouse::State::default(),
cmd: cmdline::CmdLine::default(),
status: None,
integrated_titlebar: false,
@@ -238,31 +226,27 @@ impl CadApp {
synthetic: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
frame_cap: None,
fonts_cjk: None,
styled: false,
shown_theme: None,
shown_follows_system: false,
shown_flavor: None,
frame_ms: 0.0,
quit_requested: false,
quit_confirmed: false,
closing: None,
autosave: None,
recovery: autosave::Recovery::default(),
gpu_faults: std::sync::Arc::default(),
gpu_notice: None,
}
}
/// The interface language shown: the chosen one, or English when this computer has no font
/// for it (Chinese without an installed CJK font would be boxes).
pub fn language(&self) -> &'static str {
let l = self.ui.interface_language.resolve(&self.system_languages);
if theme::can_show(l) { l } else { "en" }
self.ui.interface_language.resolve(&self.system_languages)
}
/// Draw the canvas on the GPU with the app's wgpu render state (eframe's
/// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState) {
self.canvas.gpu = Some(gpu::install(rs, &self.gpu_faults));
self.canvas.gpu = Some(gpu::install(rs));
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
@@ -322,24 +306,18 @@ impl CadApp {
/// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]).
pub fn prefs_json(&self) -> String {
let mut prefs = json!({
json!({
"theme": self.ui.theme.as_str(),
"interfaceLanguage": self.ui.interface_language.code(),
"saveFormat": self.ui.save_format.as_str(),
"saveVersion": self.ui.save_version,
});
if self.spacemouse.settings != spacemouse::Settings::default() {
prefs["spacemouse"] = json!(self.spacemouse.settings);
}
prefs.to_string()
})
.to_string()
}
/// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored.
pub fn load_prefs(&mut self, json: &str) {
let Ok(v) = serde_json::from_str::<Value>(json) else { return };
if let Some(prefs) = v.get("spacemouse") {
let _ = self.spacemouse.load_settings(prefs);
}
if let Some(t) = v.get("theme").and_then(Value::as_str).and_then(theme::ThemePref::parse) {
self.ui.theme = t;
}
@@ -414,21 +392,23 @@ impl CadApp {
if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
let cjk = self.language().starts_with("zh");
if self.fonts_cjk != Some(cjk) {
theme::install_fonts(ctx, cjk);
self.fonts_cjk = Some(cjk);
if !self.styled {
theme::install_fonts(ctx);
self.styled = true;
}
// Resolved every frame, so a System choice follows the OS appearance live (winit and the
// browser report changes as input); the saved choice itself never changes here.
let shown = self.ui.theme.resolve(ctx.system_theme());
theme::set_active(shown);
let flavor = self.ui.theme.flavor();
theme::set_flavor(flavor);
theme::set_canvas(self.options.canvas());
let follows_system = self.ui.theme == theme::ThemePref::System;
if self.shown_theme != Some(shown) || self.shown_follows_system != follows_system {
if self.shown_theme != Some(shown) || self.shown_follows_system != follows_system || self.shown_flavor != flavor {
theme::apply(ctx, shown, follows_system);
self.shown_theme = Some(shown);
self.shown_follows_system = follows_system;
self.shown_flavor = flavor;
}
self.drain_control(ctx);
if !self.synthetic.is_empty() {
@@ -436,8 +416,6 @@ impl CadApp {
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
self.gpu_fault_tick(ctx);
managers::plot_window_resume(self, ctx);
self.autosave_tick(ctx);
menus::shortcuts(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
@@ -503,10 +481,8 @@ impl CadApp {
}
});
dialogs::show(self, ui.ctx());
palettes::patterns::prompt_picker(self, ui.ctx());
closing::prompt(self, ui.ctx());
autosave::show(self, ui.ctx());
autosave::gpu_notice(self, ui.ctx());
self.frame_ms = now_ms() - t0;
}
+27 -257
View File
@@ -26,7 +26,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
let dialog = match id {
"battman" => "battman",
"linetype" | "lt" | "ltype" => "linetype",
"ddptype" | "ptype" => "ptype",
"ddptype" => "ptype",
"style" | "st" => "textstyle",
"tablestyle" | "ts" => "tablestyle",
"mleaderstyle" | "mls" => "mleaderstyle",
@@ -484,7 +484,6 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
.or_else(|| layouts.first().map(|l| l.1.clone()))
.unwrap_or_default();
let mut action = None;
let choices = PageChoices::new(ctx, d);
egui::Window::new("Page Setup Manager").open(open).default_size(vec2(560.0, 300.0)).resizable(true).show(ctx, |ui| {
if layouts.is_empty() {
ui.label("This drawing has no layouts.");
@@ -505,7 +504,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
// Layout plotting always prints the whole sheet, so only that area is offered.
let rows = PageRows { areas: &["layout"], scale: true, center: true };
egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| {
if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices) {
if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows) {
action = Some(("pagesetup", json!({ "layout": name, k: v })));
}
});
@@ -525,43 +524,6 @@ struct PageRows {
center: bool,
}
/// The devices and plot style tables the page-setup rows offer.
struct PageChoices {
devices: std::sync::Arc<Vec<cadcraft_doc::PlotterConfig>>,
styles: Vec<String>,
}
impl PageChoices {
fn new(ctx: &egui::Context, d: &cadcraft_doc::Drawing) -> Self {
PageChoices { devices: device_choices(ctx), styles: cadcraft_doc::plot_style_table_names(d) }
}
}
/// The plotter devices (built-in, then the user's files), read again at most every 2 s: the
/// dialogs ask every frame and user devices are files.
fn device_choices(ctx: &egui::Context) -> std::sync::Arc<Vec<cadcraft_doc::PlotterConfig>> {
type Cached = (f64, std::sync::Arc<Vec<cadcraft_doc::PlotterConfig>>);
let now = crate::now_ms();
if let Some((at, list)) = temp::<Cached>(ctx, "plot_devices")
&& now - at < 2000.0
{
return list;
}
let list = std::sync::Arc::new(cadcraft_engine::cmd::plotters::devices());
set_temp::<Cached>(ctx, "plot_devices", (now, list.clone()));
list
}
/// A page setup's device, or `"None"` (plot to PDF on the page's paper).
fn device_label(page: &cadcraft_doc::PageSetup) -> &str {
if page.device.trim().is_empty() { "None" } else { page.device.as_str() }
}
/// A page setup's plot style table, or `"None"`.
fn style_label(page: &cadcraft_doc::PageSetup) -> &str {
if cadcraft_doc::no_plot_style_table(&page.plot_style_table) { "None" } else { page.plot_style_table.as_str() }
}
fn area_label(a: &str) -> &str {
match a {
"layout" => "Layout",
@@ -582,51 +544,17 @@ fn parse_ratio(t: &str) -> Option<f64> {
(v.is_finite() && v > 0.0).then_some(v)
}
/// The page-setup rows (device, paper, orientation, plot area, fit, scale, center, lineweights,
/// plot style table) shared by the Page Setup Manager and the Plot dialog, inside a two-column
/// grid. Returns the setting the user changed as a `pagesetup` parameter (`device`, `paper`,
/// `landscape`, `plotArea`, `scaleToFit`, `scale`, `center`, `lineweights` or `plotStyleTable`).
fn page_rows(
ui: &mut egui::Ui,
salt: egui::Id,
page: &cadcraft_doc::PageSetup,
rows: &PageRows,
choices: &PageChoices,
) -> Option<(&'static str, Value)> {
/// The page-setup rows (paper, orientation, plot area, fit, scale, center, lineweights) shared by
/// the Page Setup Manager and the Plot dialog, inside a two-column grid. Returns the setting the
/// user changed as a `pagesetup` parameter (`paper`, `landscape`, `plotArea`, `scaleToFit`,
/// `scale`, `center` or `lineweights`).
fn page_rows(ui: &mut egui::Ui, salt: egui::Id, page: &cadcraft_doc::PageSetup, rows: &PageRows) -> Option<(&'static str, Value)> {
let mut change = None;
ui.label("Printer/plotter");
egui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| {
if ui.selectable_label(device_label(page) == "None", "None").on_hover_text("PDF on the paper chosen below").clicked()
&& device_label(page) != "None"
{
change = Some(("device", json!("None")));
}
for dev in choices.devices.iter() {
let on = dev.name.eq_ignore_ascii_case(&page.device);
if ui.selectable_label(on, &dev.name).on_hover_text(format!("{} ({})", dev.description, dev.output.as_str().to_uppercase())).clicked()
&& !on
{
change = Some(("device", json!(dev.name)));
}
}
});
ui.end_row();
// The device's papers, else the standard sizes.
let device = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&page.device));
ui.label("Paper size");
egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device {
Some(dev) => {
for p in &dev.papers {
if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper {
change = Some(("paper", json!(p.name)));
}
}
}
None => {
for p in cadcraft_render::PAPER_SIZES {
if ui.selectable_label(p.name == page.paper, p.name).clicked() && p.name != page.paper {
change = Some(("paper", json!(p.name)));
}
egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| {
for p in cadcraft_render::PAPER_SIZES {
if ui.selectable_label(p.name == page.paper, p.name).clicked() && p.name != page.paper {
change = Some(("paper", json!(p.name)));
}
}
});
@@ -683,42 +611,19 @@ fn page_rows(
change = Some(("lineweights", json!(lw)));
}
ui.end_row();
ui.label("Plot style table");
egui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| {
if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" {
change = Some(("plotStyleTable", json!("None")));
}
for name in &choices.styles {
let on = name.eq_ignore_ascii_case(&page.plot_style_table);
if ui.selectable_label(on, name).clicked() && !on {
change = Some(("plotStyleTable", json!(name)));
}
}
});
ui.end_row();
change
}
/// Apply a [`page_rows`] change to a page setup held by a dialog.
/// Device and paper changes follow PAGESETUP's rules (`plotters::page_device`): a device brings
/// its papers, orientation and printable areas.
fn apply_page_change(page: &mut cadcraft_doc::PageSetup, key: &str, v: &Value) {
use cadcraft_engine::cmd::plotters::page_device;
match key {
"device" => {
let _ = page_device("plot", &json!({ "device": v }), page);
}
"paper" => {
// A device's paper, else a standard size (no device).
if !page_device("plot", &json!({ "paper": v }), page).unwrap_or(false)
&& let Some(ps) = v.as_str().and_then(cadcraft_render::paper_size)
{
if let Some(ps) = v.as_str().and_then(cadcraft_render::paper_size) {
page.paper = ps.name.into();
page.width_mm = ps.width_mm;
page.height_mm = ps.height_mm;
}
}
"plotStyleTable" => page.plot_style_table = v.as_str().unwrap_or_default().chars().take(260).collect(),
"plotArea" => page.plot_area = v.as_str().unwrap_or("extents").into(),
"scale" => page.scale = v.as_f64().filter(|s| s.is_finite() && *s > 0.0).unwrap_or(page.scale),
_ => {
@@ -739,31 +644,6 @@ fn apply_page_change(page: &mut cadcraft_doc::PageSetup, key: &str, v: &Value) {
/// Bumped each time PLOT opens its dialog, so it starts again from the page setup.
static PLOT_GEN: AtomicU32 = AtomicU32::new(0);
/// Set while PLOTWINDOW picks the Plot dialog's window ("Window <").
const PICKING_WINDOW: &str = "plot_picking_window";
/// Set by Print... in a preview opened from the Plot dialog: plot as soon as the dialog is back.
pub(crate) const PLOT_NOW: &str = "plot_now";
/// Every frame: once PLOTWINDOW (started by the Plot dialog's "Window <") ends, bring the dialog
/// back with its settings, plotting the picked window (unchanged if the pick was cancelled).
pub fn plot_window_resume(app: &mut CadApp, ctx: &egui::Context) {
if temp::<bool>(ctx, PICKING_WINDOW) != Some(true) || app.session.running.is_some() {
return;
}
set_temp(ctx, PICKING_WINDOW, false);
if let (Some(w), Some(mut st)) = (app.session.plot_window.take(), temp::<PlotState>(ctx, "plot_state")) {
st.window = [w.min.x, w.min.y, w.max.x, w.max.y];
st.page.plot_area = "window".into();
set_temp(ctx, "plot_state", st);
}
app.ui.dialog = Some("plot".into());
}
/// Set a flag the Plot dialog reads (see [`PLOT_NOW`]).
pub(crate) fn set_flag(ctx: &egui::Context, id: &str, on: bool) {
set_temp(ctx, id, on);
}
/// Plot areas for model space (a layout always plots its sheet).
const MODEL_AREAS: &[&str] = &["extents", "display", "limits", "window"];
@@ -797,9 +677,8 @@ fn plot_page(d: &cadcraft_doc::Drawing, target: &str) -> cadcraft_doc::PageSetup
page
}
/// The `plot` parameters for the dialog's settings (without the output path). `preview_dpi`
/// asks for a PNG at that resolution instead of the device's output, on the same sheet.
fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {
/// The `plot` parameters for the dialog's settings (without the output path).
fn plot_params(st: &PlotState) -> Value {
let page = &st.page;
let mut p = json!({
"layout": st.target,
@@ -808,33 +687,11 @@ fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {
"lineweights": page.lineweights,
});
if let Some(o) = p.as_object_mut() {
let device = device_label(page) != "None";
match preview_dpi {
// The page's own sheet, so a device's paper and printable area show as they plot.
Some(dpi) => {
o.insert("device".into(), json!("None"));
o.insert("output".into(), json!("png"));
o.insert("resolution".into(), json!(dpi));
o.insert("width".into(), json!(page.width_mm));
o.insert("height".into(), json!(page.height_mm));
o.insert("margins".into(), json!(page.margins_mm));
}
None if device => {
o.insert("device".into(), json!(page.device));
o.insert("paper".into(), json!(page.paper));
}
None if cadcraft_render::paper_size(&page.paper).is_some() => {
o.insert("device".into(), json!("None"));
o.insert("paper".into(), json!(page.paper));
}
None => {
o.insert("device".into(), json!("None"));
o.insert("width".into(), json!(page.width_mm));
o.insert("height".into(), json!(page.height_mm));
}
}
if style_label(page) != "None" {
o.insert("plotStyleTable".into(), json!(page.plot_style_table));
if cadcraft_render::paper_size(&page.paper).is_some() {
o.insert("paper".into(), json!(page.paper));
} else {
o.insert("width".into(), json!(page.width_mm));
o.insert("height".into(), json!(page.height_mm));
}
if st.target == "Model" {
o.insert("plotArea".into(), json!(page.plot_area));
@@ -875,13 +732,12 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
PlotState { generation, page: plot_page(d, &target), target, window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y] }
});
let model = st.target == "Model";
let choices = PageChoices::new(ctx, d);
// Print... in a preview opened from here plots with these settings.
let mut ok = temp::<bool>(ctx, PLOT_NOW) == Some(true);
set_temp(ctx, PLOT_NOW, false);
let (mut cancel, mut action, mut pick, mut preview) = (false, None, false, false);
let (mut ok, mut cancel, mut action) = (false, false, None);
egui::Window::new("Plot").open(open).collapsible(false).resizable(false).show(ctx, |ui| {
egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Printer/plotter");
ui.label("PDF (vector)").on_hover_text("CADCraft plots to vector PDF files");
ui.end_row();
ui.label("Layout");
egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {
for n in &targets {
@@ -901,7 +757,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
} else {
PageRows { areas: &["layout"], scale: false, center: false }
};
if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices) {
if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows) {
apply_page_change(&mut st.page, k, &v);
}
if model && st.page.plot_area == "window" {
@@ -920,9 +776,6 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
});
ui.end_row();
}
ui.label("");
pick = ui.button("Window <").on_hover_text("Pick the window's corners in the drawing").clicked();
ui.end_row();
}
});
sheet_sketch(ui, &st.page, &t);
@@ -936,42 +789,22 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
json!({ "layout": st.target, "paper": page.paper, "landscape": page.landscape, "scaleToFit": page.scale_to_fit, "lineweights": page.lineweights }),
));
}
preview = ui.button("Preview").on_hover_text("The sheet as these settings plot it").clicked();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
cancel = ui.button("Cancel").clicked();
ok |= ui.button("OK").clicked();
ok = ui.button("OK").clicked();
});
});
});
// The output file type follows the device.
let ext = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&st.page.device)).map_or("pdf", |dv| dv.output.as_str());
set_temp(ctx, "plot_state", st.clone());
if cancel {
*open = false;
}
if pick {
// The dialog comes back with these settings and the picked window (`plot_window_resume`).
set_temp(ctx, PICKING_WINDOW, true);
*open = false;
app.start("plotwindow");
return action;
}
if preview {
// A sheet about 1600 pixels on its long side.
let long_in = st.page.width_mm.max(st.page.height_mm).max(1.0) / 25.4;
let mut info = format!("{} · {}", st.target, st.page.paper);
if st.page.landscape {
info.push_str(" · landscape");
}
crate::exchange::preview_plot(app, ctx, plot_params(&st, Some((1600.0 / long_in).clamp(50.0, 300.0))), info);
return action;
}
if ok {
let mut params = plot_params(&st, None);
let mut params = plot_params(&st);
if let Some(pick) = app.services.pick_save.as_ref() {
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), st.target);
let suggested = format!("{}-{}.pdf", stem.unwrap_or_else(|| "Drawing".into()), st.target);
// Cancelling the file dialog keeps the Plot dialog open with its settings.
let Some(path) = pick(&suggested) else { return action };
if let Some(o) = params.as_object_mut() {
@@ -1077,66 +910,3 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -
});
action
}
#[cfg(test)]
mod plot_dialog_tests {
use super::*;
use cadcraft_engine::Session;
fn state(target: &str) -> PlotState {
let d = cadcraft_doc::Drawing::new_metric();
PlotState { generation: 0, page: plot_page(&d, target), target: target.into(), window: [0.0, 0.0, 10.0, 10.0] }
}
#[test]
fn a_device_brings_its_papers_and_the_plot_names_them() {
let mut st = state("Model");
apply_page_change(&mut st.page, "device", &json!("CADCraft PNG"));
assert_eq!(st.page.device, "CADCraft PNG");
let dev = cadcraft_engine::cmd::plotters::find_device("CADCraft PNG").unwrap();
assert!(dev.papers.iter().any(|p| p.name == st.page.paper), "a paper of the device: {}", st.page.paper);
assert!(st.page.margins_mm.iter().any(|m| *m > 0.0), "the paper's printable area");
apply_page_change(&mut st.page, "plotStyleTable", &json!("monochrome.ctb"));
let p = plot_params(&st, None);
assert_eq!((p["device"].as_str(), p["paper"].as_str()), (Some("CADCraft PNG"), Some(st.page.paper.as_str())));
assert_eq!(p["plotStyleTable"], json!("monochrome.ctb"));
// The preview plots the same sheet to PNG, whatever the device.
let q = plot_params(&st, Some(150.0));
assert_eq!((q["output"].as_str(), q["device"].as_str()), (Some("png"), Some("None")));
assert_eq!(q["width"].as_f64(), Some(st.page.width_mm));
assert_eq!(q["margins"], json!(st.page.margins_mm));
// Back to no device: a standard paper, no device sent.
apply_page_change(&mut st.page, "device", &json!("None"));
apply_page_change(&mut st.page, "paper", &json!("A3"));
let p = plot_params(&st, None);
let a3 = cadcraft_render::paper_size("A3").unwrap().name;
assert_eq!((p["device"].as_str(), p["paper"].as_str()), (Some("None"), Some(a3)));
apply_page_change(&mut st.page, "plotStyleTable", &json!("None"));
assert!(plot_params(&st, None).get("plotStyleTable").is_none());
}
#[test]
fn window_pick_brings_the_dialog_back_with_the_picked_window() {
let ctx = egui::Context::default();
let mut app = CadApp::new(Session::new(), crate::Services::default());
set_temp(&ctx, "plot_state", state("Model"));
set_temp(&ctx, PICKING_WINDOW, true);
app.start("plotwindow");
plot_window_resume(&mut app, &ctx);
assert_eq!(app.ui.dialog, None, "still picking");
app.session.cmdline("2,3").unwrap();
app.session.cmdline("12,9").unwrap();
plot_window_resume(&mut app, &ctx);
assert_eq!(app.ui.dialog.as_deref(), Some("plot"));
let st = temp::<PlotState>(&ctx, "plot_state").unwrap();
assert_eq!((st.page.plot_area.as_str(), st.window), ("window", [2.0, 3.0, 12.0, 9.0]));
// A cancelled pick brings the dialog back unchanged.
app.ui.dialog = None;
set_temp(&ctx, PICKING_WINDOW, true);
app.start("plotwindow");
app.session.input(cadcraft_engine::Input::Cancel).ok();
plot_window_resume(&mut app, &ctx);
assert_eq!(app.ui.dialog.as_deref(), Some("plot"));
assert_eq!(temp::<PlotState>(&ctx, "plot_state").unwrap().window, [2.0, 3.0, 12.0, 9.0]);
}
}
+16 -18
View File
@@ -10,9 +10,6 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools"
/// UI-only commands: (id, label, menu path, shortcut).
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.spacemouse", "3D Mouse...", &["Tools", "3D Mouse..."], None),
("ui.spacemouse.calibrate", "Calibrate 3D Mouse", &[], None),
("ui.spacemouse.navigate", "3D Mouse Navigation", &[], None),
("ui.dock", "Arrange Panel", &[], None),
("ui.dock.reset", "Reset Panel Layout", &["Window", "Reset Panel Layout"], None),
("ui.open", "Open...", &[], Some("Cmd+O")),
@@ -33,6 +30,10 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.theme.system", "Use System Setting", &["View", "Interface Theme", "Use System Setting"], None),
("ui.theme.light", "Light", &["View", "Interface Theme", "Light"], None),
("ui.theme.dark", "Dark", &["View", "Interface Theme", "Dark"], None),
("ui.theme.catppuccinLatte", "Catppuccin Latte", &["View", "Interface Theme", "Catppuccin Latte"], None),
("ui.theme.catppuccinFrappe", "Catppuccin Frappé", &["View", "Interface Theme", "Catppuccin Frappé"], None),
("ui.theme.catppuccinMacchiato", "Catppuccin Macchiato", &["View", "Interface Theme", "Catppuccin Macchiato"], None),
("ui.theme.catppuccinMocha", "Catppuccin Mocha", &["View", "Interface Theme", "Catppuccin Mocha"], None),
("ui.theme", "Interface Theme", &[], None),
("ui.saveformat.dxf", "DXF", &["File", "Default Save Format", "DXF"], None),
("ui.saveformat.dwg", "DWG", &["File", "Default Save Format", "DWG"], None),
@@ -74,9 +75,6 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option<Resu
}
fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if let Some(result) = crate::spacemouse::route(app, id, params) {
return Some(result);
}
if let Some(r) = crate::xrefs::route(app, id, params) {
return Some(r);
}
@@ -114,13 +112,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
return Some(Err(format!("language must be {}", crate::i18n::Preference::codes())));
};
app.ui.interface_language = preference;
let shown = app.language();
if shown != preference.resolve(&app.system_languages) {
app.set_status(
"No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.",
);
}
Ok(json!({"interfaceLanguage": preference.code(), "shown": shown}))
Ok(json!({"interfaceLanguage": preference.code()}))
}
"ui.open" | "open" if no_path => {
let picked = app.services.pick_open.as_ref().and_then(|f| f());
@@ -223,15 +215,21 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
toggle(&mut app.ui.in_window_menu, params);
Ok(Value::Null)
}
"ui.theme.system" | "ui.theme.light" | "ui.theme.dark" => {
"ui.theme.system"
| "ui.theme.light"
| "ui.theme.dark"
| "ui.theme.catppuccinLatte"
| "ui.theme.catppuccinFrappe"
| "ui.theme.catppuccinMacchiato"
| "ui.theme.catppuccinMocha" => {
let pref = crate::theme::ThemePref::parse(id.trim_start_matches("ui.theme.")).unwrap_or_default();
set_theme(app, pref)
}
// `{"theme": "system"|"light"|"dark"}` sets the choice; without it, reports it.
// `{"theme": "system"|"light"|"dark"|"catppuccinLatte"|…}` (see `ThemePref::id_list`) sets the choice; without it, reports it.
"ui.theme" => match params.get("theme").and_then(Value::as_str) {
Some(name) => match crate::theme::ThemePref::parse(name) {
Some(pref) => set_theme(app, pref),
None => Err(format!("unknown theme \"{name}\" (system, light or dark)")),
None => Err(format!("unknown theme \"{name}\" ({})", crate::theme::ThemePref::id_list())),
},
None => Ok(theme_json(app)),
},
@@ -468,7 +466,7 @@ pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option<String
}
}
Entry::Sub { label, children } => {
ui.menu_button(crate::i18n::menu(label), |ui| {
ui.menu_button(crate::i18n::t(label), |ui| {
for c in children {
entry_ui(ui, c, clicked);
}
@@ -486,7 +484,7 @@ pub fn menu_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|ui| {
egui::MenuBar::new().ui(ui, |ui| {
for (name, entries) in &tree {
ui.menu_button(crate::i18n::menu(name), |ui| {
ui.menu_button(crate::i18n::t(name), |ui| {
for e in entries {
entry_ui(ui, e, &mut clicked);
}

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