mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-27 12:27:04 -06:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4a8773c3c | ||
|
|
180c1885bb | ||
|
|
9d32ead9e6 | ||
|
|
2c73e5f063 | ||
|
|
ec762af206 | ||
|
|
786367e9ba | ||
|
|
850dd94f3c | ||
|
|
0ff1b03537 | ||
|
|
d864a08b01 | ||
|
|
c3b8e7b7fe | ||
|
|
b06fd039fb | ||
|
|
98a305514b | ||
|
|
605dc43898 | ||
|
|
c22ad3229f | ||
|
|
7d0d475aee | ||
|
|
48c4a5e9d5 | ||
|
|
57f828d04c |
@@ -0,0 +1,3 @@
|
|||||||
|
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||||
|
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||||
|
nextest-version = "0.9.150"
|
||||||
Generated
+223
-24
@@ -872,6 +872,15 @@ dependencies = [
|
|||||||
"error-code",
|
"error-code",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cobs"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -995,12 +1004,40 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-docpath"
|
name = "craft-docpath"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "craft-fonts-build"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "craft-i18n"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
|
dependencies = [
|
||||||
|
"fixed_decimal",
|
||||||
|
"icu_decimal",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_locale_fallback",
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_plurals",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"icu_provider_adapters",
|
||||||
|
"icu_provider_blob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"sys-locale",
|
||||||
|
"unicode-bidi",
|
||||||
|
"windows-registry",
|
||||||
|
"writeable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-pdf"
|
name = "craft-pdf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hayro-syntax",
|
"hayro-syntax",
|
||||||
"krilla",
|
"krilla",
|
||||||
@@ -1010,20 +1047,28 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-ui"
|
name = "craft-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"craft-fonts-build",
|
||||||
|
"craft-i18n",
|
||||||
"egui",
|
"egui",
|
||||||
|
"epaint",
|
||||||
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.6.5",
|
"objc2 0.6.5",
|
||||||
"objc2-app-kit 0.3.2",
|
"objc2-app-kit 0.3.2",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"serde",
|
"serde",
|
||||||
|
"skrifa 0.44.0",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-update"
|
name = "craft-update"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -1048,7 +1093,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-window"
|
name = "craft-window"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"egui",
|
"egui",
|
||||||
"objc2 0.6.5",
|
"objc2 0.6.5",
|
||||||
@@ -1555,6 +1600,17 @@ version = "0.1.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fixed_decimal"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"smallvec",
|
||||||
|
"writeable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fixedbitset"
|
name = "fixedbitset"
|
||||||
version = "0.5.7"
|
version = "0.5.7"
|
||||||
@@ -1995,7 +2051,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "hayro-jbig2"
|
name = "hayro-jbig2"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hayro-ccitt",
|
"hayro-ccitt",
|
||||||
]
|
]
|
||||||
@@ -2009,7 +2065,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "hayro-syntax"
|
name = "hayro-syntax"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hayro-jbig2",
|
"hayro-jbig2",
|
||||||
"hayro-jpeg2000",
|
"hayro-jpeg2000",
|
||||||
@@ -2074,9 +2130,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.4.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
|
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"potential_utf",
|
"potential_utf",
|
||||||
@@ -2087,43 +2143,110 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_locale_core"
|
name = "icu_decimal"
|
||||||
version = "2.4.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
|
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"fixed_decimal",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_pattern",
|
||||||
|
"icu_plurals",
|
||||||
|
"icu_provider",
|
||||||
|
"serde",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_core"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"litemap",
|
"litemap",
|
||||||
|
"serde",
|
||||||
"tinystr",
|
"tinystr",
|
||||||
"writeable",
|
"writeable",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer"
|
name = "icu_locale_fallback"
|
||||||
version = "2.4.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
|
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||||
|
dependencies = [
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_locale_fallback_data",
|
||||||
|
"icu_provider",
|
||||||
|
"potential_utf",
|
||||||
|
"tinystr",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_fallback_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_normalizer_data",
|
"icu_normalizer_data",
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
"icu_provider",
|
"icu_provider",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"utf16_iter",
|
||||||
|
"utf8_iter",
|
||||||
|
"write16",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer_data"
|
name = "icu_normalizer_data"
|
||||||
version = "2.4.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
|
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_pattern"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"either",
|
||||||
|
"serde",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_plurals"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"fixed_decimal",
|
||||||
|
"icu_provider",
|
||||||
|
"serde",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties"
|
name = "icu_properties"
|
||||||
version = "2.4.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
|
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
@@ -2136,18 +2259,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties_data"
|
name = "icu_properties_data"
|
||||||
version = "2.4.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
|
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_provider"
|
name = "icu_provider"
|
||||||
version = "2.4.0"
|
version = "2.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
|
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
|
"postcard",
|
||||||
|
"serde",
|
||||||
|
"stable_deref_trait",
|
||||||
"writeable",
|
"writeable",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
@@ -2155,6 +2281,30 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider_adapters"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||||
|
dependencies = [
|
||||||
|
"icu_locale_fallback",
|
||||||
|
"icu_provider",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider_blob"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||||
|
dependencies = [
|
||||||
|
"icu_provider",
|
||||||
|
"postcard",
|
||||||
|
"serde",
|
||||||
|
"writeable",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2371,7 +2521,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "krilla"
|
name = "krilla"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
@@ -2469,6 +2619,9 @@ name = "litemap"
|
|||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litrs"
|
name = "litrs"
|
||||||
@@ -3400,12 +3553,24 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postcard"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||||
|
dependencies = [
|
||||||
|
"cobs",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"writeable",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4220,7 +4385,10 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4372,6 +4540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
|
"serde_core",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4614,6 +4783,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf16_iter"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8-zero"
|
name = "utf8-zero"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -5222,6 +5397,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -5462,11 +5648,20 @@ dependencies = [
|
|||||||
"read-fonts 0.39.2",
|
"read-fonts 0.39.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "write16"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11-dl"
|
name = "x11-dl"
|
||||||
@@ -5728,8 +5923,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"serde_core",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5738,6 +5936,7 @@ version = "0.11.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
"zerovec-derive",
|
"zerovec-derive",
|
||||||
|
|||||||
+8
-8
@@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" }
|
|||||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||||
cadcraft-mcp = { path = "crates/mcp" }
|
cadcraft-mcp = { path = "crates/mcp" }
|
||||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||||
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
||||||
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
|
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
|
||||||
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false }
|
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
|
||||||
|
|
||||||
# foundation
|
# foundation
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -53,15 +53,15 @@ encoding_rs = "0.8.35"
|
|||||||
quick-xml = "0.42"
|
quick-xml = "0.42"
|
||||||
|
|
||||||
# ui
|
# ui
|
||||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] }
|
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
|
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||||
egui_kittest = "0.37"
|
egui_kittest = "0.37"
|
||||||
eframe = { version = "0.37", default-features = false }
|
eframe = { version = "0.37", default-features = false }
|
||||||
egui-wgpu = "0.37"
|
egui-wgpu = "0.37"
|
||||||
# Monotonic time on native and web (performance.now on wasm).
|
# Monotonic time on native and web (performance.now on wasm).
|
||||||
web-time = "1.1"
|
web-time = "1.1"
|
||||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
|
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
arboard = { version = "3.6", default-features = false }
|
arboard = { version = "3.6", default-features = false }
|
||||||
|
|
||||||
@@ -91,9 +91,9 @@ debug = 0
|
|||||||
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
|
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
|
||||||
# (Cargo ignores dependency-local patches).
|
# (Cargo ignores dependency-local patches).
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||||
|
|
||||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ allow-unwrap-in-tests = true
|
|||||||
allow-expect-in-tests = true
|
allow-expect-in-tests = true
|
||||||
allow-panic-in-tests = true
|
allow-panic-in-tests = true
|
||||||
allow-indexing-slicing-in-tests = true
|
allow-indexing-slicing-in-tests = true
|
||||||
|
disallowed-types = [
|
||||||
|
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||||
|
]
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ impl BlockEdit {
|
|||||||
}
|
}
|
||||||
let host = &self.host.doc;
|
let host = &self.host.doc;
|
||||||
let mut d = editor.clone();
|
let mut d = editor.clone();
|
||||||
|
// When the block is saved under another name, the edited block keeps the drawing's
|
||||||
|
// description and block `name` gets the description changed in the editor.
|
||||||
|
if !name.eq_ignore_ascii_case(&self.name) {
|
||||||
|
keep_description(&mut d, host, &self.name);
|
||||||
|
}
|
||||||
d.model = host.model.clone();
|
d.model = host.model.clone();
|
||||||
d.layouts = host.layouts.clone();
|
d.layouts = host.layouts.clone();
|
||||||
d.groups = host.groups.clone();
|
d.groups = host.groups.clone();
|
||||||
@@ -171,6 +176,21 @@ impl BlockEdit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
|
||||||
|
/// drop the empty definition a description gives a block the editor started and never saved.
|
||||||
|
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
|
||||||
|
match host.block(name) {
|
||||||
|
Some(h) => {
|
||||||
|
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||||
|
&& b.description != h.description
|
||||||
|
{
|
||||||
|
Arc::make_mut(b).description = h.description.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DocState {
|
impl DocState {
|
||||||
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
|
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
|
||||||
pub fn drawing(&self) -> &Arc<Drawing> {
|
pub fn drawing(&self) -> &Arc<Drawing> {
|
||||||
@@ -272,6 +292,24 @@ impl DocState {
|
|||||||
let d = be.compose(&self.doc, &name)?;
|
let d = be.compose(&self.doc, &name)?;
|
||||||
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
|
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
|
||||||
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
|
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
|
||||||
|
// When BSAVEAS saves the block under another name, the editor's drawing and each undo and
|
||||||
|
// redo step get the block as saved and the edited block as the drawing has it, so a later
|
||||||
|
// save and the description shown start from them after an undo too.
|
||||||
|
if !name.eq_ignore_ascii_case(&be.name)
|
||||||
|
&& let Some(b) = d.block(&name).cloned()
|
||||||
|
{
|
||||||
|
let was = be.name.clone();
|
||||||
|
let reconcile = |doc: &mut Arc<Drawing>| {
|
||||||
|
let editor = Arc::make_mut(doc);
|
||||||
|
keep_description(editor, &d, &was);
|
||||||
|
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
|
||||||
|
editor.blocks.insert(name.clone(), b.clone());
|
||||||
|
};
|
||||||
|
reconcile(&mut self.doc);
|
||||||
|
for step in self.undo.iter_mut().chain(self.redo.iter_mut()) {
|
||||||
|
reconcile(&mut step.doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
let editor = self.doc.clone();
|
let editor = self.doc.clone();
|
||||||
let Some(be) = &mut self.block_edit else { return Ok(()) };
|
let Some(be) = &mut self.block_edit else { return Ok(()) };
|
||||||
let before = be.host.doc.clone();
|
let before = be.host.doc.clone();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
|||||||
|
|
||||||
mod attedit;
|
mod attedit;
|
||||||
mod burst;
|
mod burst;
|
||||||
|
mod description;
|
||||||
mod dyngrip;
|
mod dyngrip;
|
||||||
mod extract;
|
mod extract;
|
||||||
pub(crate) mod refedit;
|
pub(crate) mod refedit;
|
||||||
@@ -28,12 +29,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
CommandSpec::new("block", "Make...", run_block)
|
CommandSpec::new("block", "Make...", run_block)
|
||||||
.menu(&["Draw", "Block", "Make..."])
|
.menu(&["Draw", "Block", "Make..."])
|
||||||
.alias(&["b", "bmake"])
|
.alias(&["b", "bmake"])
|
||||||
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?, annotative?}")
|
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description? (up to 255 characters, one line), annotative?}")
|
||||||
.interactive(|_| Ok(Box::new(BlockM::default()))),
|
.interactive(|_| Ok(Box::new(BlockM::default()))),
|
||||||
// -BLOCK erases the selected objects once the block is defined.
|
// -BLOCK erases the selected objects once the block is defined.
|
||||||
CommandSpec::new("-block", "Create Block", run_dash_block)
|
CommandSpec::new("-block", "Create Block", run_dash_block)
|
||||||
.alias(&["-b"])
|
.alias(&["-b"])
|
||||||
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}")
|
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description? (up to 255 characters, one line)}")
|
||||||
.interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))),
|
.interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))),
|
||||||
CommandSpec::new("insert", "Block...", run_insert)
|
CommandSpec::new("insert", "Block...", run_insert)
|
||||||
.menu(&["Insert", "Block..."])
|
.menu(&["Insert", "Block..."])
|
||||||
@@ -79,6 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
blockreplace::blockreplace_spec(),
|
blockreplace::blockreplace_spec(),
|
||||||
extract::dataextraction_spec(),
|
extract::dataextraction_spec(),
|
||||||
extract::eattext_spec(),
|
extract::eattext_spec(),
|
||||||
|
description::description_spec(),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(attribs::attdisp_menu_specs())
|
.chain(attribs::attdisp_menu_specs())
|
||||||
@@ -266,7 +268,9 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
||||||
let hs = targets(s, p)?;
|
let hs = targets(s, p)?;
|
||||||
let keep = keep_param("block", p, Keep::Convert)?;
|
let keep = keep_param("block", p, Keep::Convert)?;
|
||||||
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
|
let description = str_param(p, "description").unwrap_or("");
|
||||||
|
description::check_text("block", description)?;
|
||||||
|
let r = make_block(s, &name, base, &hs, keep, description)?;
|
||||||
// An annotative block: its references (the converted selection too) are annotative.
|
// An annotative block: its references (the converted selection too) are annotative.
|
||||||
if bool_or(p, "annotative", false) {
|
if bool_or(p, "annotative", false) {
|
||||||
let d = s.doc_mut()?;
|
let d = s.doc_mut()?;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The block a block reference refers to.
|
/// The block a block reference refers to.
|
||||||
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
|
pub(super) fn block_of(d: &Drawing, h: Handle) -> Option<String> {
|
||||||
match d.entity(h).map(|e| &e.kind) {
|
match d.entity(h).map(|e| &e.kind) {
|
||||||
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
|
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -129,7 +129,7 @@ fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The one block reference selected, if exactly one is.
|
/// The one block reference selected, if exactly one is.
|
||||||
fn selected_reference(s: &Session) -> Option<String> {
|
pub(super) fn selected_reference(s: &Session) -> Option<String> {
|
||||||
let d = s.doc().ok()?;
|
let d = s.doc().ok()?;
|
||||||
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
|
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
|
||||||
match refs.as_slice() {
|
match refs.as_slice() {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! Block Description (Modify > Object > Block Description...): changes the description of a
|
||||||
|
//! block definition. In the Block Editor it changes the edited block's description, which BSAVE
|
||||||
|
//! saves into the drawing as it saves the block's objects.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cadcraft_doc::Block;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::bedit::selected_reference;
|
||||||
|
use crate::cmd::{CommandSpec, bad, has_doc, str_param};
|
||||||
|
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||||
|
|
||||||
|
/// The longest description a block takes, in characters.
|
||||||
|
const MAX_LEN: usize = 255;
|
||||||
|
|
||||||
|
pub(super) fn description_spec() -> CommandSpec {
|
||||||
|
CommandSpec::new("blocks.description", "Block Description...", run)
|
||||||
|
.menu(&["Modify", "Object", "Block Description..."])
|
||||||
|
.params("{name? (default the block the Block Editor is editing, else the one selected block reference's block), description (up to 255 characters, one line; \"\" clears it)} → {block, description}")
|
||||||
|
.enabled(has_doc)
|
||||||
|
.interactive(|_| Ok(Box::new(DescriptionM::default())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The block the Block Editor is editing, when it is open on a block (not on the drawing itself).
|
||||||
|
fn edited_block(s: &Session) -> Option<String> {
|
||||||
|
s.state().ok()?.block_editor().filter(|n| !n.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING)).map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The block that a call without a name is about: the edited block, else the selected reference's.
|
||||||
|
fn default_block(s: &Session) -> Option<String> {
|
||||||
|
edited_block(s).or_else(|| selected_reference(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block `name` as the drawing spells it, when its description can be changed: while the Block
|
||||||
|
/// Editor edits a block, only that block's. The block the editor has started and not saved yet has
|
||||||
|
/// no definition, and is named as the editor names it.
|
||||||
|
fn check_block(s: &Session, name: &str) -> Result<String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if let Some(edited) = edited_block(s)
|
||||||
|
&& !edited.eq_ignore_ascii_case(name)
|
||||||
|
{
|
||||||
|
return Err(bad(
|
||||||
|
"blocks.description",
|
||||||
|
format!("The Block Editor is editing \"{edited}\"; close it (BCLOSE) to change another block's description."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let d = s.doc()?;
|
||||||
|
match d.block(name) {
|
||||||
|
Some(b) if b.xref_path.is_some() => {
|
||||||
|
Err(bad("blocks.description", format!("\"{}\" is an external reference; its description comes from its drawing.", b.name)))
|
||||||
|
}
|
||||||
|
Some(b) if b.anonymous || b.name.starts_with('*') => {
|
||||||
|
Err(bad("blocks.description", format!("\"{}\" is an anonymous block made by another object; it has no description to change.", b.name)))
|
||||||
|
}
|
||||||
|
Some(b) => Ok(b.name.clone()),
|
||||||
|
None => match edited_block(s) {
|
||||||
|
Some(e) if e.eq_ignore_ascii_case(name) => Ok(e),
|
||||||
|
_ => Err(bad("blocks.description", format!("Block \"{name}\" not found."))),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description a block can take (command `cmd`'s): one line of up to [`MAX_LEN`] characters.
|
||||||
|
pub(super) fn check_text(cmd: &str, text: &str) -> Result<()> {
|
||||||
|
if text.contains(['\n', '\r']) {
|
||||||
|
return Err(bad(cmd, "the description must be one line"));
|
||||||
|
}
|
||||||
|
if text.chars().count() > MAX_LEN {
|
||||||
|
return Err(bad(cmd, format!("the description can be up to {MAX_LEN} characters")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block `name`'s definition: the drawing's, else, for the block the Block Editor edits, the one
|
||||||
|
/// outside the editor (a block saved under a new name with BSAVEAS is not in the editor's tables).
|
||||||
|
fn definition(s: &Session, name: &str) -> Option<Arc<Block>> {
|
||||||
|
let st = s.state().ok()?;
|
||||||
|
st.doc.block(name).or_else(|| st.block_edit.as_ref().and_then(|be| be.host.doc.block(name))).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The description of block `name` (as [`check_block`] gave it), or "" for one not yet saved.
|
||||||
|
fn current(s: &Session, name: &str) -> String {
|
||||||
|
definition(s, name).map(|b| b.description.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give block `name` (as [`check_block`] gave it) the description `text`.
|
||||||
|
fn set(s: &mut Session, name: &str, text: &str) -> Result<Value> {
|
||||||
|
check_text("blocks.description", text)?;
|
||||||
|
let outside = definition(s, name);
|
||||||
|
let doc = s.doc_mut()?;
|
||||||
|
match doc.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name)) {
|
||||||
|
Some((_, b)) => {
|
||||||
|
if b.description != text {
|
||||||
|
Arc::make_mut(b).description = text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The block the Block Editor edits and the editor's tables don't hold (one it started, or
|
||||||
|
// one BSAVEAS made): BSAVE saves it with this description.
|
||||||
|
None => {
|
||||||
|
let mut b = outside.map(|b| (*b).clone()).unwrap_or_else(|| Block::new(name));
|
||||||
|
b.description = text.to_string();
|
||||||
|
doc.blocks.insert(name.to_string(), Arc::new(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.state()?.block_editor().is_some() {
|
||||||
|
s.echo(format!("Description of block \"{name}\" changed; BSAVE saves it into the drawing."));
|
||||||
|
} else {
|
||||||
|
s.echo(format!("Description of block \"{name}\" changed."));
|
||||||
|
}
|
||||||
|
Ok(json!({ "block": name, "description": text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let name = match str_param(p, "name") {
|
||||||
|
Some(n) => n.to_string(),
|
||||||
|
None => default_block(s)
|
||||||
|
.ok_or_else(|| bad("blocks.description", "`name` is required (or open the block in the Block Editor, or select one block reference)"))?,
|
||||||
|
};
|
||||||
|
let text = str_param(p, "description").ok_or_else(|| bad("blocks.description", "`description` is required"))?;
|
||||||
|
let name = check_block(s, &name)?;
|
||||||
|
set(s, &name, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- interactive ----------------
|
||||||
|
|
||||||
|
/// Block Description: the block (the edited one, the selected reference's, or a name typed or a
|
||||||
|
/// reference picked), then its new description; Enter keeps the description it has.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DescriptionM {
|
||||||
|
/// The block whose description is asked for, once known.
|
||||||
|
block: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interactive for DescriptionM {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"BLOCKS.DESCRIPTION"
|
||||||
|
}
|
||||||
|
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||||
|
if let Some(name) = default_block(s) {
|
||||||
|
self.block = Some(check_block(s, &name)?);
|
||||||
|
}
|
||||||
|
Ok(Step::Continue)
|
||||||
|
}
|
||||||
|
fn prompt(&self, s: &Session) -> Prompt {
|
||||||
|
match &self.block {
|
||||||
|
Some(name) => {
|
||||||
|
let cur = current(s, name);
|
||||||
|
let p = Prompt::new(format!("Enter description for block \"{name}\""), Accept::TEXT);
|
||||||
|
if cur.is_empty() { p } else { p.default(cur) }
|
||||||
|
}
|
||||||
|
None => Prompt::new(
|
||||||
|
"Enter block name or select a block reference",
|
||||||
|
Accept { point: true, number: false, text: true, select: true, enter: true },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||||
|
if let Some(name) = self.block.clone() {
|
||||||
|
return match i {
|
||||||
|
Input::Text(t) => set(s, &name, t.trim()).map(|_| Step::Done),
|
||||||
|
Input::Enter => Ok(Step::Done),
|
||||||
|
_ => Ok(Step::Continue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match i {
|
||||||
|
Input::Text(t) | Input::Keyword(t) => {
|
||||||
|
self.block = Some(check_block(s, &t)?);
|
||||||
|
Ok(Step::Continue)
|
||||||
|
}
|
||||||
|
Input::Pick(hs) => {
|
||||||
|
let picked = {
|
||||||
|
let d = s.doc()?;
|
||||||
|
hs.iter().find_map(|h| super::bedit::block_of(d, *h))
|
||||||
|
};
|
||||||
|
match picked {
|
||||||
|
Some(n) => {
|
||||||
|
self.block = Some(check_block(s, &n)?);
|
||||||
|
Ok(Step::Continue)
|
||||||
|
}
|
||||||
|
None => Err(EngineError::Other("Select a block reference.".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Input::Enter => Ok(Step::Done),
|
||||||
|
_ => Ok(Step::Continue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_p
|
|||||||
use crate::units::{format_angle, format_distance};
|
use crate::units::{format_angle, format_distance};
|
||||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||||
|
|
||||||
|
mod arcs;
|
||||||
mod loops;
|
mod loops;
|
||||||
mod regions;
|
mod regions;
|
||||||
mod shapes;
|
mod shapes;
|
||||||
@@ -418,6 +419,9 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
|
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
|
||||||
object_area(s, h)?
|
object_area(s, h)?
|
||||||
};
|
};
|
||||||
|
if let Some(what) = too_large(a, per) {
|
||||||
|
return Err(bad("area", format!("the {what} is too large")));
|
||||||
|
}
|
||||||
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
|
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +460,17 @@ fn fmt_volume(s: &Session, v: f64) -> String {
|
|||||||
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
|
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "area" or "perimeter" when that value is past f64's range, `None` when both can be reported.
|
||||||
|
fn too_large(a: f64, per: f64) -> Option<&'static str> {
|
||||||
|
if !a.is_finite() {
|
||||||
|
Some("area")
|
||||||
|
} else if !per.is_finite() {
|
||||||
|
Some("perimeter")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Area and perimeter of a closed object (AREA's Object option).
|
/// Area and perimeter of a closed object (AREA's Object option).
|
||||||
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||||
let d = s.doc()?;
|
let d = s.doc()?;
|
||||||
@@ -694,10 +709,16 @@ pub(super) struct DistM {
|
|||||||
sources: Vec<Option<crate::snap::ViewportPoint>>,
|
sources: Vec<Option<crate::snap::ViewportPoint>>,
|
||||||
/// Asking for the length of a segment (the Length option).
|
/// Asking for the length of a segment (the Length option).
|
||||||
length: bool,
|
length: bool,
|
||||||
|
/// The bulge of the segment that starts at each point (the Arc option).
|
||||||
|
bulges: Vec<f64>,
|
||||||
|
arcs: arcs::Arcs,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total length of the segments through `pts`.
|
/// Total length of the segments through `pts`, each with its bulge in `bulges`.
|
||||||
fn path_len(pts: &[Vec2]) -> f64 {
|
fn path_len(pts: &[Vec2], bulges: &[f64]) -> f64 {
|
||||||
|
if arcs::has_arcs(bulges) {
|
||||||
|
return arcs::polyline(pts, bulges, false).len();
|
||||||
|
}
|
||||||
pts.windows(2)
|
pts.windows(2)
|
||||||
.map(|w| match w {
|
.map(|w| match w {
|
||||||
[a, b] => a.dist(*b),
|
[a, b] => a.dist(*b),
|
||||||
@@ -707,15 +728,11 @@ fn path_len(pts: &[Vec2]) -> f64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single
|
/// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single
|
||||||
/// point): the Length option of AREA and DIST's point sequences.
|
/// point, along the tangent after an arc segment): the Length option of AREA and DIST's point
|
||||||
fn extend_last(pts: &[Vec2], len: f64) -> Option<Vec2> {
|
/// sequences.
|
||||||
|
fn extend_last(pts: &[Vec2], bulges: &[f64], len: f64) -> Option<Vec2> {
|
||||||
let last = *pts.last()?;
|
let last = *pts.last()?;
|
||||||
let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) {
|
let p = last + arcs::tangent(pts, bulges) * len;
|
||||||
Some(prev) => (last - *prev).normalized(),
|
|
||||||
None => Vec2::X,
|
|
||||||
};
|
|
||||||
let dir = if dir == Vec2::ZERO { Vec2::X } else { dir };
|
|
||||||
let p = last + dir * len;
|
|
||||||
p.is_finite().then_some(p)
|
p.is_finite().then_some(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,13 +741,21 @@ fn length_prompt() -> Prompt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter).
|
/// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter).
|
||||||
fn length_input(pts: &[Vec2], i: Input) -> Result<Option<Vec2>> {
|
fn length_input(pts: &[Vec2], bulges: &[f64], i: Input) -> Result<Option<Vec2>> {
|
||||||
let t = match i {
|
let t = match i {
|
||||||
Input::Text(t) => t,
|
Input::Text(t) => t,
|
||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
||||||
Ok(extend_last(pts, len))
|
Ok(extend_last(pts, bulges, len))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the paper point `p`, computed near `last` (an arc option's end point), lies in the model
|
||||||
|
/// space of the viewport `last` was picked through (the last of `sources`).
|
||||||
|
fn follow_source(sources: &[Option<crate::snap::ViewportPoint>], last: Vec2, p: Vec2) -> Option<crate::snap::ViewportPoint> {
|
||||||
|
let source = sources.last().copied().flatten()?;
|
||||||
|
let point = source.point + (p - last) / source.scale;
|
||||||
|
(source.scale.abs() > 1e-12 && point.is_finite()).then_some(crate::snap::ViewportPoint { point, ..source })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Model units apply only when every point was snapped through one and the same viewport.
|
/// Model units apply only when every point was snapped through one and the same viewport.
|
||||||
@@ -743,10 +768,13 @@ impl Interactive for DistM {
|
|||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"DIST"
|
"DIST"
|
||||||
}
|
}
|
||||||
fn prompt(&self, _s: &Session) -> Prompt {
|
fn prompt(&self, s: &Session) -> Prompt {
|
||||||
if self.length {
|
if self.length {
|
||||||
return length_prompt();
|
return length_prompt();
|
||||||
}
|
}
|
||||||
|
if let Some(p) = self.multi.as_deref().and_then(|pts| self.arcs.prompt(s, pts, &self.bulges, "")) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
match (&self.multi, self.first) {
|
match (&self.multi, self.first) {
|
||||||
(Some(pts), _) if pts.len() < 2 => {
|
(Some(pts), _) if pts.len() < 2 => {
|
||||||
Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied())
|
Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied())
|
||||||
@@ -763,7 +791,7 @@ impl Interactive for DistM {
|
|||||||
if let Some(pts) = &mut self.multi {
|
if let Some(pts) = &mut self.multi {
|
||||||
if self.length {
|
if self.length {
|
||||||
let model = model_points(&self.sources);
|
let model = model_points(&self.sources);
|
||||||
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), i)? {
|
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), &self.bulges, i)? {
|
||||||
let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten());
|
let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten());
|
||||||
let paper = match (source, pts.last()) {
|
let paper = match (source, pts.last()) {
|
||||||
(Some(source), Some(last)) => *last + (p - source.point) * source.scale,
|
(Some(source), Some(last)) => *last + (p - source.point) * source.scale,
|
||||||
@@ -772,31 +800,64 @@ impl Interactive for DistM {
|
|||||||
if !paper.is_finite() {
|
if !paper.is_finite() {
|
||||||
return Err(bad("dist", "the next point is too large"));
|
return Err(bad("dist", "the next point is too large"));
|
||||||
}
|
}
|
||||||
pts.push(paper);
|
arcs::push(pts, &mut self.bulges, paper, 0.0);
|
||||||
self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source }));
|
self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source }));
|
||||||
let model = model_points(&self.sources);
|
let model = model_points(&self.sources);
|
||||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
|
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
|
||||||
}
|
}
|
||||||
self.length = false;
|
self.length = false;
|
||||||
return Ok(Step::Continue);
|
return Ok(Step::Continue);
|
||||||
}
|
}
|
||||||
|
if self.arcs.asking() {
|
||||||
|
let last = pts.last().copied();
|
||||||
|
// A picked end point keeps its own viewport; an end computed from the answers follows the
|
||||||
|
// last point's. In model units, a typed radius or chord length is in model units too.
|
||||||
|
let picked_end = match i {
|
||||||
|
Input::Point(p) if self.arcs.asks_end_point() => Some(p),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let i = match model_points(&self.sources).and(self.sources.last().copied().flatten()) {
|
||||||
|
Some(source) => self.arcs.scale_typed_size(i, source.scale),
|
||||||
|
None => i,
|
||||||
|
};
|
||||||
|
if self.arcs.answer(s, i, pts, &mut self.bulges)? {
|
||||||
|
// The arc can end off the picked point (CEnter projects it onto the circle).
|
||||||
|
let source = match (picked_end, last, pts.last()) {
|
||||||
|
(Some(p), _, Some(end)) => follow_source(&[s.picked_viewport], p, *end),
|
||||||
|
(None, Some(last), Some(end)) => follow_source(&self.sources, last, *end),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
self.sources.push(source);
|
||||||
|
let model = model_points(&self.sources);
|
||||||
|
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
|
||||||
|
}
|
||||||
|
return Ok(Step::Continue);
|
||||||
|
}
|
||||||
match i {
|
match i {
|
||||||
Input::Point(p) => {
|
Input::Point(p) => {
|
||||||
pts.push(p);
|
self.arcs.point(pts, &mut self.bulges, p)?;
|
||||||
self.sources.push(s.picked_viewport);
|
self.sources.push(s.picked_viewport);
|
||||||
let model = model_points(&self.sources);
|
let model = model_points(&self.sources);
|
||||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
|
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
|
||||||
}
|
}
|
||||||
Input::Keyword(k) if k == "Undo" => {
|
Input::Keyword(k) if k == "Undo" => {
|
||||||
if pts.len() > 1 {
|
if pts.len() > 1 {
|
||||||
pts.pop();
|
self.arcs.undo(pts, &mut self.bulges);
|
||||||
self.sources.pop();
|
self.sources.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Input::Keyword(k) if k == "Length" => self.length = true,
|
Input::Keyword(k) if k == "Length" => self.length = true,
|
||||||
Input::Keyword(k) if k == "Total" => return self.total(s),
|
Input::Keyword(k) if k == "Total" => return self.total(s, false),
|
||||||
Input::Keyword(k) => s.echo(format!("{k}: not available yet")),
|
Input::Keyword(k) if k == "CLose" && self.arcs.on => {
|
||||||
Input::Enter => return self.total(s),
|
self.arcs.close(pts, &mut self.bulges)?;
|
||||||
|
return self.total(s, true);
|
||||||
|
}
|
||||||
|
Input::Keyword(k) => {
|
||||||
|
if !self.arcs.keyword(&k, pts) {
|
||||||
|
s.echo(format!("{k}: not available yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Input::Enter => return self.total(s, false),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
return Ok(Step::Continue);
|
return Ok(Step::Continue);
|
||||||
@@ -830,20 +891,20 @@ impl Interactive for DistM {
|
|||||||
}
|
}
|
||||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||||
match &self.multi {
|
match &self.multi {
|
||||||
Some(pts) => {
|
Some(pts) => self.arcs.preview(pts, &self.bulges, c, false),
|
||||||
let mut v: Vec<cadcraft_geom::PolyVertex> = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
|
|
||||||
v.push(cadcraft_geom::PolyVertex::new(c));
|
|
||||||
vec![super::helpers::lwpoly(v, false)]
|
|
||||||
}
|
|
||||||
None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(),
|
None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DistM {
|
impl DistM {
|
||||||
fn total(&self, s: &mut Session) -> Result<Step> {
|
/// Report the total distance and end; `closed` adds the closing segment (CLose).
|
||||||
|
fn total(&self, s: &mut Session, closed: bool) -> Result<Step> {
|
||||||
let model = model_points(&self.sources);
|
let model = model_points(&self.sources);
|
||||||
let d = model.as_deref().or(self.multi.as_deref()).map_or(0.0, path_len);
|
let d = model
|
||||||
|
.as_deref()
|
||||||
|
.or(self.multi.as_deref())
|
||||||
|
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
|
||||||
s.echo(format!("Distance = {}", fmt_d(s, d)));
|
s.echo(format!("Distance = {}", fmt_d(s, d)));
|
||||||
Ok(Step::Done)
|
Ok(Step::Done)
|
||||||
}
|
}
|
||||||
@@ -936,6 +997,9 @@ pub(super) struct AreaM {
|
|||||||
pending: Vec<f64>,
|
pending: Vec<f64>,
|
||||||
/// The first point of a height given by two points.
|
/// The first point of a height given by two points.
|
||||||
height_from: Option<Vec2>,
|
height_from: Option<Vec2>,
|
||||||
|
/// The bulge of the segment that starts at each point (the Arc option).
|
||||||
|
bulges: Vec<f64>,
|
||||||
|
arcs: arcs::Arcs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AreaM {
|
impl AreaM {
|
||||||
@@ -1039,27 +1103,45 @@ impl AreaM {
|
|||||||
}
|
}
|
||||||
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
|
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
|
||||||
/// In volume mode the area waits for its height instead.
|
/// In volume mode the area waits for its height instead.
|
||||||
|
/// An area, perimeter or total past f64's range is not reported or added to the total.
|
||||||
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
|
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
|
||||||
if self.volume {
|
if self.volume {
|
||||||
|
if !a.is_finite() {
|
||||||
|
s.echo("The area is too large.");
|
||||||
|
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||||
|
}
|
||||||
self.pending.push(a);
|
self.pending.push(a);
|
||||||
return Step::Continue;
|
return Step::Continue;
|
||||||
}
|
}
|
||||||
s.echo(area_text(s, a, per));
|
let total = self.total_with(a);
|
||||||
match self.mode {
|
if let Some(what) = too_large(a, per).or_else(|| (!total.is_finite()).then_some("total area")) {
|
||||||
AreaMode::Single => return Step::Done,
|
s.echo(format!("The {what} is too large."));
|
||||||
AreaMode::Add => self.total += a,
|
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||||
AreaMode::Subtract => self.total -= a,
|
|
||||||
}
|
}
|
||||||
|
s.echo(area_text(s, a, per));
|
||||||
|
if self.mode == AreaMode::Single {
|
||||||
|
return Step::Done;
|
||||||
|
}
|
||||||
|
self.total = total;
|
||||||
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
|
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
|
||||||
Step::Continue
|
Step::Continue
|
||||||
}
|
}
|
||||||
/// Close the boundary picked so far (Enter or Total at the next-point prompt).
|
/// Close the boundary picked so far (Enter or Total at the next-point prompt).
|
||||||
|
/// An arc segment (the Arc option) can close an area of two points.
|
||||||
fn close(&mut self, s: &mut Session) -> Step {
|
fn close(&mut self, s: &mut Session) -> Step {
|
||||||
let pts = std::mem::take(&mut self.pts);
|
let pts = std::mem::take(&mut self.pts);
|
||||||
if pts.len() < 3 {
|
let bulges = std::mem::take(&mut self.bulges);
|
||||||
|
self.arcs = arcs::Arcs::default();
|
||||||
|
let curved = arcs::has_arcs(&bulges);
|
||||||
|
if pts.len() < if curved { 2 } else { 3 } {
|
||||||
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||||
}
|
}
|
||||||
let (a, per) = area_of(&pts);
|
let (a, per) = if curved {
|
||||||
|
let g = arcs::polyline(&pts, &bulges, true);
|
||||||
|
(g.area().abs(), g.len())
|
||||||
|
} else {
|
||||||
|
area_of(&pts)
|
||||||
|
};
|
||||||
self.measured(s, a, per)
|
self.measured(s, a, per)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1068,7 +1150,7 @@ impl Interactive for AreaM {
|
|||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
if self.volume { "MEASUREGEOM" } else { "AREA" }
|
if self.volume { "MEASUREGEOM" } else { "AREA" }
|
||||||
}
|
}
|
||||||
fn prompt(&self, _s: &Session) -> Prompt {
|
fn prompt(&self, s: &Session) -> Prompt {
|
||||||
if !self.pending.is_empty() {
|
if !self.pending.is_empty() {
|
||||||
return match self.height_from {
|
return match self.height_from {
|
||||||
Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
||||||
@@ -1081,6 +1163,9 @@ impl Interactive for AreaM {
|
|||||||
if self.length {
|
if self.length {
|
||||||
return length_prompt();
|
return length_prompt();
|
||||||
}
|
}
|
||||||
|
if let Some(p) = self.arcs.prompt(s, &self.pts, &self.bulges, self.mode_prefix()) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
let next = format!("{}Specify next point", self.mode_prefix());
|
let next = format!("{}Specify next point", self.mode_prefix());
|
||||||
let (add, subtract) = self.mode_keywords();
|
let (add, subtract) = self.mode_keywords();
|
||||||
match (self.pts.len(), self.mode) {
|
match (self.pts.len(), self.mode) {
|
||||||
@@ -1099,24 +1184,34 @@ impl Interactive for AreaM {
|
|||||||
return Ok(self.object_input(s, i));
|
return Ok(self.object_input(s, i));
|
||||||
}
|
}
|
||||||
if self.length {
|
if self.length {
|
||||||
if let Some(p) = length_input(&self.pts, i)? {
|
if let Some(p) = length_input(&self.pts, &self.bulges, i)? {
|
||||||
self.pts.push(p);
|
arcs::push(&mut self.pts, &mut self.bulges, p, 0.0);
|
||||||
}
|
}
|
||||||
self.length = false;
|
self.length = false;
|
||||||
return Ok(Step::Continue);
|
return Ok(Step::Continue);
|
||||||
}
|
}
|
||||||
|
if self.arcs.asking() {
|
||||||
|
self.arcs.answer(s, i, &mut self.pts, &mut self.bulges)?;
|
||||||
|
return Ok(Step::Continue);
|
||||||
|
}
|
||||||
match i {
|
match i {
|
||||||
Input::Point(p) => self.pts.push(p),
|
Input::Point(p) => self.arcs.point(&mut self.pts, &mut self.bulges, p)?,
|
||||||
Input::Keyword(k) => match k.as_str() {
|
Input::Keyword(k) => match k.as_str() {
|
||||||
"Object" => self.object = true,
|
"Object" => self.object = true,
|
||||||
"Add area" | "Add volume" => self.mode = AreaMode::Add,
|
"Add area" | "Add volume" => self.mode = AreaMode::Add,
|
||||||
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
|
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
|
||||||
"Undo" => {
|
"Undo" => self.arcs.undo(&mut self.pts, &mut self.bulges),
|
||||||
self.pts.pop();
|
|
||||||
}
|
|
||||||
"Length" => self.length = true,
|
"Length" => self.length = true,
|
||||||
"Total" => return Ok(self.close(s)),
|
"Total" => return Ok(self.close(s)),
|
||||||
_ => s.echo(format!("{k}: not available yet")),
|
"CLose" if self.arcs.on => {
|
||||||
|
self.arcs.close(&self.pts, &mut self.bulges)?;
|
||||||
|
return Ok(self.close(s));
|
||||||
|
}
|
||||||
|
k => {
|
||||||
|
if !self.arcs.keyword(k, &self.pts) {
|
||||||
|
s.echo(format!("{k}: not available yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Input::Enter if self.pts.is_empty() => match self.mode {
|
Input::Enter if self.pts.is_empty() => match self.mode {
|
||||||
// Enter takes the default, Object; in add/subtract mode it ends the command.
|
// Enter takes the default, Object; in add/subtract mode it ends the command.
|
||||||
@@ -1132,9 +1227,7 @@ impl Interactive for AreaM {
|
|||||||
if self.pts.is_empty() || self.object || self.length {
|
if self.pts.is_empty() || self.object || self.length {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut v: Vec<cadcraft_geom::PolyVertex> = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
|
self.arcs.preview(&self.pts, &self.bulges, c, true)
|
||||||
v.push(cadcraft_geom::PolyVertex::new(c));
|
|
||||||
vec![super::helpers::lwpoly(v, true)]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! The Arc option of AREA's and DIST's point sequences (and of MEASUREGEOM's Distance, Area and
|
||||||
|
//! Volume): arc segments, asked for as PLINE's arc mode asks for them. Each point carries the
|
||||||
|
//! bulge of the segment that starts at it; the last point's bulge is the closing segment's,
|
||||||
|
//! which only CLose sets.
|
||||||
|
|
||||||
|
use cadcraft_doc::EntityKind;
|
||||||
|
use cadcraft_geom::{PolyVertex, Polyline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||||
|
|
||||||
|
use super::super::helpers::lwpoly;
|
||||||
|
use super::super::machines::number;
|
||||||
|
use super::super::pline_opts::{Outcome, PlineAsk};
|
||||||
|
|
||||||
|
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
||||||
|
|
||||||
|
/// Arc mode's keywords: PLINE's arc mode without Halfwidth and Width.
|
||||||
|
const KEYWORDS: [&str; 8] = ["Angle", "CEnter", "CLose", "Direction", "Line", "Radius", "Second pt", "Undo"];
|
||||||
|
|
||||||
|
/// Arc mode of a point sequence.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Arcs {
|
||||||
|
/// The Arc option turned arc mode on; Line turns it off.
|
||||||
|
pub(super) on: bool,
|
||||||
|
/// An arc option waiting for its answers (Angle, CEnter, Direction, Radius, Second pt).
|
||||||
|
ask: Option<PlineAsk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The polyline through `pts`, each segment with its bulge in `bulges` (straight when missing).
|
||||||
|
pub(super) fn polyline(pts: &[Vec2], bulges: &[f64], closed: bool) -> Polyline {
|
||||||
|
let vertices = pts.iter().enumerate().map(|(i, p)| PolyVertex::with_bulge(*p, bulges.get(i).copied().unwrap_or(0.0))).collect();
|
||||||
|
Polyline { vertices, closed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any segment of the sequence is an arc.
|
||||||
|
pub(super) fn has_arcs(bulges: &[f64]) -> bool {
|
||||||
|
bulges.iter().any(|b| *b != 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The direction the sequence runs in at its last point (+X before the second point): along the
|
||||||
|
/// last line segment, or along the tangent at the end of the last arc segment.
|
||||||
|
pub(super) fn tangent(pts: &[Vec2], bulges: &[f64]) -> Vec2 {
|
||||||
|
let Some(i) = pts.len().checked_sub(2) else { return Vec2::X };
|
||||||
|
let (Some(prev), Some(last)) = (pts.get(i).copied(), pts.last().copied()) else { return Vec2::X };
|
||||||
|
let d = match bulge_to_arc(prev, last, bulges.get(i).copied().unwrap_or(0.0)) {
|
||||||
|
Some((a, ccw)) => {
|
||||||
|
let r = (last - a.center).perp().normalized();
|
||||||
|
if ccw { r } else { -r }
|
||||||
|
}
|
||||||
|
None => (last - prev).normalized(),
|
||||||
|
};
|
||||||
|
if d == Vec2::ZERO { Vec2::X } else { d }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bulge of an arc from the last point to `p` that continues the sequence's direction; an
|
||||||
|
/// error when `p` lies straight behind the last point, where no such arc exists.
|
||||||
|
fn tangent_bulge(pts: &[Vec2], bulges: &[f64], p: Vec2) -> Result<f64> {
|
||||||
|
let Some(last) = pts.last().copied() else { return Ok(0.0) };
|
||||||
|
let dir = tangent(pts, bulges);
|
||||||
|
let chord = p - last;
|
||||||
|
// The included angle is twice the angle between the tangent and the chord.
|
||||||
|
let theta = dir.cross(chord).atan2(dir.dot(chord));
|
||||||
|
let b = arc_to_bulge(2.0 * theta);
|
||||||
|
if theta.abs() < std::f64::consts::PI - 1e-6 && b.is_finite() { Ok(b) } else { Err(EngineError::Other("Invalid arc endpoint.".into())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the segment from the last point to `end` with bulge `bulge` (the first point when there is none).
|
||||||
|
pub(super) fn push(pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>, end: Vec2, bulge: f64) {
|
||||||
|
bulges.resize(pts.len(), 0.0);
|
||||||
|
if let Some(b) = bulges.last_mut() {
|
||||||
|
*b = bulge;
|
||||||
|
}
|
||||||
|
pts.push(end);
|
||||||
|
bulges.push(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Arcs {
|
||||||
|
/// An arc option is waiting for its answers.
|
||||||
|
pub(super) fn asking(&self) -> bool {
|
||||||
|
self.ask.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The question being answered takes the arc's end point, so a picked point is the end of the segment.
|
||||||
|
pub(super) fn asks_end_point(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.ask,
|
||||||
|
Some(PlineAsk::AngleEnd(_) | PlineAsk::CenterEnd(_) | PlineAsk::DirectionEnd(_) | PlineAsk::RadiusEnd(_) | PlineAsk::SecondEnd(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed radius or chord length multiplied by `scale`, for a sequence measured in another
|
||||||
|
/// space's units than the one its points are in; any other input as it is.
|
||||||
|
pub(super) fn scale_typed_size(&self, i: Input, scale: f64) -> Input {
|
||||||
|
let size = matches!(self.ask, Some(PlineAsk::Radius | PlineAsk::AngleRadius(_) | PlineAsk::CenterLength(_)));
|
||||||
|
match i {
|
||||||
|
Input::Text(t) if size => match number(&t).map(|v| v * scale).filter(|v| v.is_finite()) {
|
||||||
|
Some(v) => Input::Text(v.to_string()),
|
||||||
|
None => Input::Text(t),
|
||||||
|
},
|
||||||
|
i => i,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The arc-mode prompt, or the question of the arc option being answered; `None` in line mode
|
||||||
|
/// and before the first point.
|
||||||
|
pub(super) fn prompt(&self, s: &Session, pts: &[Vec2], bulges: &[f64], prefix: &str) -> Option<Prompt> {
|
||||||
|
let st = pts.last().copied()?;
|
||||||
|
if let Some(a) = self.ask {
|
||||||
|
return Some(a.prompt(st, tangent(pts, bulges), s));
|
||||||
|
}
|
||||||
|
self.on.then(|| Prompt::new(format!("{prefix}Specify endpoint of arc"), Accept::POINT).kw(&KEYWORDS).base(st))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed an answer to the arc option being answered; true when it added a segment.
|
||||||
|
pub(super) fn answer(&mut self, s: &Session, i: Input, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>) -> Result<bool> {
|
||||||
|
let (Some(a), Some(st)) = (self.ask, pts.last().copied()) else {
|
||||||
|
self.ask = None;
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
match a.input(s, i, st, tangent(pts, bulges))? {
|
||||||
|
Outcome::Ask(next) => self.ask = Some(next),
|
||||||
|
Outcome::Back => self.ask = None,
|
||||||
|
Outcome::Segment(end, bulge) => {
|
||||||
|
self.ask = None;
|
||||||
|
push(pts, bulges, end, bulge);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arc, Line, and arc mode's Angle, CEnter, Direction, Radius and Second pt; false for any
|
||||||
|
/// other keyword.
|
||||||
|
pub(super) fn keyword(&mut self, k: &str, pts: &[Vec2]) -> bool {
|
||||||
|
match k {
|
||||||
|
"Arc" if !pts.is_empty() => self.on = true,
|
||||||
|
"Line" if self.on => self.on = false,
|
||||||
|
_ => match PlineAsk::from_keyword(k, true) {
|
||||||
|
Some(a) if self.on && !pts.is_empty() => self.ask = Some(a),
|
||||||
|
_ => return false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point at the next-point prompt: in arc mode, the end of an arc that continues the
|
||||||
|
/// sequence's direction; otherwise the end of a line segment.
|
||||||
|
pub(super) fn point(&self, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>, p: Vec2) -> Result<()> {
|
||||||
|
let bulge = if self.on && !pts.is_empty() { tangent_bulge(pts, bulges, p)? } else { 0.0 };
|
||||||
|
push(pts, bulges, p, bulge);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CLose: the closing segment becomes an arc from the last point to the first that continues
|
||||||
|
/// the sequence's direction.
|
||||||
|
pub(super) fn close(&self, pts: &[Vec2], bulges: &mut Vec<f64>) -> Result<()> {
|
||||||
|
let Some(first) = pts.first().copied().filter(|_| pts.len() >= 2) else {
|
||||||
|
return Err(EngineError::Other("CLose needs at least two points.".into()));
|
||||||
|
};
|
||||||
|
let bulge = tangent_bulge(pts, bulges, first)?;
|
||||||
|
bulges.resize(pts.len(), 0.0);
|
||||||
|
if let Some(b) = bulges.last_mut() {
|
||||||
|
*b = bulge;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo: remove the last point and the segment to it. Removing the first point leaves arc mode.
|
||||||
|
pub(super) fn undo(&mut self, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>) {
|
||||||
|
pts.pop();
|
||||||
|
bulges.truncate(pts.len());
|
||||||
|
if let Some(b) = bulges.last_mut() {
|
||||||
|
*b = 0.0;
|
||||||
|
}
|
||||||
|
if pts.is_empty() {
|
||||||
|
*self = Arcs::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sequence with the segment to the cursor `c` that a click would add.
|
||||||
|
pub(super) fn preview(&self, pts: &[Vec2], bulges: &[f64], c: Vec2, closed: bool) -> Vec<EntityKind> {
|
||||||
|
let Some(st) = pts.last().copied() else { return Vec::new() };
|
||||||
|
let next = match self.ask {
|
||||||
|
Some(a) => a.preview(st, tangent(pts, bulges), c),
|
||||||
|
None if self.on => tangent_bulge(pts, bulges, c).ok().map(|b| (c, b)),
|
||||||
|
None => Some((c, 0.0)),
|
||||||
|
};
|
||||||
|
let mut g = polyline(pts, bulges, closed);
|
||||||
|
if let Some((end, bulge)) = next {
|
||||||
|
if let Some(v) = g.vertices.last_mut() {
|
||||||
|
v.bulge = bulge;
|
||||||
|
}
|
||||||
|
g.vertices.push(PolyVertex::new(end));
|
||||||
|
}
|
||||||
|
vec![lwpoly(g.vertices, closed)]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy),
|
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy/saveas),
|
||||||
//! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF.
|
//! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF.
|
||||||
//!
|
//!
|
||||||
//! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays
|
//! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays
|
||||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
vec![
|
vec![
|
||||||
CommandSpec::new("layout", "Layout", run_layout)
|
CommandSpec::new("layout", "Layout", run_layout)
|
||||||
.alias(&["lo"])
|
.alias(&["lo"])
|
||||||
.params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)")
|
.params("{option: new|copy|delete|rename|saveas|set|list, name?, to?, path? (saveas)} (typed LAYOUT / -LAYOUT: option prompts)")
|
||||||
.interactive(|_| Ok(Box::new(prompts::LayoutM::default()))),
|
.interactive(|_| Ok(Box::new(prompts::LayoutM::default()))),
|
||||||
CommandSpec::new("layout.new", "New Layout", run_new)
|
CommandSpec::new("layout.new", "New Layout", run_new)
|
||||||
.menu(&["Insert", "Layout", "New Layout"])
|
.menu(&["Insert", "Layout", "New Layout"])
|
||||||
@@ -32,6 +32,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"),
|
CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"),
|
||||||
CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"),
|
CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"),
|
||||||
CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(),
|
CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(),
|
||||||
|
CommandSpec::new("layout.saveas", "Save Layout as Template", saveas::run_saveas)
|
||||||
|
.params("{name?: layout (default: the current layout, else the first tab), path: .dwt (or .dwg, .dxf; no extension adds .dwt)} → {name, path, bytes, notSaved?}")
|
||||||
|
.noundo(),
|
||||||
CommandSpec::new("mview", "Viewports", run_mview)
|
CommandSpec::new("mview", "Viewports", run_mview)
|
||||||
.alias(&["mv"])
|
.alias(&["mv"])
|
||||||
.params(
|
.params(
|
||||||
@@ -334,6 +337,9 @@ fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LAYOUT SAveas: a layout written as a drawing template.
|
||||||
|
mod saveas;
|
||||||
|
|
||||||
/// `-LAYOUT`-style dispatcher.
|
/// `-LAYOUT`-style dispatcher.
|
||||||
fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase();
|
let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase();
|
||||||
@@ -362,8 +368,9 @@ fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?;
|
let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?;
|
||||||
(set.run)(s, p)
|
(set.run)(s, p)
|
||||||
}
|
}
|
||||||
|
"saveas" | "sa" => saveas::run_saveas(s, p),
|
||||||
"list" | "?" => run_list(s, p),
|
"list" | "?" => run_list(s, p),
|
||||||
_ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")),
|
_ => Err(bad("layout", "option must be new, copy, delete, rename, saveas, set or list")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ enum Stage {
|
|||||||
RenameFrom,
|
RenameFrom,
|
||||||
RenameTo(String),
|
RenameTo(String),
|
||||||
Set,
|
Set,
|
||||||
|
SaveasLayout,
|
||||||
|
SaveasFile(String),
|
||||||
List,
|
List,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ pub struct LayoutM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The layout the name prompts default to: the current one, else the first tab.
|
/// The layout the name prompts default to: the current one, else the first tab.
|
||||||
fn current_layout(s: &Session) -> Option<String> {
|
pub(super) fn current_layout(s: &Session) -> Option<String> {
|
||||||
if let Space::Paper(n) = s.layout_space() {
|
if let Space::Paper(n) = s.layout_space() {
|
||||||
return Some(n);
|
return Some(n);
|
||||||
}
|
}
|
||||||
@@ -70,9 +72,10 @@ impl LayoutM {
|
|||||||
"New" => Stage::New,
|
"New" => Stage::New,
|
||||||
"Rename" => Stage::RenameFrom,
|
"Rename" => Stage::RenameFrom,
|
||||||
"Set" => Stage::Set,
|
"Set" => Stage::Set,
|
||||||
|
"SAveas" => Stage::SaveasLayout,
|
||||||
"?" => Stage::List,
|
"?" => Stage::List,
|
||||||
other => {
|
other => {
|
||||||
// Template, SAveas: report and end, leaving the next input to the command line.
|
// Template: report and end, leaving the next input to the command line.
|
||||||
s.echo(format!("LAYOUT {other} is not available yet."));
|
s.echo(format!("LAYOUT {other} is not available yet."));
|
||||||
return Ok(Step::Done);
|
return Ok(Step::Done);
|
||||||
}
|
}
|
||||||
@@ -131,6 +134,8 @@ impl Interactive for LayoutM {
|
|||||||
let tab = s.layout_space().tab_name().to_string();
|
let tab = s.layout_space().tab_name().to_string();
|
||||||
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
|
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
|
||||||
}
|
}
|
||||||
|
Stage::SaveasLayout => with_cur(Prompt::new("Enter layout to save to template", Accept::TEXT)),
|
||||||
|
Stage::SaveasFile(layout) => Prompt::new("Enter template file name", Accept::TEXT).default(super::saveas::default_path(s, layout)),
|
||||||
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
|
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +204,19 @@ impl Interactive for LayoutM {
|
|||||||
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
|
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
|
||||||
Ok(Step::Done)
|
Ok(Step::Done)
|
||||||
}
|
}
|
||||||
|
Stage::SaveasLayout => {
|
||||||
|
let n = need(name.or_else(|| current_layout(s)))?;
|
||||||
|
let n = super::layout_name("layout.saveas", s.doc()?, &n).map_err(retry)?;
|
||||||
|
self.stage = Stage::SaveasFile(n);
|
||||||
|
Ok(Step::Continue)
|
||||||
|
}
|
||||||
|
Stage::SaveasFile(layout) => {
|
||||||
|
let path = name.unwrap_or_else(|| super::saveas::default_path(s, &layout));
|
||||||
|
let r = super::saveas::run_saveas(s, &json!({ "name": layout, "path": path })).map_err(retry)?;
|
||||||
|
let path = r.get("path").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
s.echo(format!("Layout \"{layout}\" saved to template \"{path}\"."));
|
||||||
|
Ok(Step::Done)
|
||||||
|
}
|
||||||
Stage::List => {
|
Stage::List => {
|
||||||
Self::list(s, name.as_deref().unwrap_or("*"))?;
|
Self::list(s, name.as_deref().unwrap_or("*"))?;
|
||||||
Ok(Step::Done)
|
Ok(Step::Done)
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! LAYOUT SAveas: a layout written to a drawing template file (`.dwt`, or a `.dwg` or `.dxf`).
|
||||||
|
//!
|
||||||
|
//! The template holds the layout's page setup and paper space objects, viewports included, and
|
||||||
|
//! no model space objects and no other layout. Named objects the layout does not use (blocks,
|
||||||
|
//! layers, linetypes, text, dimension, multileader, multiline and table styles, empty groups)
|
||||||
|
//! are left out, as PURGE All removes them; layers the layout's viewports freeze or recolor stay.
|
||||||
|
//! The open drawing is not changed.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, EntityStore, Point};
|
||||||
|
use cadcraft_geom::Vec3;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::super::file::{drops_unknown, io, not_saved, not_saved_message};
|
||||||
|
use super::super::{bad, str_param};
|
||||||
|
use super::layout_name;
|
||||||
|
use super::prompts::current_layout;
|
||||||
|
use crate::{Result, Session};
|
||||||
|
|
||||||
|
/// File types a layout template is written as; a path without an extension gets `.dwt`.
|
||||||
|
const TEMPLATE_TYPES: [&str; 3] = ["dwt", "dwg", "dxf"];
|
||||||
|
|
||||||
|
/// The most PURGE All passes run on a template; they stop at the first pass that purges nothing.
|
||||||
|
const MAX_PURGE_PASSES: usize = 64;
|
||||||
|
|
||||||
|
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
|
||||||
|
pub(super) fn default_path(s: &Session, layout: &str) -> String {
|
||||||
|
let file = format!("{layout}.dwt");
|
||||||
|
match s.state().ok().and_then(|st| st.path.clone()) {
|
||||||
|
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
|
||||||
|
None => file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
|
||||||
|
fn template_path(path: &str) -> Result<String> {
|
||||||
|
let path = path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err(bad("layout.saveas", "`path` is empty"));
|
||||||
|
}
|
||||||
|
match std::path::Path::new(path).extension() {
|
||||||
|
None => Ok(format!("{path}.dwt")),
|
||||||
|
Some(e) if TEMPLATE_TYPES.iter().any(|t| e.eq_ignore_ascii_case(t)) => Ok(path.to_string()),
|
||||||
|
Some(_) => Err(bad("layout.saveas", "a layout template is saved as a .dwt, .dwg or .dxf file")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The drawing a template of `layout` holds: `d` with that layout alone, an empty model space,
|
||||||
|
/// and without the named objects nothing left in it uses.
|
||||||
|
fn template_of(d: &Drawing, layout: &str) -> Result<Drawing> {
|
||||||
|
let mut t = d.clone();
|
||||||
|
t.layouts.retain(|l| l.name == layout);
|
||||||
|
t.active_layout = Some(layout.to_string());
|
||||||
|
t.model = EntityStore::new();
|
||||||
|
t.hidden_objects.clear();
|
||||||
|
let gone: Vec<u32> = t.constraints.iter().filter(|c| c.refs.iter().any(|r| t.entity(r.handle).is_none())).map(|c| c.id).collect();
|
||||||
|
t.constraints.retain(|c| !gone.contains(&c.id));
|
||||||
|
// Layers the layout's viewports freeze or recolor stay, with those settings. PURGE counts
|
||||||
|
// only objects, so each such layer gets a point in model space while PURGE runs.
|
||||||
|
let named: BTreeSet<String> = t
|
||||||
|
.layouts
|
||||||
|
.iter()
|
||||||
|
.flat_map(|l| l.entities.iter())
|
||||||
|
.filter_map(|e| match &e.kind {
|
||||||
|
EntityKind::Viewport(v) => Some(v.frozen_layers.iter().cloned().chain(v.layer_colors.iter().map(|(n, _)| n.clone())).collect::<Vec<_>>()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
for layer in named {
|
||||||
|
let handle = t.new_handle();
|
||||||
|
t.model.push(Entity { handle, common: Common { layer, ..Common::default() }, kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) });
|
||||||
|
}
|
||||||
|
// PURGE runs on a session of its own, so the open drawing keeps everything.
|
||||||
|
let mut scratch = Session::empty();
|
||||||
|
scratch.open_drawing(t, layout, None);
|
||||||
|
for _ in 0..MAX_PURGE_PASSES {
|
||||||
|
let r = super::super::blocks::purge::run(&mut scratch, &json!({ "type": "all" }))?;
|
||||||
|
let purged = r.get("purged").and_then(Value::as_object).is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty())));
|
||||||
|
if !purged {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let st = scratch.docs.pop().ok_or_else(|| bad("layout.saveas", "the template could not be made"))?;
|
||||||
|
let mut t = std::sync::Arc::unwrap_or_clone(st.doc);
|
||||||
|
t.model = EntityStore::new();
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `layout.saveas {name?, path}`: write a layout as a drawing template.
|
||||||
|
pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let path = template_path(str_param(p, "path").ok_or_else(|| bad("layout.saveas", "`path` is required"))?)?;
|
||||||
|
if s.doc()?.refedit.is_some() {
|
||||||
|
return Err(bad("layout.saveas", "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
|
||||||
|
}
|
||||||
|
let name = match str_param(p, "name") {
|
||||||
|
Some(n) => n.to_string(),
|
||||||
|
None => current_layout(s).ok_or_else(|| bad("layout.saveas", "the drawing has no layout"))?,
|
||||||
|
};
|
||||||
|
// A file is the drawing outside the Block Editor.
|
||||||
|
let d = s.state()?.drawing();
|
||||||
|
let name = layout_name("layout.saveas", d, &name)?;
|
||||||
|
let t = template_of(d, &name)?;
|
||||||
|
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
|
||||||
|
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
|
||||||
|
store(&path, &bytes)?;
|
||||||
|
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
|
||||||
|
let lost = not_saved(&t);
|
||||||
|
if drops_unknown(&path)
|
||||||
|
&& let Some(msg) = not_saved_message(&lost)
|
||||||
|
{
|
||||||
|
s.echo(msg);
|
||||||
|
if let Some(o) = r.as_object_mut() {
|
||||||
|
o.insert("notSaved".into(), json!(lost));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the template: natively to `path` through a temporary file; on the web as a download.
|
||||||
|
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||||
|
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
|
||||||
|
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,12 @@
|
|||||||
//! tiles is an undo step. The session keeps each tile's view ([`crate::ModelTiles`]); the current
|
//! tiles is an undo step. The session keeps each tile's view ([`crate::ModelTiles`]); the current
|
||||||
//! tile shows the model space view itself, so ZOOM, PAN and every pick work in it unchanged. The
|
//! tile shows the model space view itself, so ZOOM, PAN and every pick work in it unchanged. The
|
||||||
//! UI draws the tiles from [`Session::model_tiles`] and hands clicks in other tiles to
|
//! UI draws the tiles from [`Session::model_tiles`] and hands clicks in other tiles to
|
||||||
//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW.
|
//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. View > Viewports > Named Viewports...
|
||||||
|
//! restores or deletes a saved configuration.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use cadcraft_doc::{Drawing, Space, VportConfig, VportTile};
|
use cadcraft_doc::{Drawing, EntityKind, Space, VportConfig, VportTile};
|
||||||
use cadcraft_geom::Vec2;
|
use cadcraft_geom::Vec2;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -37,6 +38,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
)
|
)
|
||||||
.interactive(vports_factory),
|
.interactive(vports_factory),
|
||||||
CommandSpec::new("vports.activate", "Set Current Viewport", run_activate).params("{index: tile number from 1}").noundo(),
|
CommandSpec::new("vports.activate", "Set Current Viewport", run_activate).params("{index: tile number from 1}").noundo(),
|
||||||
|
CommandSpec::new("vports.named", "Named Viewports...", run_named)
|
||||||
|
.menu(&["View", "Viewports", "Named Viewports..."])
|
||||||
|
.params(
|
||||||
|
"{name?: a saved viewport configuration, delete?: bool, p1?, p2?, layout?} (no name: → {current, configurations: [{name, viewports}]}; the Model tab shows the configuration's tiles, a layout gets one viewport per tile over p1-p2 or the printable area; typed or from the menu: the Named Viewports dialog)",
|
||||||
|
)
|
||||||
|
.interactive(named_factory),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +395,59 @@ fn run_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
summary(s)
|
summary(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// View > Viewports > Named Viewports...: without a name, the saved configurations and the one
|
||||||
|
/// the Model tab shows; with one, restore it (its tiles in the Model tab, MVIEW Restore in a
|
||||||
|
/// layout) or, with `delete`, delete it.
|
||||||
|
fn run_named(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let delete = match p.get("delete") {
|
||||||
|
None | Some(Value::Null) => false,
|
||||||
|
Some(Value::Bool(b)) => *b,
|
||||||
|
Some(_) => return Err(bad("vports.named", "`delete` must be true or false")),
|
||||||
|
};
|
||||||
|
let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) else {
|
||||||
|
let configs: Vec<Value> =
|
||||||
|
s.doc()?.vports.iter().filter(|c| !c.is_active()).map(|c| json!({ "name": c.name, "viewports": c.tiles.len() })).collect();
|
||||||
|
return Ok(json!({ "current": s.viewport_configuration(), "configurations": configs }));
|
||||||
|
};
|
||||||
|
let saved = s
|
||||||
|
.doc()?
|
||||||
|
.vports
|
||||||
|
.iter()
|
||||||
|
.find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name))
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::<String>())))?;
|
||||||
|
if delete {
|
||||||
|
s.doc_mut()?.vports.retain(|c| c.is_active() || c.name != saved);
|
||||||
|
return Ok(json!({ "deleted": saved, "message": format!("Viewport configuration \"{saved}\" deleted.") }));
|
||||||
|
}
|
||||||
|
if s.layout_space() != Space::Model || p.get("layout").is_some() {
|
||||||
|
let mview = find_command("mview").ok_or_else(|| EngineError::UnknownCommand("mview".into()))?;
|
||||||
|
let mut p = p.clone();
|
||||||
|
if let Some(o) = p.as_object_mut() {
|
||||||
|
o.remove("delete");
|
||||||
|
o.insert("option".into(), json!("restore"));
|
||||||
|
o.insert("name".into(), json!(saved));
|
||||||
|
}
|
||||||
|
return (mview.run)(s, &p);
|
||||||
|
}
|
||||||
|
run_vports(s, &json!({ "option": "restore", "name": saved }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two configurations have their tiles in the same places (in any order).
|
||||||
|
fn same_places(a: &[VportTile], b: &[VportTile]) -> bool {
|
||||||
|
let near = |p: Vec2, q: Vec2| (p.x - q.x).abs() < EPS && (p.y - q.y).abs() < EPS;
|
||||||
|
a.len() == b.len() && a.iter().all(|t| b.iter().any(|u| near(t.min, u.min) && near(t.max, u.max)))
|
||||||
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
|
/// The saved viewport configuration whose tiles are where the Model tab's tiles are now (the
|
||||||
|
/// first such one in the drawing), if any.
|
||||||
|
pub fn viewport_configuration(&self) -> Option<String> {
|
||||||
|
let st = self.state().ok()?;
|
||||||
|
let (tiles, _) = live(st, self.viewport_px);
|
||||||
|
st.doc.vports.iter().filter(|c| !c.is_active()).find(|c| same_places(&c.tiles, &tiles)).map(|c| c.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// The model space tiles to draw (empty: one view, or not in the Model tab).
|
/// The model space tiles to draw (empty: one view, or not in the Model tab).
|
||||||
pub fn model_tiles(&self) -> Vec<ModelTile> {
|
pub fn model_tiles(&self) -> Vec<ModelTile> {
|
||||||
let Ok(st) = self.state() else { return Vec::new() };
|
let Ok(st) = self.state() else { return Vec::new() };
|
||||||
@@ -598,3 +657,33 @@ impl VportsM {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Named Viewports... without the dialog: -VPORTS Restore in the Model tab, MVIEW Restore in a
|
||||||
|
/// layout.
|
||||||
|
fn named_factory(s: &Session) -> Result<Box<dyn Interactive>> {
|
||||||
|
Ok(Box::new(Restoring(vports_factory(s)?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// -VPORTS or MVIEW started at its Restore option.
|
||||||
|
struct Restoring(Box<dyn Interactive>);
|
||||||
|
|
||||||
|
impl Interactive for Restoring {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
self.0.name()
|
||||||
|
}
|
||||||
|
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||||
|
match self.0.begin(s)? {
|
||||||
|
Step::Continue => self.0.input(s, Input::Keyword("Restore".into())),
|
||||||
|
other => Ok(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn prompt(&self, s: &Session) -> Prompt {
|
||||||
|
self.0.prompt(s)
|
||||||
|
}
|
||||||
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||||
|
self.0.input(s, i)
|
||||||
|
}
|
||||||
|
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||||
|
self.0.preview(s, cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! Block Description (blocks.description): a block's description changes in the drawing, and in
|
||||||
|
//! the Block Editor BSAVE saves the edited block's new description.
|
||||||
|
|
||||||
|
use cadcraft_engine::Session;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// A drawing with block "Door" (one line, described "Hinged door") and one reference to it, which
|
||||||
|
/// is returned.
|
||||||
|
fn door(s: &mut Session) -> Value {
|
||||||
|
door_with(s, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn door_with(s: &mut Session, annotative: bool) -> Value {
|
||||||
|
let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone();
|
||||||
|
let p = json!({ "name": "Door", "base": [10, 10], "handles": [l], "description": "Hinged door", "annotative": annotative });
|
||||||
|
s.execute("block", &p).unwrap()["insert"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(s: &Session, block: &str) -> Option<String> {
|
||||||
|
s.doc().unwrap().block(block).map(|b| b.description.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The description of `block` in the drawing outside the Block Editor.
|
||||||
|
fn saved_description(s: &Session, block: &str) -> Option<String> {
|
||||||
|
let st = s.state().unwrap();
|
||||||
|
let d = st.block_edit.as_ref().map_or(&st.doc, |be| &be.host.doc);
|
||||||
|
d.block(block).map(|b| b.description.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_description_of_a_block_changes_and_undo_puts_it_back() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
door(&mut s);
|
||||||
|
let r = s.execute("blocks.description", &json!({ "name": "door", "description": "Sliding door" })).unwrap();
|
||||||
|
assert_eq!(r, json!({ "block": "Door", "description": "Sliding door" }));
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door"));
|
||||||
|
s.execute("undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
|
||||||
|
// An empty description clears it; 255 characters are the most a description takes.
|
||||||
|
s.execute("blocks.description", &json!({ "name": "Door", "description": "" })).unwrap();
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some(""));
|
||||||
|
let longest = "é".repeat(255);
|
||||||
|
s.execute("blocks.description", &json!({ "name": "Door", "description": longest })).unwrap();
|
||||||
|
assert_eq!(description(&s, "Door"), Some(longest));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_block_description_refuses() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
door(&mut s);
|
||||||
|
for p in [
|
||||||
|
json!({ "name": "Door", "description": "x".repeat(256) }),
|
||||||
|
json!({ "name": "Door", "description": "two\nlines" }),
|
||||||
|
json!({ "name": "Door", "description": "carriage\rreturn" }),
|
||||||
|
json!({ "name": "Window", "description": "x" }),
|
||||||
|
json!({ "name": "Door" }),
|
||||||
|
json!({ "name": "Door", "description": 7 }),
|
||||||
|
json!({ "description": "nothing selected" }),
|
||||||
|
] {
|
||||||
|
assert!(s.execute("blocks.description", &p).is_err(), "{p}");
|
||||||
|
}
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_refuses_a_description_a_block_cannot_take() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
for cmd in ["block", "-block"] {
|
||||||
|
for description in ["x".repeat(256), "two\nlines".into()] {
|
||||||
|
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
|
||||||
|
let p = json!({ "name": "B", "base": [0, 0], "handles": [l], "description": description });
|
||||||
|
assert!(s.execute(cmd, &p).is_err(), "{cmd} {description:?}");
|
||||||
|
assert!(s.doc().unwrap().block("B").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (cmd, name) in [("block", "B"), ("-block", "C")] {
|
||||||
|
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
|
||||||
|
s.execute(cmd, &json!({ "name": name, "base": [0, 0], "handles": [l], "description": "x".repeat(255) })).unwrap();
|
||||||
|
assert_eq!(description(&s, name), Some("x".repeat(255)), "{cmd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_selected_reference_names_the_block_at_the_prompts() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
let r = door(&mut s);
|
||||||
|
s.execute("select", &json!({ "handles": [r] })).unwrap();
|
||||||
|
s.cmdline("blocks.description").unwrap();
|
||||||
|
assert!(s.prompt_text().contains("Enter description for block \"Door\" <Hinged door>"), "{}", s.prompt_text());
|
||||||
|
// Enter keeps the description.
|
||||||
|
s.cmdline("").unwrap();
|
||||||
|
assert!(s.running.is_none());
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
|
||||||
|
s.execute("select", &json!({ "handles": [] })).unwrap();
|
||||||
|
s.script("blocks.description\nDoor\nSliding door\n").unwrap();
|
||||||
|
assert!(s.running.is_none());
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_block_editor_saves_the_new_description_with_the_block() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
door(&mut s);
|
||||||
|
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
|
||||||
|
assert!(s.state().unwrap().block_unsaved());
|
||||||
|
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
s.execute("bsave", &json!({})).unwrap();
|
||||||
|
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Sliding door"));
|
||||||
|
|
||||||
|
// Closing without saving leaves the drawing's description as it was.
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap();
|
||||||
|
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bsaveas_gives_the_new_description_to_the_new_block_only() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
door_with(&mut s, true);
|
||||||
|
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
|
||||||
|
s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap();
|
||||||
|
assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door"));
|
||||||
|
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
|
||||||
|
// The editor now edits the new block; saving again leaves the old block's description alone
|
||||||
|
// and keeps the new block's other properties.
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Sliding door, 900" })).unwrap();
|
||||||
|
s.execute("bclose", &json!({})).unwrap();
|
||||||
|
assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door, 900"));
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
assert!(s.doc().unwrap().block("Slider").unwrap().annotative);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bsaveas_over_another_block_keeps_the_saved_description() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
door(&mut s);
|
||||||
|
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
|
||||||
|
s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l], "description": "Casement" })).unwrap();
|
||||||
|
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
|
||||||
|
s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap();
|
||||||
|
assert_eq!(saved_description(&s, "Window").as_deref(), Some("Sliding door"));
|
||||||
|
// The prompt shows the saved description, and a later save keeps it.
|
||||||
|
s.cmdline("blocks.description").unwrap();
|
||||||
|
assert!(s.prompt_text().contains("<Sliding door>"), "{}", s.prompt_text());
|
||||||
|
s.cmdline("").unwrap();
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [0, 1]] })).unwrap();
|
||||||
|
s.execute("bclose", &json!({})).unwrap();
|
||||||
|
assert_eq!(description(&s, "Window").as_deref(), Some("Sliding door"));
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
|
||||||
|
// An undo in the editor after saving over Window leaves Window's new description.
|
||||||
|
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap();
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [0, 2]] })).unwrap();
|
||||||
|
s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap();
|
||||||
|
s.execute("undo", &json!({})).unwrap();
|
||||||
|
s.execute("bsave", &json!({})).unwrap();
|
||||||
|
s.execute("bclose", &json!({})).unwrap();
|
||||||
|
assert_eq!(description(&s, "Window").as_deref(), Some("Pocket door"));
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_after_bsaveas_does_not_give_the_old_block_the_new_description() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
door(&mut s);
|
||||||
|
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [0, 3]] })).unwrap();
|
||||||
|
s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap();
|
||||||
|
// Undo goes back past the line to the editor's drawing as it was before BSAVEAS.
|
||||||
|
s.execute("undo", &json!({})).unwrap();
|
||||||
|
s.execute("bsave", &json!({})).unwrap();
|
||||||
|
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door"));
|
||||||
|
|
||||||
|
// In the editor, only the edited block's description changes.
|
||||||
|
assert!(s.execute("blocks.description", &json!({ "name": "Door", "description": "x" })).is_err());
|
||||||
|
s.execute("bclose", &json!({})).unwrap();
|
||||||
|
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
|
||||||
|
assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_block_in_the_block_editor_takes_its_description() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("bedit", &json!({ "name": "Table", "create": true })).unwrap();
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap();
|
||||||
|
s.execute("blocks.description", &json!({ "description": "Desk" })).unwrap();
|
||||||
|
assert_eq!(saved_description(&s, "Table"), None);
|
||||||
|
|
||||||
|
// Saved under another name first, the started block is not created empty.
|
||||||
|
s.execute("bsaveas", &json!({ "name": "Desk" })).unwrap();
|
||||||
|
assert_eq!(saved_description(&s, "Desk").as_deref(), Some("Desk"));
|
||||||
|
assert_eq!(saved_description(&s, "Table"), None);
|
||||||
|
s.execute("bclose", &json!({})).unwrap();
|
||||||
|
assert!(s.doc().unwrap().block("Table").is_none());
|
||||||
|
|
||||||
|
s.execute("bedit", &json!({ "name": "Chair", "create": true })).unwrap();
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap();
|
||||||
|
s.execute("blocks.description", &json!({ "name": "chair", "description": "Office chair" })).unwrap();
|
||||||
|
s.execute("bclose", &json!({})).unwrap();
|
||||||
|
assert_eq!(description(&s, "Chair").as_deref(), Some("Office chair"));
|
||||||
|
assert_eq!(s.doc().unwrap().block("Chair").unwrap().entities.len(), 1);
|
||||||
|
}
|
||||||
@@ -34,6 +34,45 @@ fn area_object_add_and_subtract_keep_a_running_total() {
|
|||||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn area_too_large_to_hold_is_refused() {
|
||||||
|
// A 2e154 square has an area of 4e308, past f64's range; its perimeter fits.
|
||||||
|
let big = json!([[0, 0], [2e154, 0], [2e154, 2e154], [0, 2e154]]);
|
||||||
|
let mut s = Session::new();
|
||||||
|
let err = s.execute("area", &json!({ "points": big })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("the area is too large"), "{err}");
|
||||||
|
// Two sides of 1e308: the area fits, the perimeter does not.
|
||||||
|
let long = json!([[0, 0], [1e308, 0], [1e308, 1e-300]]);
|
||||||
|
let err = s.execute("area", &json!({ "points": long })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("the perimeter is too large"), "{err}");
|
||||||
|
|
||||||
|
// Typed, nothing is reported.
|
||||||
|
let (_, log) = run(&["area", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""]);
|
||||||
|
assert!(has(&log, "The area is too large."), "{log:?}");
|
||||||
|
assert!(!has(&log, "Area = "), "{log:?}");
|
||||||
|
|
||||||
|
// Areas of 8.1e307 that each fit overflow the running total at the third; the total keeps two.
|
||||||
|
let sq = ["0,0", "9e153,0", "9e153,9e153", "0,9e153", ""];
|
||||||
|
for mode in ["a", "s"] {
|
||||||
|
let mut lines = vec!["area", mode];
|
||||||
|
for _ in 0..3 {
|
||||||
|
lines.extend(sq);
|
||||||
|
}
|
||||||
|
let (s, log) = run(&lines);
|
||||||
|
assert!(has(&log, "The total area is too large."), "{mode}: {log:?}");
|
||||||
|
assert_eq!(log.iter().filter(|l| l.contains("Total area = ")).count(), 2, "{mode}: {log:?}");
|
||||||
|
assert!(s.running.is_some(), "{mode}: the command goes on");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MEASUREGEOM's Volume option does not keep such an area waiting for a height: the next area is measured.
|
||||||
|
let mut lines = vec!["measuregeom", "v", "a", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""];
|
||||||
|
lines.extend(["0,0", "1,0", "1,1", "0,1", "", "2"]);
|
||||||
|
let (s, log) = run(&lines);
|
||||||
|
assert!(has(&log, "The area is too large."), "{log:?}");
|
||||||
|
assert!(has(&log, "Total volume = 2"), "{log:?}");
|
||||||
|
assert!(s.running.is_some(), "the command goes on");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() {
|
fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() {
|
||||||
// Open, 160 long; the closing side back to 0,0 would add 116.619.
|
// Open, 160 long; the closing side back to 0,0 would add 116.619.
|
||||||
@@ -42,10 +81,7 @@ fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn area_unsupported_keywords_say_so_and_length_extends_the_last_segment() {
|
fn area_length_extends_the_last_segment() {
|
||||||
let (s, log) = run(&["area", "0,0", "a"]);
|
|
||||||
assert!(has(&log, "not available yet"), "{log:?}");
|
|
||||||
assert!(s.running.is_some());
|
|
||||||
// Length continues along the previous segment: 0,0 -> 4,0 -> +6 -> 10,0, then 10,10.
|
// Length continues along the previous segment: 0,0 -> 4,0 -> +6 -> 10,0, then 10,10.
|
||||||
let (_, log) = run(&["area", "0,0", "4,0", "l", "6", "10,10", ""]);
|
let (_, log) = run(&["area", "0,0", "4,0", "l", "6", "10,10", ""]);
|
||||||
assert!(has(&log, "Area = 50.0000"), "{log:?}");
|
assert!(has(&log, "Area = 50.0000"), "{log:?}");
|
||||||
@@ -58,6 +94,91 @@ fn dist_multiple_points_sums_the_segments() {
|
|||||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn area_arc_option_measures_arc_segments() {
|
||||||
|
// A 10 x 10 square whose right side is a half circle of radius 5 bulging out:
|
||||||
|
// area 100 + 12.5 pi, perimeter 30 + 5 pi.
|
||||||
|
let expected = "Area = 139.2699, Perimeter = 45.7080";
|
||||||
|
|
||||||
|
// Arc mode: a point ends an arc that continues the last segment; Line goes back to lines.
|
||||||
|
let (s, _) = run(&["area", "0,0", "10,0", "a"]);
|
||||||
|
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
|
||||||
|
assert!(s.prompt_text().contains("CEnter"), "{}", s.prompt_text());
|
||||||
|
let (s, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]);
|
||||||
|
assert!(has(&log, expected), "{log:?}");
|
||||||
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||||
|
|
||||||
|
// The same half circle by its center, by its included angle and by a second point.
|
||||||
|
for arc in [&["ce", "10,5", "10,10"][..], &["a", "180", "10,10"], &["s", "15,5", "10,10"]] {
|
||||||
|
let mut lines = vec!["area", "0,0", "10,0", "a"];
|
||||||
|
lines.extend_from_slice(arc);
|
||||||
|
lines.extend(["l", "0,10", ""]);
|
||||||
|
let (_, log) = run(&lines);
|
||||||
|
assert!(has(&log, expected), "{arc:?}: {log:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the arc, Length continues along its end tangent (-X): 10 to 0,10.
|
||||||
|
let (_, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "l", "10", ""]);
|
||||||
|
assert!(has(&log, expected), "{log:?}");
|
||||||
|
|
||||||
|
// Undo removes the arc segment; arc mode stays on.
|
||||||
|
let (s, _) = run(&["area", "0,0", "10,0", "a", "10,10", "u"]);
|
||||||
|
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
|
||||||
|
|
||||||
|
// CLose ends with an arc back to the first point: center 0,10, radius 10, 270 degrees.
|
||||||
|
let (s, log) = run(&["area", "0,0", "10,0", "10,10", "a", "cl"]);
|
||||||
|
assert!(has(&log, "Area = 335.6194, Perimeter = 67.1239"), "{log:?}");
|
||||||
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||||
|
// No arc continues the last segment back to the first point when it lies straight behind.
|
||||||
|
let (s, log) = run(&["area", "0,0", "10,0", "a", "cl"]);
|
||||||
|
assert!(has(&log, "Invalid arc endpoint."), "{log:?}");
|
||||||
|
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
|
||||||
|
|
||||||
|
// Add mode keeps its prefix in arc mode and adds the curved area to the total.
|
||||||
|
let (s, _) = run(&["area", "a", "0,0", "10,0", "a"]);
|
||||||
|
assert!(s.prompt_text().contains("(ADD mode) Specify endpoint of arc"), "{}", s.prompt_text());
|
||||||
|
let (_, log) = run(&["area", "a", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]);
|
||||||
|
assert!(has(&log, "Total area = 139.2699"), "{log:?}");
|
||||||
|
|
||||||
|
// MEASUREGEOM's Volume option measures the same area times the height.
|
||||||
|
let (_, log) = run(&["measuregeom", "v", "0,0", "10,0", "a", "10,10", "l", "0,10", "", "2"]);
|
||||||
|
assert!(has(&log, "Volume = 278.5398"), "{log:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dist_multiple_points_arc_option_adds_arc_lengths() {
|
||||||
|
// 10 along X, then a half circle of radius 5 up to 10,10: 10 + 5 pi.
|
||||||
|
let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10"]);
|
||||||
|
assert!(has(&log, "Distance = 25.7080"), "{log:?}");
|
||||||
|
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
|
||||||
|
|
||||||
|
// Line, then Length continues along the tangent at the arc's end (-X): 5 more.
|
||||||
|
let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "l", "l", "5", ""]);
|
||||||
|
assert!(has(&log, "Distance = 30.7080"), "{log:?}");
|
||||||
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||||
|
|
||||||
|
// Undo removes the arc segment.
|
||||||
|
let (_, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "u", ""]);
|
||||||
|
assert_eq!(log.iter().filter(|l| l.contains("Distance = 10.0000")).count(), 2, "{log:?}");
|
||||||
|
|
||||||
|
// CLose adds an arc back to the first point and ends: 20 + 15 pi.
|
||||||
|
let (s, log) = run(&["dist", "0,0", "m", "10,0", "10,10", "a", "cl"]);
|
||||||
|
assert!(has(&log, "Distance = 67.1239"), "{log:?}");
|
||||||
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||||
|
|
||||||
|
// CLose needs a second point.
|
||||||
|
let (s, log) = run(&["dist", "0,0", "m", "a", "cl"]);
|
||||||
|
assert!(has(&log, "CLose needs at least two points."), "{log:?}");
|
||||||
|
assert!(s.running.is_some());
|
||||||
|
|
||||||
|
// No arc continues the last segment to a point straight behind it.
|
||||||
|
for end in ["5,0", "cl"] {
|
||||||
|
let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", end]);
|
||||||
|
assert!(has(&log, "Invalid arc endpoint."), "{end}: {log:?}");
|
||||||
|
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{end}: {}", s.prompt_text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() {
|
fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() {
|
||||||
for end in [90, 45] {
|
for end in [90, 45] {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ fn typed_layout_options_prompt_for_names() {
|
|||||||
s.script("LAYOUT\n\nModel\n").unwrap();
|
s.script("LAYOUT\n\nModel\n").unwrap();
|
||||||
assert_eq!(s.layout_space(), Space::Model);
|
assert_eq!(s.layout_space(), Space::Model);
|
||||||
|
|
||||||
// ? lists; Template and SAveas report and end without taking the next input.
|
// ? lists; Template reports and ends without taking the next input.
|
||||||
let n = s.log.len();
|
let n = s.log.len();
|
||||||
s.cmdline("LAYOUT ? Sheet*").unwrap();
|
s.cmdline("LAYOUT ? Sheet*").unwrap();
|
||||||
assert!(s.log[n..].iter().any(|l| l == "Layout: Sheet A (2)"), "{:?}", &s.log[n..]);
|
assert!(s.log[n..].iter().any(|l| l == "Layout: Sheet A (2)"), "{:?}", &s.log[n..]);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod attedit_prompts;
|
|||||||
mod attrib_modes;
|
mod attrib_modes;
|
||||||
mod attrib_props;
|
mod attrib_props;
|
||||||
mod attribute_tools;
|
mod attribute_tools;
|
||||||
|
mod block_description;
|
||||||
mod block_keep;
|
mod block_keep;
|
||||||
mod block_redefine;
|
mod block_redefine;
|
||||||
mod blockreplace;
|
mod blockreplace;
|
||||||
@@ -75,6 +76,7 @@ mod modify_curves;
|
|||||||
mod mtext_columns;
|
mod mtext_columns;
|
||||||
mod mtext_options;
|
mod mtext_options;
|
||||||
mod multiline_attributes;
|
mod multiline_attributes;
|
||||||
|
mod named_viewports;
|
||||||
mod named_views;
|
mod named_views;
|
||||||
mod non_finite_edits;
|
mod non_finite_edits;
|
||||||
mod offset_closed_spline;
|
mod offset_closed_spline;
|
||||||
|
|||||||
@@ -161,6 +161,99 @@ fn multiple_points_length_and_undo_keep_the_measurement_units() {
|
|||||||
assert_eq!(s.log.last().unwrap(), "Distance = 20.0000");
|
assert_eq!(s.log.last().unwrap(), "Distance = 20.0000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_points_arc_options_keep_the_measurement_units() {
|
||||||
|
let (d, _) = drawing();
|
||||||
|
let mut s = session(d);
|
||||||
|
s.start("dist").unwrap();
|
||||||
|
s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
|
||||||
|
s.input(Input::Keyword("Multiple points".into())).unwrap();
|
||||||
|
s.input_snapped(pick(&s, Vec2::new(100.0, 50.0), mode::INT)).unwrap();
|
||||||
|
// A half circle of radius 2.5 on paper, ended by its included angle, is radius 5 in the
|
||||||
|
// model: 10 + 5 pi.
|
||||||
|
s.input(Input::Keyword("Arc".into())).unwrap();
|
||||||
|
s.input(Input::Keyword("CEnter".into())).unwrap();
|
||||||
|
s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap();
|
||||||
|
s.input(Input::Keyword("Angle".into())).unwrap();
|
||||||
|
s.input(Input::Text("180".into())).unwrap();
|
||||||
|
assert_eq!(s.log.last().unwrap(), "Distance = 25.7080");
|
||||||
|
let base = s.current_prompt().unwrap().base.unwrap();
|
||||||
|
assert!(base.near(Vec2::new(100.0, 55.0), 1e-9), "the path stays in paper coordinates: {base:?}");
|
||||||
|
s.input(Input::Enter).unwrap();
|
||||||
|
assert_eq!(s.log.last().unwrap(), "Distance = 25.7080");
|
||||||
|
|
||||||
|
// A typed radius is in model units: 5 is 2.5 on paper, the same half circle.
|
||||||
|
let start = |s: &mut Session| {
|
||||||
|
s.start("dist").unwrap();
|
||||||
|
s.input_snapped(pick(s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
|
||||||
|
s.input(Input::Keyword("Multiple points".into())).unwrap();
|
||||||
|
s.input_snapped(pick(s, Vec2::new(100.0, 50.0), mode::INT)).unwrap();
|
||||||
|
s.input(Input::Keyword("Arc".into())).unwrap();
|
||||||
|
};
|
||||||
|
start(&mut s);
|
||||||
|
for i in [Input::Keyword("Radius".into()), Input::Text("5".into()), Input::Keyword("Angle".into()), Input::Text("180".into()), Input::Enter] {
|
||||||
|
s.input(i).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(s.log.last().unwrap(), "Distance = 25.7080");
|
||||||
|
|
||||||
|
// A snapped end point off the circle gives the direction of the end: the model end is the
|
||||||
|
// arc's, not the snapped point (0,0). Radius 5 from angle -90 to the angle of (-2,-1).
|
||||||
|
start(&mut s);
|
||||||
|
s.input(Input::Keyword("CEnter".into())).unwrap();
|
||||||
|
s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap();
|
||||||
|
s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
|
||||||
|
let sweep = (-1.0f64).atan2(-2.0) + std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU;
|
||||||
|
assert_eq!(s.log.last().unwrap(), &format!("Distance = {:.4}", 10.0 + 5.0 * sweep));
|
||||||
|
s.input(Input::Enter).unwrap();
|
||||||
|
|
||||||
|
// A typed end point is a paper point, as in line mode: the path is measured in paper units.
|
||||||
|
start(&mut s);
|
||||||
|
s.input(Input::Keyword("CEnter".into())).unwrap();
|
||||||
|
s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap();
|
||||||
|
s.input(Input::Point(Vec2::new(100.0, 55.0))).unwrap();
|
||||||
|
assert_eq!(s.log.last().unwrap(), "Distance = 12.8540");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_arc_end_picked_through_another_viewport_uses_paper_units() {
|
||||||
|
let (mut d, paper) = drawing();
|
||||||
|
let mut vp = match &d.entity(viewport(&d, &paper)).unwrap().kind {
|
||||||
|
EntityKind::Viewport(vp) => vp.clone(),
|
||||||
|
_ => panic!("viewport fixture"),
|
||||||
|
};
|
||||||
|
vp.center.x = 120.0;
|
||||||
|
vp.view_height = 20.0;
|
||||||
|
vp.id = 3;
|
||||||
|
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
|
||||||
|
let mut s = session(d);
|
||||||
|
let mut measure = |snap: bool| {
|
||||||
|
s.start("dist").unwrap();
|
||||||
|
let first = pick(&s, Vec2::new(95.0, 50.0), mode::END);
|
||||||
|
let second = pick(&s, Vec2::new(100.0, 50.0), mode::INT);
|
||||||
|
let end = pick(&s, Vec2::new(110.0, 50.0), mode::END);
|
||||||
|
assert_ne!(first.viewport.unwrap().viewport, end.viewport.unwrap().viewport);
|
||||||
|
for (n, hit) in [first, second].into_iter().enumerate() {
|
||||||
|
if n == 1 {
|
||||||
|
s.input(Input::Keyword("Multiple points".into())).unwrap();
|
||||||
|
}
|
||||||
|
if snap { s.input_snapped(hit).unwrap() } else { s.input(Input::Point(hit.point)).unwrap() }
|
||||||
|
}
|
||||||
|
s.input(Input::Keyword("Arc".into())).unwrap();
|
||||||
|
s.input(Input::Keyword("Second pt".into())).unwrap();
|
||||||
|
s.input(Input::Point(Vec2::new(105.0, 52.0))).unwrap();
|
||||||
|
if snap {
|
||||||
|
s.input_snapped(end).unwrap()
|
||||||
|
} else {
|
||||||
|
s.input(Input::Point(end.point)).unwrap()
|
||||||
|
}
|
||||||
|
s.input(Input::Enter).unwrap();
|
||||||
|
s.log.last().unwrap().clone()
|
||||||
|
};
|
||||||
|
let typed = measure(false);
|
||||||
|
assert_eq!(typed, "Distance = 16.0347", "5 along X, then the arc through 105,52 to 110,50");
|
||||||
|
assert_eq!(measure(true), typed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_endpoint_and_intersection_overrides_work_from_the_command_line() {
|
fn explicit_endpoint_and_intersection_overrides_work_from_the_command_line() {
|
||||||
let (d, _) = drawing();
|
let (d, _) = drawing();
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! View > Viewports > Named Viewports...: the saved model space viewport configurations, the one
|
||||||
|
//! the Model tab shows, and restoring or deleting one (its tiles in the Model tab, layout
|
||||||
|
//! viewports in a layout).
|
||||||
|
|
||||||
|
use cadcraft_engine::Session;
|
||||||
|
use cadcraft_engine::doc::{EntityKind, Space};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// A drawing with "Two" (two tiles side by side) and "Quad" (four tiles) saved, showing one view.
|
||||||
|
fn session() -> Session {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.viewport_px = (1200.0, 800.0);
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
|
||||||
|
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
|
||||||
|
s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap();
|
||||||
|
s.execute("-vports", &json!({ "option": "4", "applyTo": "display" })).unwrap();
|
||||||
|
s.execute("-vports", &json!({ "option": "save", "name": "Quad" })).unwrap();
|
||||||
|
s.execute("-vports", &json!({ "option": "single" })).unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rects(s: &Session) -> Vec<[f64; 4]> {
|
||||||
|
let mut r: Vec<[f64; 4]> = s.model_tiles().iter().map(|t| [t.min.x, t.min.y, t.max.x, t.max.y]).collect();
|
||||||
|
r.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_viewports(s: &Session) -> usize {
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
d.space(&Space::Paper("Layout1".into())).unwrap().iter().filter(|e| matches!(e.kind, EntityKind::Viewport(_))).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_viewports_lists_restores_and_deletes_in_the_model_tab() {
|
||||||
|
let mut s = session();
|
||||||
|
let list = s.execute("vports.named", &json!({})).unwrap();
|
||||||
|
assert_eq!(list["configurations"], json!([{ "name": "Two", "viewports": 2 }, { "name": "Quad", "viewports": 4 }]));
|
||||||
|
assert_eq!(list["current"], json!(null), "one view matches no saved configuration");
|
||||||
|
assert_eq!(s.viewport_configuration(), None);
|
||||||
|
|
||||||
|
s.execute("vports.named", &json!({ "name": "two" })).unwrap();
|
||||||
|
assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]);
|
||||||
|
assert_eq!(s.viewport_configuration().as_deref(), Some("Two"));
|
||||||
|
assert_eq!(s.execute("vports.named", &json!({})).unwrap()["current"], json!("Two"));
|
||||||
|
s.undo().unwrap();
|
||||||
|
assert!(rects(&s).is_empty(), "restoring is one undo step");
|
||||||
|
|
||||||
|
let r = s.execute("vports.named", &json!({ "name": "QUAD", "delete": true })).unwrap();
|
||||||
|
assert_eq!(r["deleted"], json!("Quad"));
|
||||||
|
let names: Vec<String> = s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect();
|
||||||
|
assert_eq!(names, vec!["Two".to_string()]);
|
||||||
|
s.undo().unwrap();
|
||||||
|
assert!(s.doc().unwrap().vports.iter().any(|c| c.name == "Quad"), "deleting is one undo step");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_viewports_refuses_unknown_names_and_a_delete_that_is_not_a_boolean() {
|
||||||
|
let mut s = session();
|
||||||
|
for bad in [
|
||||||
|
json!({ "name": "nope" }),
|
||||||
|
json!({ "name": "*Active" }),
|
||||||
|
json!({ "name": "Two", "delete": "yes" }),
|
||||||
|
json!({ "name": "nope", "delete": true }),
|
||||||
|
] {
|
||||||
|
assert!(s.execute("vports.named", &bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
// Only the exact name is deleted, never a wildcard match.
|
||||||
|
assert!(s.execute("vports.named", &json!({ "name": "*", "delete": true })).is_err());
|
||||||
|
assert_eq!(s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_viewports_places_a_configuration_in_a_layout() {
|
||||||
|
let mut s = session();
|
||||||
|
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||||
|
let before = layout_viewports(&s);
|
||||||
|
let r = s.execute("vports.named", &json!({ "name": "Quad", "p1": [10, 10], "p2": [210, 110] })).unwrap();
|
||||||
|
assert_eq!(r["configuration"], json!("Quad"));
|
||||||
|
assert_eq!(layout_viewports(&s), before + 4);
|
||||||
|
assert!(s.model_tiles().is_empty(), "the Model tab's tiles do not change");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_named_viewports_asks_for_the_configuration() {
|
||||||
|
let mut s = session();
|
||||||
|
s.start("vports.named").unwrap();
|
||||||
|
assert!(s.prompt_text().contains("Enter name of viewport configuration to restore"), "{}", s.prompt_text());
|
||||||
|
s.cmdline("Two").unwrap();
|
||||||
|
assert!(s.running.is_none());
|
||||||
|
assert_eq!(rects(&s).len(), 2);
|
||||||
|
|
||||||
|
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||||
|
let before = layout_viewports(&s);
|
||||||
|
s.start("vports.named").unwrap();
|
||||||
|
assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text());
|
||||||
|
s.cmdline("Quad").unwrap();
|
||||||
|
assert!(s.prompt_text().contains("Specify first corner"), "{}", s.prompt_text());
|
||||||
|
s.cmdline("").unwrap();
|
||||||
|
assert!(s.running.is_none());
|
||||||
|
assert_eq!(layout_viewports(&s), before + 4, "Enter fits the configuration to the printable area");
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Drawing templates (#552): SAVEAS to a .dwt, NEW/QNEW from a template file, and the default
|
//! Drawing templates (#552): SAVEAS to a .dwt, NEW/QNEW from a template file, and the default
|
||||||
//! template (QNEWTEMPLATE). The io hooks here keep drawings in memory: a "file" holds the number
|
//! template (QNEWTEMPLATE), and a layout saved as a template (LAYOUT SAveas). The io hooks here keep drawings in memory: a "file" holds the number
|
||||||
//! of the drawing written, so the test sees exactly what the engine hands the file formats.
|
//! of the drawing written, so the test sees exactly what the engine hands the file formats.
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -105,3 +105,95 @@ fn a_template_that_cannot_be_read_makes_no_drawing() {
|
|||||||
assert!(s.execute("new", &json!({ "template": 42 })).is_err());
|
assert!(s.execute("new", &json!({ "template": 42 })).is_err());
|
||||||
assert_eq!(s.docs.len(), docs);
|
assert_eq!(s.docs.len(), docs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The drawing the io hooks were last handed for a file whose name ends with `name`.
|
||||||
|
fn written(name: &str) -> Drawing {
|
||||||
|
FILES.lock().unwrap().iter().rev().find(|(n, _)| n.ends_with(name)).map(|f| f.1.clone()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_names(d: &Drawing) -> Vec<String> {
|
||||||
|
d.layouts.iter().map(|l| l.name.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LAYOUT SAveas: the template holds the layout alone, with the named objects it uses, and the
|
||||||
|
/// drawing it came from keeps everything.
|
||||||
|
#[test]
|
||||||
|
fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
|
||||||
|
hooks();
|
||||||
|
let mut s = Session::new();
|
||||||
|
for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] {
|
||||||
|
s.execute("layer.new", &json!({ "name": name })).unwrap();
|
||||||
|
}
|
||||||
|
s.execute("layer.current", &json!({ "name": "PARTS-LAYER" })).unwrap();
|
||||||
|
let c = s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap()["handle"].clone();
|
||||||
|
s.execute("block", &json!({ "name": "PARTS", "base": [0, 0], "handles": [c], "keep": "retain" })).unwrap();
|
||||||
|
// WALLS has model space objects only, and NOTES none; the sheet's viewport freezes one and recolors the other.
|
||||||
|
s.execute("layer.current", &json!({ "name": "WALLS" })).unwrap();
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [0, 10]] })).unwrap();
|
||||||
|
let vp = s.execute("layout.new", &json!({ "name": "Sheet" })).unwrap()["viewport"].clone();
|
||||||
|
s.execute("viewport.set", &json!({ "handle": vp, "freeze": ["WALLS"], "colors": { "NOTES": 1 } })).unwrap();
|
||||||
|
s.execute("layout.set", &json!({ "name": "Sheet" })).unwrap();
|
||||||
|
s.execute("layer.current", &json!({ "name": "TITLE" })).unwrap();
|
||||||
|
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||||
|
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
|
||||||
|
let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count();
|
||||||
|
|
||||||
|
let p = path("sheet.dwt");
|
||||||
|
let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap();
|
||||||
|
assert_eq!(r["name"], json!("Sheet"), "the current layout by default");
|
||||||
|
assert_eq!(r["path"], json!(p));
|
||||||
|
let t = written("sheet.dwt");
|
||||||
|
assert_eq!(layout_names(&t), ["Sheet"]);
|
||||||
|
assert_eq!(t.active_layout.as_deref(), Some("Sheet"));
|
||||||
|
assert_eq!(t.model.iter().count(), 0, "no model space objects");
|
||||||
|
assert_eq!(t.layout("Sheet").unwrap().entities.iter().count(), sheet_objects, "the layout's viewport and line");
|
||||||
|
assert!(t.layer("TITLE").is_some() && t.layer("0").is_some());
|
||||||
|
assert!(t.layer("UNUSED").is_none() && t.layer("PARTS-LAYER").is_none(), "unused layers are left out");
|
||||||
|
assert!(t.block("PARTS").is_none(), "unused blocks are left out");
|
||||||
|
// Layers the viewport freezes or recolors stay, and so do those settings.
|
||||||
|
assert!(t.layer("WALLS").is_some() && t.layer("NOTES").is_some());
|
||||||
|
let sheet_vp = t.layout("Sheet").unwrap().entities.iter().find_map(|e| match &e.kind {
|
||||||
|
cadcraft_engine::doc::EntityKind::Viewport(v) if v.id != 1 => Some(v.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let sheet_vp = sheet_vp.unwrap();
|
||||||
|
assert_eq!(sheet_vp.frozen_layers, ["WALLS"]);
|
||||||
|
assert_eq!(sheet_vp.layer_colors.len(), 1);
|
||||||
|
|
||||||
|
// The drawing is not changed.
|
||||||
|
let d = s.doc().unwrap();
|
||||||
|
assert_eq!(layout_names(d), ["Layout1", "Layout2", "Sheet"]);
|
||||||
|
assert_eq!(d.model.iter().count(), 2);
|
||||||
|
assert!(d.layer("UNUSED").is_some() && d.block("PARTS").is_some());
|
||||||
|
|
||||||
|
// A drawing started from the template has the layout.
|
||||||
|
s.execute("new", &json!({ "template": p })).unwrap();
|
||||||
|
assert_eq!(layout_names(s.doc().unwrap()), ["Sheet"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() {
|
||||||
|
hooks();
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.cmdline("LAYOUT").unwrap();
|
||||||
|
s.cmdline("sa").unwrap();
|
||||||
|
assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template <Layout1>:");
|
||||||
|
s.cmdline("Layout2").unwrap();
|
||||||
|
assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name <Layout2.dwt>:");
|
||||||
|
// No extension: the template is a .dwt.
|
||||||
|
let p = path("typed");
|
||||||
|
s.cmdline(&p).unwrap();
|
||||||
|
assert!(s.current_prompt().is_none());
|
||||||
|
let saved = format!("{p}.dwt");
|
||||||
|
assert!(s.log.iter().any(|l| *l == format!("Layout \"Layout2\" saved to template \"{saved}\".")), "{:?}", s.log);
|
||||||
|
assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]);
|
||||||
|
|
||||||
|
// The JSON form of LAYOUT, other file types, a missing path and an unknown layout.
|
||||||
|
let dxf = path("sheet1.dxf");
|
||||||
|
let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap();
|
||||||
|
assert_eq!(r["name"], json!("Layout1"));
|
||||||
|
assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]);
|
||||||
|
assert!(s.execute("layout.saveas", &json!({ "path": path("sheet.png") })).is_err());
|
||||||
|
assert!(s.execute("layout.saveas", &json!({})).is_err());
|
||||||
|
assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": path("nope.dwt") })).is_err());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Export timing on a realistic large drawing: `examples/apartment.dxf` repeated on a grid
|
||||||
|
//! (a floor plan of many apartments).
|
||||||
|
//!
|
||||||
|
//! `cargo run --release -p cadcraft-io --example export_bench -- [FORMAT] [GRID] [RUNS] [OUT_DIR]`
|
||||||
|
//!
|
||||||
|
//! FORMAT is `png`, `pdf`, `svg`, `dxf` or `all` (default `png`), GRID the apartments per side
|
||||||
|
//! (default 10), RUNS the timed runs per format (default 7). Prints the median and minimum wall
|
||||||
|
//! time of each format's export (from the drawing in memory to the file's bytes). With OUT_DIR,
|
||||||
|
//! the last run's output is written there as `bench.FORMAT` to compare builds.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use cadcraft_doc::{Drawing, Space};
|
||||||
|
use cadcraft_geom::{Mat3, Vec2};
|
||||||
|
|
||||||
|
fn drawing(grid: usize) -> Drawing {
|
||||||
|
let src = include_bytes!("../../../examples/apartment.dxf");
|
||||||
|
let Ok(mut d) = cadcraft_io::read(src, "apartment.dxf") else {
|
||||||
|
eprintln!("apartment.dxf did not read");
|
||||||
|
std::process::exit(1);
|
||||||
|
};
|
||||||
|
let ents: Vec<_> = d.space(&Space::Model).map(|s| s.iter().map(|e| (**e).clone()).collect()).unwrap_or_default();
|
||||||
|
let ext = d.extents(&Space::Model);
|
||||||
|
let step = Vec2::new(ext.width() * 1.1, ext.height() * 1.1);
|
||||||
|
for i in 0..grid {
|
||||||
|
for j in 0..grid {
|
||||||
|
if (i, j) == (0, 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let m = Mat3::translate(Vec2::new(step.x * j as f64, step.y * i as f64));
|
||||||
|
for e in &ents {
|
||||||
|
let mut e = e.clone();
|
||||||
|
e.handle = d.new_handle();
|
||||||
|
e.kind.transform(&m);
|
||||||
|
if let Some(s) = d.space_mut(&Space::Model) {
|
||||||
|
s.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let format = args.first().map(String::as_str).unwrap_or("png").to_string();
|
||||||
|
let grid: usize = args.get(1).and_then(|a| a.parse().ok()).unwrap_or(10);
|
||||||
|
let runs: usize = args.get(2).and_then(|a| a.parse().ok()).unwrap_or(7).max(1);
|
||||||
|
let out_dir = args.get(3);
|
||||||
|
let d = drawing(grid);
|
||||||
|
eprintln!("{} entities", d.entity_count());
|
||||||
|
let formats: Vec<&str> = if format == "all" { vec!["dxf", "png", "pdf", "svg"] } else { vec![format.as_str()] };
|
||||||
|
for f in formats {
|
||||||
|
let name = format!("bench.{f}");
|
||||||
|
let mut times: Vec<Duration> = Vec::new();
|
||||||
|
let mut last = Vec::new();
|
||||||
|
for _ in 0..runs {
|
||||||
|
let t = Instant::now();
|
||||||
|
match cadcraft_io::write(&d, &name) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
times.push(t.elapsed());
|
||||||
|
last = bytes;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{f}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
times.sort();
|
||||||
|
let ms = |t: Duration| t.as_secs_f64() * 1000.0;
|
||||||
|
println!("{f}: median {:.1} ms, min {:.1} ms, {} runs, {} bytes", ms(times[times.len() / 2]), ms(times[0]), runs, last.len());
|
||||||
|
if let Some(dir) = out_dir
|
||||||
|
&& let Err(e) = std::fs::write(std::path::Path::new(dir).join(&name), &last)
|
||||||
|
{
|
||||||
|
eprintln!("{name}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
|
|||||||
let mut pm = Pixmap::new(view.width, view.height)?;
|
let mut pm = Pixmap::new(view.width, view.height)?;
|
||||||
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
|
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
|
||||||
let vis = view.world_bounds();
|
let vis = view.world_bounds();
|
||||||
|
let mut hairlines = Hairlines::default();
|
||||||
|
let mut screen: Vec<(f32, f32)> = Vec::new();
|
||||||
for p in &list.prims {
|
for p in &list.prims {
|
||||||
let c = p.display_rgb(o.background);
|
let c = p.display_rgb(o.background);
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
@@ -85,23 +87,32 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
|
|||||||
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
|
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
|
||||||
paint.anti_alias = o.antialias;
|
paint.anti_alias = o.antialias;
|
||||||
let pts = list.points(p);
|
let pts = list.points(p);
|
||||||
|
if p.kind != Kind::Polyline {
|
||||||
|
hairlines.flush(&mut pm, o.antialias);
|
||||||
|
}
|
||||||
match p.kind {
|
match p.kind {
|
||||||
Kind::Polyline => {
|
Kind::Polyline => {
|
||||||
let bb = Bounds2::from_points(pts.iter().copied());
|
let bb = Bounds2::from_points(pts.iter().copied());
|
||||||
if !bb.intersects(&vis) {
|
if !bb.intersects(&vis) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
|
||||||
|
screen.clear();
|
||||||
|
screen.extend(pts.iter().map(|q| view.to_screen(*q)));
|
||||||
|
if is_hairline(w, o.antialias) && screen.len() >= 2 && screen.iter().all(|&(x, y)| batchable(x) && batchable(y)) {
|
||||||
|
hairlines.add(&mut pm, [c.0, c.1, c.2, p.alpha()], w, &screen, o.antialias);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hairlines.flush(&mut pm, o.antialias);
|
||||||
let mut pb = PathBuilder::new();
|
let mut pb = PathBuilder::new();
|
||||||
for (i, q) in pts.iter().enumerate() {
|
for (i, (x, y)) in screen.iter().enumerate() {
|
||||||
let (x, y) = view.to_screen(*q);
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
pb.move_to(x, y);
|
pb.move_to(*x, *y);
|
||||||
} else {
|
} else {
|
||||||
pb.line_to(x, y);
|
pb.line_to(*x, *y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(path) = pb.finish() {
|
if let Some(path) = pb.finish() {
|
||||||
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
|
|
||||||
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
|
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
|
||||||
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||||
}
|
}
|
||||||
@@ -152,9 +163,65 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hairlines.flush(&mut pm, o.antialias);
|
||||||
Some(pm)
|
Some(pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether tiny-skia strokes a line `width` pixels wide (untransformed) as a hairline: zero
|
||||||
|
/// width, or at most one pixel when anti-aliased.
|
||||||
|
fn is_hairline(width: f32, antialias: bool) -> bool {
|
||||||
|
width == 0.0 || (antialias && width <= 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largest screen coordinate (pixels) of a polyline stroked together with others. Beyond it a
|
||||||
|
/// path's integer bounds could overflow, so such a polyline is stroked on its own as before.
|
||||||
|
const MAX_BATCH_COORD: f32 = 1.0e8;
|
||||||
|
|
||||||
|
fn batchable(v: f32) -> bool {
|
||||||
|
v.is_finite() && v.abs() <= MAX_BATCH_COORD
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consecutive hairline polylines with the same colour, alpha and width, stroked as one path.
|
||||||
|
///
|
||||||
|
/// tiny-skia draws a hairline segment by segment straight onto the pixmap (each segment with
|
||||||
|
/// its own caps, a polyline's first and last segment extended for round caps), so one path of
|
||||||
|
/// several polylines paints exactly the pixels, in the same order, as stroking them one by one;
|
||||||
|
/// it saves building a paint pipeline and a path per polyline. Thicker strokes are filled
|
||||||
|
/// outlines, where overlaps would cover once instead of twice, and are never batched.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Hairlines {
|
||||||
|
key: Option<([u8; 4], f32)>,
|
||||||
|
pb: PathBuilder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hairlines {
|
||||||
|
fn add(&mut self, pm: &mut Pixmap, rgba: [u8; 4], width: f32, pts: &[(f32, f32)], antialias: bool) {
|
||||||
|
if self.key.is_some_and(|k| k != (rgba, width)) {
|
||||||
|
self.flush(pm, antialias);
|
||||||
|
}
|
||||||
|
self.key = Some((rgba, width));
|
||||||
|
for (i, (x, y)) in pts.iter().enumerate() {
|
||||||
|
if i == 0 {
|
||||||
|
self.pb.move_to(*x, *y);
|
||||||
|
} else {
|
||||||
|
self.pb.line_to(*x, *y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self, pm: &mut Pixmap, antialias: bool) {
|
||||||
|
let Some(([r, g, b, a], width)) = self.key.take() else { return };
|
||||||
|
let pb = std::mem::take(&mut self.pb);
|
||||||
|
let Some(path) = pb.finish() else { return };
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color_rgba8(r, g, b, a);
|
||||||
|
paint.anti_alias = antialias;
|
||||||
|
let stroke = Stroke { width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
|
||||||
|
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||||
|
self.pb = path.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render and encode as PNG.
|
/// Render and encode as PNG.
|
||||||
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
|
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
|
||||||
render(list, view, o)?.encode_png().ok()
|
render(list, view, o)?.encode_png().ok()
|
||||||
|
|||||||
@@ -999,3 +999,126 @@ fn wide_polyline_centre_line_is_drawn_on_screen_only() {
|
|||||||
assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline");
|
assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each primitive stroked or filled on its own, as `render` did before it batched hairlines.
|
||||||
|
fn render_one_by_one(list: &DisplayList, view: &View, o: &RasterOptions) -> tiny_skia::Pixmap {
|
||||||
|
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||||
|
let mut pm = Pixmap::new(view.width, view.height).unwrap();
|
||||||
|
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
|
||||||
|
for p in &list.prims {
|
||||||
|
let c = p.display_rgb(o.background);
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
|
||||||
|
paint.anti_alias = o.antialias;
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
match p.kind {
|
||||||
|
Kind::Polyline => {
|
||||||
|
for (i, q) in list.points(p).iter().enumerate() {
|
||||||
|
let (x, y) = view.to_screen(*q);
|
||||||
|
if i == 0 {
|
||||||
|
pb.move_to(x, y);
|
||||||
|
} else {
|
||||||
|
pb.line_to(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(path) = pb.finish() {
|
||||||
|
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
|
||||||
|
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
|
||||||
|
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Tris => {
|
||||||
|
for t in list.points(p).chunks(3) {
|
||||||
|
let [a, b, c] = t else { continue };
|
||||||
|
let ((ax, ay), (bx, by), (cx, cy)) = (view.to_screen(*a), view.to_screen(*b), view.to_screen(*c));
|
||||||
|
pb.move_to(ax, ay);
|
||||||
|
pb.line_to(bx, by);
|
||||||
|
pb.line_to(cx, cy);
|
||||||
|
pb.close();
|
||||||
|
}
|
||||||
|
if let Some(path) = pb.finish() {
|
||||||
|
pm.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => panic!("not drawn by this reference"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pm
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_prim(l: &mut DisplayList, kind: Kind, color: Rgb, transparency: u8, lw: f32, pts: &[Vec2]) {
|
||||||
|
let arr = if kind == Kind::Tris { &mut l.tris } else { &mut l.verts };
|
||||||
|
let start = arr.len() as u32;
|
||||||
|
arr.extend_from_slice(pts);
|
||||||
|
for p in pts.iter().filter(|p| p.is_finite() && p.x.abs() < 1e6) {
|
||||||
|
l.bounds.add(*p);
|
||||||
|
}
|
||||||
|
l.prims.push(DPrim { handle: VIEWPORT_CONTENT, color, aci7: false, lw, transparency, kind, start, len: pts.len() as u32 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hairline polylines with the same paint are stroked as one path; the pixels must be exactly
|
||||||
|
/// those of stroking each on its own, whatever breaks a run (colour, transparency, lineweight,
|
||||||
|
/// fills, unusable coordinates) and with overlaps, caps and dots.
|
||||||
|
#[test]
|
||||||
|
fn raster_hairline_batches_match_one_by_one() {
|
||||||
|
let red = Rgb(255, 0, 0);
|
||||||
|
let blue = Rgb(0, 0, 255);
|
||||||
|
let mut l = DisplayList::default();
|
||||||
|
// A hatch-like run of crossing, overlapping and touching lines in one colour.
|
||||||
|
for i in 0..40 {
|
||||||
|
let x = f64::from(i) * 0.37;
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x, 0.0), Vec2::new(x + 6.0, 9.0)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x + 3.0, 0.0), Vec2::new(x - 2.0, 9.0), Vec2::new(x + 1.0, 4.0)]);
|
||||||
|
}
|
||||||
|
// Zero-length (a dot), single-point (nothing), and repeated lines.
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(3.0, 3.0)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]);
|
||||||
|
// Transparent lines build up where they overlap, within a run and across runs.
|
||||||
|
for i in 0..10 {
|
||||||
|
let y = f64::from(i) * 0.2;
|
||||||
|
push_prim(&mut l, Kind::Polyline, blue, 50, 0.0, &[Vec2::new(0.0, y), Vec2::new(12.0, 8.0 - y)]);
|
||||||
|
}
|
||||||
|
push_prim(&mut l, Kind::Polyline, blue, 20, 0.0, &[Vec2::new(0.0, 5.0), Vec2::new(12.0, 5.0)]);
|
||||||
|
// A lineweight (a filled outline when lineweights are shown), a fill, then the run again.
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.5, &[Vec2::new(0.0, 6.0), Vec2::new(12.0, 7.0), Vec2::new(5.0, 2.0)]);
|
||||||
|
push_prim(&mut l, Kind::Tris, blue, 30, 0.0, &[Vec2::new(4.0, 4.0), Vec2::new(9.0, 4.0), Vec2::new(6.0, 8.0)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 8.0), Vec2::new(12.0, 1.0)]);
|
||||||
|
// Unusable or far-out coordinates are stroked on their own, as before.
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(f64::NAN, 3.0), Vec2::new(9.0, 9.0)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-1e12, 4.0), Vec2::new(1e12, 4.5)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(5.0, -1e7), Vec2::new(5.5, 1e7)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 0.5), Vec2::new(12.0, 0.5)]);
|
||||||
|
// Partly and wholly off screen.
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-30.0, -30.0), Vec2::new(40.0, 30.0)]);
|
||||||
|
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(100.0, 100.0), Vec2::new(120.0, 101.0)]);
|
||||||
|
let view = View::fit(&l.bounds, 160, 120, 0.05);
|
||||||
|
let bg = Rgb(33, 40, 48);
|
||||||
|
for o in [
|
||||||
|
RasterOptions::default(),
|
||||||
|
RasterOptions { antialias: false, ..Default::default() },
|
||||||
|
RasterOptions { hairline: 0.5, ..Default::default() },
|
||||||
|
RasterOptions { hairline: 0.0, antialias: false, ..Default::default() },
|
||||||
|
RasterOptions { px_per_mm: 4.0, background: Rgb(255, 255, 255), ..Default::default() },
|
||||||
|
RasterOptions { hairline: 1.5, background: bg, ..Default::default() },
|
||||||
|
] {
|
||||||
|
let fast = render(&l, &view, &o).unwrap();
|
||||||
|
let reference = render_one_by_one(&l, &view, &o);
|
||||||
|
assert!(fast.data() == reference.data(), "pixels differ with {o:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real hatch pattern (many parallel lines in one colour) renders exactly as stroked line by
|
||||||
|
/// line.
|
||||||
|
#[test]
|
||||||
|
fn raster_hatch_pattern_matches_one_by_one() {
|
||||||
|
let mut d = sample();
|
||||||
|
d.add(&Space::Model, Common::default(), EntityKind::Hatch(pattern_hatch("ANSI31", 0.2, (0.0, 0.0, 10.0, 10.0)))).unwrap();
|
||||||
|
let l = build(&d, &Space::Model, &Options::default());
|
||||||
|
assert!(l.prims.len() > 20);
|
||||||
|
let view = View::fit(&l.bounds, 300, 200, 0.05);
|
||||||
|
for o in [RasterOptions::default(), RasterOptions { background: Rgb(255, 255, 255), ..Default::default() }] {
|
||||||
|
assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(crate::tl!("Sort"));
|
ui.label(crate::tl!("Sort"));
|
||||||
egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| {
|
||||||
for k in SortKey::ALL {
|
for k in SortKey::ALL {
|
||||||
if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() {
|
if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() {
|
||||||
v.key = k;
|
v.key = k;
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||||||
let mut deg = current;
|
let mut deg = current;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let label = ui.label(crate::tl!("Increment angle"));
|
let label = ui.label(crate::tl!("Increment angle"));
|
||||||
let combo = egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
|
let combo = craft_ui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
|
||||||
for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] {
|
for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] {
|
||||||
ui.selectable_value(&mut deg, a, format!("{a}"));
|
ui.selectable_value(&mut deg, a, format!("{a}"));
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ const INSERTION_UNITS: [&str; 25] = [
|
|||||||
/// A combo box over `(value, label)` options.
|
/// A combo box over `(value, label)` options.
|
||||||
fn pick(ui: &mut egui::Ui, salt: &str, value: &mut i64, options: &[(i64, String)]) {
|
fn pick(ui: &mut egui::Ui, salt: &str, value: &mut i64, options: &[(i64, String)]) {
|
||||||
let current = options.iter().find(|o| o.0 == *value).map(|o| o.1.clone()).unwrap_or_default();
|
let current = options.iter().find(|o| o.0 == *value).map(|o| o.1.clone()).unwrap_or_default();
|
||||||
egui::ComboBox::from_id_salt(salt).selected_text(current).width(180.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt(salt).selected_text(current).width(180.0).show_ui(ui, |ui| {
|
||||||
for (k, label) in options {
|
for (k, label) in options {
|
||||||
ui.selectable_value(value, *k, label);
|
ui.selectable_value(value, *k, label);
|
||||||
}
|
}
|
||||||
@@ -431,7 +431,7 @@ fn insert_block(app: &mut CadApp, name: &str) {
|
|||||||
/// Interface Language combo; selecting a language runs `ui.language` with its code.
|
/// Interface Language combo; selecting a language runs `ui.language` with its code.
|
||||||
fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||||
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| {
|
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| {
|
||||||
egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| {
|
||||||
for preference in crate::i18n::Preference::for_picker() {
|
for preference in crate::i18n::Preference::for_picker() {
|
||||||
if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() {
|
if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() {
|
||||||
let _ = app.run("ui.language", serde_json::json!({"lang": preference.code()}));
|
let _ = app.run("ui.language", serde_json::json!({"lang": preference.code()}));
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
|
|||||||
}
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label(crate::tl!("Shape"));
|
ui.label(crate::tl!("Shape"));
|
||||||
egui::ComboBox::from_id_salt("hatch_shape").selected_text(shape_label(&f.shape)).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("hatch_shape").selected_text(shape_label(&f.shape)).show_ui(ui, |ui| {
|
||||||
for name in SHAPES {
|
for name in SHAPES {
|
||||||
ui.selectable_value(&mut f.shape, name.to_string(), shape_label(name));
|
ui.selectable_value(&mut f.shape, name.to_string(), shape_label(name));
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
|
|||||||
f.origin = None;
|
f.origin = None;
|
||||||
}
|
}
|
||||||
ui.add_enabled_ui(f.origin.is_none(), |ui| {
|
ui.add_enabled_ui(f.origin.is_none(), |ui| {
|
||||||
egui::ComboBox::from_id_salt("hatch_origin_mode").selected_text(origin_label(&f.origin_mode)).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("hatch_origin_mode").selected_text(origin_label(&f.origin_mode)).show_ui(ui, |ui| {
|
||||||
for m in ORIGIN_MODES {
|
for m in ORIGIN_MODES {
|
||||||
ui.selectable_value(&mut f.origin_mode, m.to_string(), origin_label(m));
|
ui.selectable_value(&mut f.origin_mode, m.to_string(), origin_label(m));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
Blend دمج تدريجي
|
Blend دمج تدريجي
|
||||||
Block كتلة
|
Block كتلة
|
||||||
Block Attribute Manager... مدير سمات الكتلة...
|
Block Attribute Manager... مدير سمات الكتلة...
|
||||||
|
Block Description... وصف الكتلة...
|
||||||
Block Editor محرر الكتل
|
Block Editor محرر الكتل
|
||||||
Block Reference مرجع كتلة
|
Block Reference مرجع كتلة
|
||||||
Block... كتلة...
|
Block... كتلة...
|
||||||
@@ -587,6 +588,7 @@
|
|||||||
Name الاسم
|
Name الاسم
|
||||||
Name (A–Z) الاسم (أ–ي)
|
Name (A–Z) الاسم (أ–ي)
|
||||||
Named مسمى
|
Named مسمى
|
||||||
|
Named Viewports... أطر العرض المسماة...
|
||||||
Named Views... طرق عرض مسماة...
|
Named Views... طرق عرض مسماة...
|
||||||
Nearest الأقرب
|
Nearest الأقرب
|
||||||
New جديد
|
New جديد
|
||||||
|
|||||||
|
@@ -92,6 +92,7 @@
|
|||||||
Blend Spojit křivky
|
Blend Spojit křivky
|
||||||
Block Blok
|
Block Blok
|
||||||
Block Attribute Manager... Správce atributů bloků...
|
Block Attribute Manager... Správce atributů bloků...
|
||||||
|
Block Description... Popis bloku...
|
||||||
Block Editor Editor bloků
|
Block Editor Editor bloků
|
||||||
Block Reference Reference bloku
|
Block Reference Reference bloku
|
||||||
Block... Blok...
|
Block... Blok...
|
||||||
@@ -537,6 +538,7 @@
|
|||||||
N S
|
N S
|
||||||
Name Název
|
Name Název
|
||||||
Name (A–Z) Název (A–Z)
|
Name (A–Z) Název (A–Z)
|
||||||
|
Named Viewports... Pojmenované výřezy...
|
||||||
Named Views... Pojmenované pohledy...
|
Named Views... Pojmenované pohledy...
|
||||||
Nearest Nejbližší
|
Nearest Nejbližší
|
||||||
New Nový
|
New Nový
|
||||||
|
|||||||
|
@@ -67,6 +67,7 @@
|
|||||||
Blend Übergang
|
Blend Übergang
|
||||||
Block Block
|
Block Block
|
||||||
Block Attribute Manager... Blockattribut-Manager...
|
Block Attribute Manager... Blockattribut-Manager...
|
||||||
|
Block Description... Blockbeschreibung...
|
||||||
Block Editor Blockeditor
|
Block Editor Blockeditor
|
||||||
Block Reference Blockreferenz
|
Block Reference Blockreferenz
|
||||||
Block... Block...
|
Block... Block...
|
||||||
@@ -457,6 +458,7 @@
|
|||||||
N N
|
N N
|
||||||
Name Name
|
Name Name
|
||||||
Name (A–Z) Name (A–Z)
|
Name (A–Z) Name (A–Z)
|
||||||
|
Named Viewports... Benannte Ansichtsfenster...
|
||||||
Nearest Nächstgelegener
|
Nearest Nächstgelegener
|
||||||
New Neu
|
New Neu
|
||||||
New (metric) Neu (metrisch)
|
New (metric) Neu (metrisch)
|
||||||
|
|||||||
|
@@ -100,6 +100,7 @@
|
|||||||
Blend Ομαλή σύνδεση (Blend)
|
Blend Ομαλή σύνδεση (Blend)
|
||||||
Block Μπλοκ (Block)
|
Block Μπλοκ (Block)
|
||||||
Block Attribute Manager... Διαχείριση ιδιοτήτων μπλοκ (Block Attributes)...
|
Block Attribute Manager... Διαχείριση ιδιοτήτων μπλοκ (Block Attributes)...
|
||||||
|
Block Description... Περιγραφή μπλοκ...
|
||||||
Block Editor Επεξεργαστής μπλοκ (Block Editor)
|
Block Editor Επεξεργαστής μπλοκ (Block Editor)
|
||||||
Block Reference Αναφορά μπλοκ (Block Reference)
|
Block Reference Αναφορά μπλοκ (Block Reference)
|
||||||
Block to create or edit Μπλοκ προς δημιουργία ή επεξεργασία
|
Block to create or edit Μπλοκ προς δημιουργία ή επεξεργασία
|
||||||
@@ -640,6 +641,7 @@
|
|||||||
Name Όνομα
|
Name Όνομα
|
||||||
Name (A–Z) Όνομα (Α–Ω)
|
Name (A–Z) Όνομα (Α–Ω)
|
||||||
Named Με όνομα
|
Named Με όνομα
|
||||||
|
Named Viewports... Αποθηκευμένα παράθυρα προβολής...
|
||||||
Named Views... Αποθηκευμένες όψεις...
|
Named Views... Αποθηκευμένες όψεις...
|
||||||
Nearest Πλησιέστερο (Nearest)
|
Nearest Πλησιέστερο (Nearest)
|
||||||
New Νέο
|
New Νέο
|
||||||
|
|||||||
|
@@ -87,6 +87,7 @@
|
|||||||
Blend Fusión de curvas
|
Blend Fusión de curvas
|
||||||
Block Bloque
|
Block Bloque
|
||||||
Block Attribute Manager... Administrador de atributos de bloque...
|
Block Attribute Manager... Administrador de atributos de bloque...
|
||||||
|
Block Description... Descripción de bloque...
|
||||||
Block Editor Editor de bloques
|
Block Editor Editor de bloques
|
||||||
Block Reference Referencia de bloque
|
Block Reference Referencia de bloque
|
||||||
Block... Bloque...
|
Block... Bloque...
|
||||||
@@ -567,6 +568,7 @@
|
|||||||
N N
|
N N
|
||||||
Name Nombre
|
Name Nombre
|
||||||
Name (A–Z) Nombre (A–Z)
|
Name (A–Z) Nombre (A–Z)
|
||||||
|
Named Viewports... Ventanas gráficas con nombre...
|
||||||
Named Views... Vistas con nombre...
|
Named Views... Vistas con nombre...
|
||||||
Nearest Cercano
|
Nearest Cercano
|
||||||
New Nuevo
|
New Nuevo
|
||||||
|
|||||||
|
@@ -67,6 +67,7 @@
|
|||||||
Blend Raccord lissé
|
Blend Raccord lissé
|
||||||
Block Bloc
|
Block Bloc
|
||||||
Block Attribute Manager... Gestionnaire des attributs de bloc...
|
Block Attribute Manager... Gestionnaire des attributs de bloc...
|
||||||
|
Block Description... Description de bloc...
|
||||||
Block Editor Éditeur de blocs
|
Block Editor Éditeur de blocs
|
||||||
Block Reference Référence de bloc
|
Block Reference Référence de bloc
|
||||||
Block... Bloc...
|
Block... Bloc...
|
||||||
@@ -452,6 +453,7 @@
|
|||||||
N N
|
N N
|
||||||
Name Nom
|
Name Nom
|
||||||
Name (A–Z) Nom (A–Z)
|
Name (A–Z) Nom (A–Z)
|
||||||
|
Named Viewports... Fenêtres nommées...
|
||||||
Nearest Plus proche
|
Nearest Plus proche
|
||||||
New Nouveau
|
New Nouveau
|
||||||
New (metric) Nouveau (métrique)
|
New (metric) Nouveau (métrique)
|
||||||
|
|||||||
|
@@ -114,6 +114,7 @@
|
|||||||
Blend Átmenetgörbe
|
Blend Átmenetgörbe
|
||||||
Block Blokk
|
Block Blokk
|
||||||
Block Attribute Manager... Blokkattribútum-kezelő...
|
Block Attribute Manager... Blokkattribútum-kezelő...
|
||||||
|
Block Description... Blokkleírás...
|
||||||
Block Editor Blokkszerkesztő
|
Block Editor Blokkszerkesztő
|
||||||
Block Reference Blokkhivatkozás
|
Block Reference Blokkhivatkozás
|
||||||
Block to create or edit Létrehozandó vagy szerkesztendő blokk
|
Block to create or edit Létrehozandó vagy szerkesztendő blokk
|
||||||
@@ -650,6 +651,7 @@
|
|||||||
Name Név
|
Name Név
|
||||||
Name (A–Z) Név (A–Z)
|
Name (A–Z) Név (A–Z)
|
||||||
Named Elnevezett
|
Named Elnevezett
|
||||||
|
Named Viewports... Elnevezett nézetablakok...
|
||||||
Named Views... Elnevezett nézetek...
|
Named Views... Elnevezett nézetek...
|
||||||
Nearest Legközelebbi
|
Nearest Legközelebbi
|
||||||
New Új
|
New Új
|
||||||
|
|||||||
|
Can't render this file because it contains an unexpected character in line 16 and column 72.
|
@@ -97,6 +97,7 @@ menu Modify Edita
|
|||||||
Blend Fondi
|
Blend Fondi
|
||||||
Block Blocco
|
Block Blocco
|
||||||
Block Attribute Manager... Gestore attributi blocco...
|
Block Attribute Manager... Gestore attributi blocco...
|
||||||
|
Block Description... Descrizione blocco...
|
||||||
Block Editor Editor blocchi
|
Block Editor Editor blocchi
|
||||||
Block Reference Riferimento di blocco
|
Block Reference Riferimento di blocco
|
||||||
Block... Blocco...
|
Block... Blocco...
|
||||||
@@ -549,6 +550,7 @@ menu Modify Edita
|
|||||||
N N
|
N N
|
||||||
Name Nome
|
Name Nome
|
||||||
Name (A–Z) Nome (A–Z)
|
Name (A–Z) Nome (A–Z)
|
||||||
|
Named Viewports... Finestre con nome...
|
||||||
Named Views... Viste con nome...
|
Named Views... Viste con nome...
|
||||||
Nearest Vicino
|
Nearest Vicino
|
||||||
New Nuovo
|
New Nuovo
|
||||||
|
|||||||
|
@@ -92,6 +92,7 @@
|
|||||||
Blend 곡선 연결
|
Blend 곡선 연결
|
||||||
Block 블록
|
Block 블록
|
||||||
Block Attribute Manager... 블록 속성 관리자...
|
Block Attribute Manager... 블록 속성 관리자...
|
||||||
|
Block Description... 블록 설명...
|
||||||
Block Editor 블록 편집기
|
Block Editor 블록 편집기
|
||||||
Block Reference 블록 참조
|
Block Reference 블록 참조
|
||||||
Block... 블록...
|
Block... 블록...
|
||||||
@@ -539,6 +540,7 @@
|
|||||||
N 북
|
N 북
|
||||||
Name 이름
|
Name 이름
|
||||||
Name (A–Z) 이름(A–Z)
|
Name (A–Z) 이름(A–Z)
|
||||||
|
Named Viewports... 명명된 뷰포트...
|
||||||
Named Views... 명명된 뷰...
|
Named Views... 명명된 뷰...
|
||||||
Nearest 근처점
|
Nearest 근처점
|
||||||
New 새로 만들기
|
New 새로 만들기
|
||||||
|
|||||||
|
@@ -86,6 +86,7 @@
|
|||||||
Blend Curva de transição
|
Blend Curva de transição
|
||||||
Block Bloco
|
Block Bloco
|
||||||
Block Attribute Manager... Gerenciador de atributos de bloco...
|
Block Attribute Manager... Gerenciador de atributos de bloco...
|
||||||
|
Block Description... Descrição do bloco...
|
||||||
Block Editor Editor de blocos
|
Block Editor Editor de blocos
|
||||||
Block Reference Referência de bloco
|
Block Reference Referência de bloco
|
||||||
Block... Bloco...
|
Block... Bloco...
|
||||||
@@ -565,6 +566,7 @@
|
|||||||
N N
|
N N
|
||||||
Name Nome
|
Name Nome
|
||||||
Name (A–Z) Nome (A–Z)
|
Name (A–Z) Nome (A–Z)
|
||||||
|
Named Viewports... Viewports nomeados...
|
||||||
Named Views... Vistas nomeadas...
|
Named Views... Vistas nomeadas...
|
||||||
Nearest Mais próximo
|
Nearest Mais próximo
|
||||||
New Novo
|
New Novo
|
||||||
|
|||||||
|
@@ -67,6 +67,7 @@
|
|||||||
Blend Сгладить
|
Blend Сгладить
|
||||||
Block Блок
|
Block Блок
|
||||||
Block Attribute Manager... Диспетчер атрибутов блоков...
|
Block Attribute Manager... Диспетчер атрибутов блоков...
|
||||||
|
Block Description... Описание блока...
|
||||||
Block Editor Редактор блоков
|
Block Editor Редактор блоков
|
||||||
Block Reference Вхождение блока
|
Block Reference Вхождение блока
|
||||||
Block... Блок...
|
Block... Блок...
|
||||||
@@ -452,6 +453,7 @@
|
|||||||
N С
|
N С
|
||||||
Name Название
|
Name Название
|
||||||
Name (A–Z) Название (А–Я)
|
Name (A–Z) Название (А–Я)
|
||||||
|
Named Viewports... Именованные видовые экраны...
|
||||||
Nearest Ближайшая точка
|
Nearest Ближайшая точка
|
||||||
New Создать
|
New Создать
|
||||||
New (metric) Создать (метрический)
|
New (metric) Создать (метрический)
|
||||||
|
|||||||
|
@@ -90,6 +90,7 @@
|
|||||||
Blend Mjuk övergång
|
Blend Mjuk övergång
|
||||||
Block Block
|
Block Block
|
||||||
Block Attribute Manager... Hantera blockattribut...
|
Block Attribute Manager... Hantera blockattribut...
|
||||||
|
Block Description... Blockbeskrivning...
|
||||||
Block Editor Blockredigerare
|
Block Editor Blockredigerare
|
||||||
Block Reference Blockreferens
|
Block Reference Blockreferens
|
||||||
Block... Block...
|
Block... Block...
|
||||||
@@ -569,6 +570,7 @@
|
|||||||
N N
|
N N
|
||||||
Name Namn
|
Name Namn
|
||||||
Name (A–Z) Namn (A–Ö)
|
Name (A–Z) Namn (A–Ö)
|
||||||
|
Named Viewports... Namngivna vyfönster...
|
||||||
Named Views... Namngivna vyer...
|
Named Views... Namngivna vyer...
|
||||||
Nearest Närmaste
|
Nearest Närmaste
|
||||||
New Ny
|
New Ny
|
||||||
|
|||||||
|
@@ -114,6 +114,7 @@
|
|||||||
Blend Harmanla
|
Blend Harmanla
|
||||||
Block Blok
|
Block Blok
|
||||||
Block Attribute Manager... Blok Öznitelik Yöneticisi...
|
Block Attribute Manager... Blok Öznitelik Yöneticisi...
|
||||||
|
Block Description... Blok Açıklaması...
|
||||||
Block Editor Blok Düzenleyici
|
Block Editor Blok Düzenleyici
|
||||||
Block Reference Blok Referansı
|
Block Reference Blok Referansı
|
||||||
Block to create or edit Oluşturulacak ya da düzenlenecek blok
|
Block to create or edit Oluşturulacak ya da düzenlenecek blok
|
||||||
@@ -651,6 +652,7 @@
|
|||||||
Name Ad
|
Name Ad
|
||||||
Name (A–Z) Ad (A–Z)
|
Name (A–Z) Ad (A–Z)
|
||||||
Named Adlandırılmış
|
Named Adlandırılmış
|
||||||
|
Named Viewports... Adlandırılmış Görünüm Pencereleri...
|
||||||
Named Views... Adlandırılmış Görünümler...
|
Named Views... Adlandırılmış Görünümler...
|
||||||
Nearest En Yakın
|
Nearest En Yakın
|
||||||
New Yeni
|
New Yeni
|
||||||
|
|||||||
|
Can't render this file because it contains an unexpected character in line 11 and column 13.
|
@@ -94,6 +94,7 @@
|
|||||||
Blend Плавне з'єднання
|
Blend Плавне з'єднання
|
||||||
Block Блок
|
Block Блок
|
||||||
Block Attribute Manager... Диспетчер атрибутів блоків...
|
Block Attribute Manager... Диспетчер атрибутів блоків...
|
||||||
|
Block Description... Опис блока...
|
||||||
Block Editor Редактор блоків
|
Block Editor Редактор блоків
|
||||||
Block Reference Посилання на блок
|
Block Reference Посилання на блок
|
||||||
Block... Блок...
|
Block... Блок...
|
||||||
@@ -553,6 +554,7 @@
|
|||||||
N Пн
|
N Пн
|
||||||
Name Назва
|
Name Назва
|
||||||
Name (A–Z) Назва (А–Я)
|
Name (A–Z) Назва (А–Я)
|
||||||
|
Named Viewports... Іменовані видові екрани...
|
||||||
Named Views... Іменовані види...
|
Named Views... Іменовані види...
|
||||||
Nearest Найближча точка
|
Nearest Найближча точка
|
||||||
New Створити
|
New Створити
|
||||||
|
|||||||
|
@@ -67,6 +67,7 @@
|
|||||||
Blend 过渡
|
Blend 过渡
|
||||||
Block 块
|
Block 块
|
||||||
Block Attribute Manager... 块属性管理器...
|
Block Attribute Manager... 块属性管理器...
|
||||||
|
Block Description... 块描述...
|
||||||
Block Editor 块编辑器
|
Block Editor 块编辑器
|
||||||
Block Reference 块参照
|
Block Reference 块参照
|
||||||
Block... 块...
|
Block... 块...
|
||||||
@@ -1121,6 +1122,7 @@
|
|||||||
Open Recent 打开最近的图形
|
Open Recent 打开最近的图形
|
||||||
Sheet Books to PDF... 图纸册转 PDF...
|
Sheet Books to PDF... 图纸册转 PDF...
|
||||||
Drawing Recovery Manager... 图形恢复管理器...
|
Drawing Recovery Manager... 图形恢复管理器...
|
||||||
|
Named Viewports... 命名视口...
|
||||||
Named Views... 命名视图...
|
Named Views... 命名视图...
|
||||||
Polygonal Viewport 多边形视口
|
Polygonal Viewport 多边形视口
|
||||||
From Template... 来自模板...
|
From Template... 来自模板...
|
||||||
|
|||||||
|
@@ -281,7 +281,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||||||
// Layer states.
|
// Layer states.
|
||||||
ui.label(crate::tl!("Layer State"));
|
ui.label(crate::tl!("Layer State"));
|
||||||
let label = st.state_sel.clone().unwrap_or_else(|| crate::tl!("Unsaved Layer State").into());
|
let label = st.state_sel.clone().unwrap_or_else(|| crate::tl!("Unsaved Layer State").into());
|
||||||
egui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| {
|
||||||
for s in &states {
|
for s in &states {
|
||||||
if ui.selectable_label(st.state_sel.as_deref() == Some(s), s).clicked() {
|
if ui.selectable_label(st.state_sel.as_deref() == Some(s), s).clicked() {
|
||||||
st.state_sel = Some(s.clone());
|
st.state_sel = Some(s.clone());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Style and settings dialogs opened from the menus: Linetype Manager, Point Style, Text Style,
|
//! Style and settings dialogs opened from the menus: Linetype Manager, Point Style, Text Style,
|
||||||
//! Table Style, Multileader Style, Page Setup Manager, Plot and Constraint Settings.
|
//! Table Style, Multileader Style, Page Setup Manager, Plot, Constraint Settings and Named
|
||||||
|
//! Viewports.
|
||||||
//!
|
//!
|
||||||
//! Typed or chosen from a menu (no parameters) these commands open their dialog; JSON calls run
|
//! Typed or chosen from a menu (no parameters) these commands open their dialog; JSON calls run
|
||||||
//! the command. The dialogs read the drawing and change it only through the same commands
|
//! the command. The dialogs read the drawing and change it only through the same commands
|
||||||
@@ -16,6 +17,8 @@ use crate::theme::Tokens;
|
|||||||
|
|
||||||
/// The Block Attribute Manager (BATTMAN).
|
/// The Block Attribute Manager (BATTMAN).
|
||||||
mod battman;
|
mod battman;
|
||||||
|
/// Named Viewports (View > Viewports > Named Viewports...).
|
||||||
|
mod named_vports;
|
||||||
|
|
||||||
/// Open the dialog for a typed or menu-invoked command. `None` if `id` has no dialog here or the
|
/// Open the dialog for a typed or menu-invoked command. `None` if `id` has no dialog here or the
|
||||||
/// call carries parameters.
|
/// call carries parameters.
|
||||||
@@ -32,6 +35,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
|||||||
"mleaderstyle" | "mls" => "mleaderstyle",
|
"mleaderstyle" | "mls" => "mleaderstyle",
|
||||||
"pagesetup" => "pagesetup",
|
"pagesetup" => "pagesetup",
|
||||||
"constraintsettings" | "csettings" => "csettings",
|
"constraintsettings" | "csettings" => "csettings",
|
||||||
|
"vports.named" => "namedvports",
|
||||||
"plot" | "print" => {
|
"plot" | "print" => {
|
||||||
PLOT_GEN.fetch_add(1, Ordering::Relaxed);
|
PLOT_GEN.fetch_add(1, Ordering::Relaxed);
|
||||||
"plot"
|
"plot"
|
||||||
@@ -53,6 +57,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool
|
|||||||
"mleaderstyle" => mleader_style(app, ctx, open),
|
"mleaderstyle" => mleader_style(app, ctx, open),
|
||||||
"pagesetup" => page_setup(app, ctx, open),
|
"pagesetup" => page_setup(app, ctx, open),
|
||||||
"csettings" => constraint_settings(app, ctx, open),
|
"csettings" => constraint_settings(app, ctx, open),
|
||||||
|
"namedvports" => named_vports::dialog(app, ctx, open),
|
||||||
"plot" => plot(app, ctx, open),
|
"plot" => plot(app, ctx, open),
|
||||||
_ => {
|
_ => {
|
||||||
*open = false;
|
*open = false;
|
||||||
@@ -118,7 +123,7 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||||||
let mut action = None;
|
let mut action = None;
|
||||||
egui::Window::new("Linetype Manager").open(open).default_size(vec2(520.0, 360.0)).resizable(true).show(ctx, |ui| {
|
egui::Window::new("Linetype Manager").open(open).default_size(vec2(520.0, 360.0)).resizable(true).show(ctx, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
egui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| {
|
||||||
if library.is_empty() {
|
if library.is_empty() {
|
||||||
ui.label("Every library linetype is loaded.");
|
ui.label("Every library linetype is loaded.");
|
||||||
} else if ui.selectable_label(false, "All").clicked() {
|
} else if ui.selectable_label(false, "All").clicked() {
|
||||||
@@ -445,7 +450,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati
|
|||||||
Field::Choice(key, label, v, choices) => {
|
Field::Choice(key, label, v, choices) => {
|
||||||
ui.label(*label);
|
ui.label(*label);
|
||||||
let mut picked = None;
|
let mut picked = None;
|
||||||
egui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| {
|
||||||
for c in choices {
|
for c in choices {
|
||||||
if ui.selectable_label(c == v, c).clicked() && c != v {
|
if ui.selectable_label(c == v, c).clicked() && c != v {
|
||||||
picked = Some(c.clone());
|
picked = Some(c.clone());
|
||||||
@@ -599,7 +604,7 @@ fn page_rows(
|
|||||||
) -> Option<(&'static str, Value)> {
|
) -> Option<(&'static str, Value)> {
|
||||||
let mut change = None;
|
let mut change = None;
|
||||||
ui.label("Printer/plotter");
|
ui.label("Printer/plotter");
|
||||||
egui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| {
|
craft_ui::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()
|
if ui.selectable_label(device_label(page) == "None", "None").on_hover_text("PDF on the paper chosen below").clicked()
|
||||||
&& device_label(page) != "None"
|
&& device_label(page) != "None"
|
||||||
{
|
{
|
||||||
@@ -618,7 +623,7 @@ fn page_rows(
|
|||||||
// The device's papers, else the standard sizes.
|
// The device's papers, else the standard sizes.
|
||||||
let device = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&page.device));
|
let device = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&page.device));
|
||||||
ui.label("Paper size");
|
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 {
|
craft_ui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device {
|
||||||
Some(dev) => {
|
Some(dev) => {
|
||||||
for p in &dev.papers {
|
for p in &dev.papers {
|
||||||
if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper {
|
if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper {
|
||||||
@@ -647,7 +652,7 @@ fn page_rows(
|
|||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label("Plot area");
|
ui.label("Plot area");
|
||||||
ui.add_enabled_ui(rows.areas.len() > 1, |ui| {
|
ui.add_enabled_ui(rows.areas.len() > 1, |ui| {
|
||||||
egui::ComboBox::from_id_salt(salt.with("area")).selected_text(area_label(&page.plot_area)).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt(salt.with("area")).selected_text(area_label(&page.plot_area)).show_ui(ui, |ui| {
|
||||||
for a in rows.areas {
|
for a in rows.areas {
|
||||||
if ui.selectable_label(page.plot_area == *a, area_label(a)).clicked() && page.plot_area != *a {
|
if ui.selectable_label(page.plot_area == *a, area_label(a)).clicked() && page.plot_area != *a {
|
||||||
change = Some(("plotArea", json!(a)));
|
change = Some(("plotArea", json!(a)));
|
||||||
@@ -723,7 +728,7 @@ fn page_rows(
|
|||||||
}
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label("Plot style table");
|
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| {
|
craft_ui::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" {
|
if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" {
|
||||||
change = Some(("plotStyleTable", json!("None")));
|
change = Some(("plotStyleTable", json!("None")));
|
||||||
}
|
}
|
||||||
@@ -941,7 +946,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||||||
egui::Window::new("Plot").open(open).collapsible(false).resizable(false).pivot(egui::Align2::CENTER_CENTER).default_pos(center).show(ctx, |ui| {
|
egui::Window::new("Plot").open(open).collapsible(false).resizable(false).pivot(egui::Align2::CENTER_CENTER).default_pos(center).show(ctx, |ui| {
|
||||||
egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||||
ui.label("Layout");
|
ui.label("Layout");
|
||||||
egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {
|
||||||
for n in &targets {
|
for n in &targets {
|
||||||
if ui.selectable_label(*n == st.target, n).clicked() && *n != st.target {
|
if ui.selectable_label(*n == st.target, n).clicked() && *n != st.target {
|
||||||
st.page = plot_page(d, n);
|
st.page = plot_page(d, n);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<
|
|||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Block:");
|
ui.label("Block:");
|
||||||
egui::ComboBox::from_id_salt("battman_blocks").selected_text(block.as_str()).width(180.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("battman_blocks").selected_text(block.as_str()).width(180.0).show_ui(ui, |ui| {
|
||||||
for b in &blocks {
|
for b in &blocks {
|
||||||
if ui.selectable_label(*b == block, b).clicked() {
|
if ui.selectable_label(*b == block, b).clicked() {
|
||||||
block = b.clone();
|
block = b.clone();
|
||||||
@@ -189,7 +189,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<
|
|||||||
}
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label("Layer");
|
ui.label("Layer");
|
||||||
egui::ComboBox::from_id_salt("battman_layer").selected_text(r.layer.as_str()).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("battman_layer").selected_text(r.layer.as_str()).show_ui(ui, |ui| {
|
||||||
for l in &layers {
|
for l in &layers {
|
||||||
if ui.selectable_label(*l == r.layer, l).clicked() && *l != r.layer {
|
if ui.selectable_label(*l == r.layer, l).clicked() && *l != r.layer {
|
||||||
action = act(edit("layer", json!(l)), apply);
|
action = act(edit("layer", json!(l)), apply);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! Named Viewports (View > Viewports > Named Viewports...): the drawing's saved model space
|
||||||
|
//! viewport configurations, with the selected one's tiles drawn as a preview. Restore runs
|
||||||
|
//! `vports.named` in the Model tab; in a layout it starts MVIEW Restore with the name, so the
|
||||||
|
//! viewports' corners are picked next. Delete runs `vports.named` with `delete`.
|
||||||
|
|
||||||
|
use cadcraft_doc::{Space, VportConfig, VportTile};
|
||||||
|
use cadcraft_engine::Input;
|
||||||
|
use egui::{RichText, Sense, Stroke, vec2};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::{set_temp, temp};
|
||||||
|
use crate::CadApp;
|
||||||
|
use crate::theme::Tokens;
|
||||||
|
|
||||||
|
/// Draws the tiles of a configuration in a 180 x 120 box; tile coordinates (0, 0) are the bottom left.
|
||||||
|
fn preview(ui: &mut egui::Ui, tiles: &[VportTile], t: &Tokens) {
|
||||||
|
let (rect, _) = ui.allocate_exact_size(vec2(180.0, 120.0), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
p.rect_filled(rect, 0.0, t.panel);
|
||||||
|
let at = |x: f64, y: f64| egui::pos2(rect.min.x + x.clamp(0.0, 1.0) as f32 * rect.width(), rect.max.y - y.clamp(0.0, 1.0) as f32 * rect.height());
|
||||||
|
for tile in tiles.iter().filter(|t| t.min.is_finite() && t.max.is_finite()) {
|
||||||
|
let r = egui::Rect::from_two_pos(at(tile.min.x, tile.min.y), at(tile.max.x, tile.max.y));
|
||||||
|
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
|
||||||
|
}
|
||||||
|
p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the dialog; returns the command to run.
|
||||||
|
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<(&'static str, Value)> {
|
||||||
|
let t = Tokens::get();
|
||||||
|
let Ok(d) = app.session.doc() else {
|
||||||
|
egui::Window::new("Named Viewports").open(open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||||
|
ui.label("Open a drawing first.");
|
||||||
|
});
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let configs: Vec<(String, Vec<VportTile>)> = d.vports.iter().filter(|c| !c.is_active()).map(|c| (c.name.clone(), c.tiles.clone())).collect();
|
||||||
|
let model = app.session.layout_space() == Space::Model;
|
||||||
|
let current = if model { app.session.viewport_configuration() } else { None };
|
||||||
|
let mut sel = temp::<String>(ctx, "namedvp_sel")
|
||||||
|
.filter(|s| configs.iter().any(|c| c.0 == *s))
|
||||||
|
.or_else(|| current.clone())
|
||||||
|
.or_else(|| configs.first().map(|c| c.0.clone()));
|
||||||
|
let mut action: Option<(&'static str, Value)> = None;
|
||||||
|
let mut restore = false;
|
||||||
|
let mut close = false;
|
||||||
|
egui::Window::new("Named Viewports").open(open).default_width(400.0).resizable(false).show(ctx, |ui| {
|
||||||
|
if model {
|
||||||
|
ui.label(format!("Current name: {}", current.as_deref().unwrap_or(VportConfig::ACTIVE)));
|
||||||
|
} else {
|
||||||
|
ui.label(RichText::new("Saved model space configurations to place in this layout").color(t.text_dim));
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if configs.is_empty() {
|
||||||
|
ui.label("This drawing has no named viewport configurations.");
|
||||||
|
}
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
egui::ScrollArea::vertical().id_salt("namedvp_list").max_height(120.0).min_scrolled_width(180.0).show(ui, |ui| {
|
||||||
|
ui.set_min_width(180.0);
|
||||||
|
for (name, _) in &configs {
|
||||||
|
let r = ui.selectable_label(sel.as_deref() == Some(name.as_str()), name);
|
||||||
|
if r.clicked() {
|
||||||
|
sel = Some(name.clone());
|
||||||
|
}
|
||||||
|
if r.double_clicked() {
|
||||||
|
sel = Some(name.clone());
|
||||||
|
restore = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let tiles = sel.as_ref().and_then(|s| configs.iter().find(|c| c.0 == *s)).map(|c| c.1.as_slice()).unwrap_or_default();
|
||||||
|
preview(ui, tiles, &t);
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let hint = if model { "Show this configuration's viewports" } else { "Place this configuration's viewports in the layout" };
|
||||||
|
if ui.add_enabled(sel.is_some(), egui::Button::new("Restore")).on_hover_text(hint).clicked() {
|
||||||
|
restore = true;
|
||||||
|
}
|
||||||
|
if ui.add_enabled(sel.is_some(), egui::Button::new("Delete")).on_hover_text("Delete the saved configuration").clicked()
|
||||||
|
&& let Some(n) = &sel
|
||||||
|
{
|
||||||
|
action = Some(("vports.named", json!({ "name": n, "delete": true })));
|
||||||
|
}
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if let Some(n) = sel.clone() {
|
||||||
|
set_temp(ctx, "namedvp_sel", n);
|
||||||
|
}
|
||||||
|
if close {
|
||||||
|
*open = false;
|
||||||
|
}
|
||||||
|
let Some(name) = sel.filter(|_| restore) else { return action };
|
||||||
|
*open = false;
|
||||||
|
if model {
|
||||||
|
return Some(("vports.named", json!({ "name": name })));
|
||||||
|
}
|
||||||
|
// The viewports' corners are picked in the layout, as for MVIEW Restore.
|
||||||
|
app.start("mview");
|
||||||
|
if app.session.running.as_ref().is_some_and(|r| r.id == "mview") {
|
||||||
|
for i in [Input::Keyword("Restore".into()), Input::Text(name)] {
|
||||||
|
if let Err(e) = app.session.input(i) {
|
||||||
|
app.session.echo(e.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -231,7 +231,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
|
|||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
// Font
|
// Font
|
||||||
let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone());
|
let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone());
|
||||||
egui::ComboBox::from_id_salt("mtext_font")
|
craft_ui::ComboBox::from_id_salt("mtext_font")
|
||||||
.width(120.0)
|
.width(120.0)
|
||||||
.selected_text(shown.clone())
|
.selected_text(shown.clone())
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
|||||||
grid(ui, "options_display", |ui| {
|
grid(ui, "options_display", |ui| {
|
||||||
let theme_caption = ui.label(crate::tl!("Interface theme"));
|
let theme_caption = ui.label(crate::tl!("Interface theme"));
|
||||||
let mut theme = app.ui.theme;
|
let mut theme = app.ui.theme;
|
||||||
let theme_combo = egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| {
|
let theme_combo = craft_ui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| {
|
||||||
for t in ThemePref::ALL {
|
for t in ThemePref::ALL {
|
||||||
ui.selectable_value(&mut theme, t, theme_label(t));
|
ui.selectable_value(&mut theme, t, theme_label(t));
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
|||||||
|
|
||||||
let animations_caption = ui.label(crate::tl!("Animation effects"));
|
let animations_caption = ui.label(crate::tl!("Animation effects"));
|
||||||
let mut animations = app.ui.animations;
|
let mut animations = app.ui.animations;
|
||||||
let animations_combo = egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| {
|
let animations_combo = craft_ui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| {
|
||||||
for a in AnimationPref::ALL {
|
for a in AnimationPref::ALL {
|
||||||
ui.selectable_value(&mut animations, a, animations_label(a));
|
ui.selectable_value(&mut animations, a, animations_label(a));
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
|||||||
let scale_caption = ui.label(crate::tl!("Interface scale"));
|
let scale_caption = ui.label(crate::tl!("Interface scale"));
|
||||||
let scale = app.options.interface_scale;
|
let scale = app.options.interface_scale;
|
||||||
let percent = |s: f32| format!("{} %", (s * 100.0).round());
|
let percent = |s: f32| format!("{} %", (s * 100.0).round());
|
||||||
let scale_combo = egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| {
|
let scale_combo = craft_ui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| {
|
||||||
for n in [75u16, 100, 125, 150, 175, 200, 250, 300] {
|
for n in [75u16, 100, 125, 150, 175, 200, 250, 300] {
|
||||||
let s = f32::from(n) / 100.0;
|
let s = f32::from(n) / 100.0;
|
||||||
if ui.selectable_label((scale - s).abs() < 1e-4, format!("{n} %")).clicked() && (scale - s).abs() >= 1e-4 {
|
if ui.selectable_label((scale - s).abs() < 1e-4, format!("{n} %")).clicked() && (scale - s).abs() >= 1e-4 {
|
||||||
@@ -252,7 +252,7 @@ fn save_version_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map<String, V
|
|||||||
let version_caption = ui.label(crate::tl!("Default save version"));
|
let version_caption = ui.label(crate::tl!("Default save version"));
|
||||||
let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default();
|
let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default();
|
||||||
let mut version = current;
|
let mut version = current;
|
||||||
let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| {
|
let version_combo = craft_ui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| {
|
||||||
for v in crate::SaveVersion::ALL {
|
for v in crate::SaveVersion::ALL {
|
||||||
ui.selectable_value(&mut version, v, v.year());
|
ui.selectable_value(&mut version, v, v.year());
|
||||||
}
|
}
|
||||||
@@ -531,7 +531,7 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>
|
|||||||
grid(ui, "options_preferences", |ui| {
|
grid(ui, "options_preferences", |ui| {
|
||||||
let language_caption = ui.label(crate::tl!("Interface language"));
|
let language_caption = ui.label(crate::tl!("Interface language"));
|
||||||
let mut lang = app.ui.interface_language;
|
let mut lang = app.ui.interface_language;
|
||||||
let language_combo = egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| {
|
let language_combo = craft_ui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| {
|
||||||
for p in crate::i18n::Preference::for_picker() {
|
for p in crate::i18n::Preference::for_picker() {
|
||||||
ui.selectable_value(&mut lang, p, p.name());
|
ui.selectable_value(&mut lang, p, p.name());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -727,6 +727,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||||||
let tb_styles: Vec<String> = d.table_styles.iter().map(|l| l.name.clone()).collect();
|
let tb_styles: Vec<String> = d.table_styles.iter().map(|l| l.name.clone()).collect();
|
||||||
let mut action: Option<(&str, Value)> = None;
|
let mut action: Option<(&str, Value)> = None;
|
||||||
let mut act = |c: &'static str, p: Value| action = Some((c, p));
|
let mut act = |c: &'static str, p: Value| action = Some((c, p));
|
||||||
|
// In the Block Editor, the edited block's name and description come first.
|
||||||
|
let edited = app.session.state().ok().and_then(|st| {
|
||||||
|
let be = st.block_edit.as_ref().filter(|be| !be.current)?;
|
||||||
|
let b = d.block(&be.name).or_else(|| be.host.doc.block(&be.name));
|
||||||
|
Some((be.name.clone(), b.map(|b| b.description.clone()).unwrap_or_default()))
|
||||||
|
});
|
||||||
|
if let Some((name, description)) = edited {
|
||||||
|
prop_row(ui, "Name", |ui| {
|
||||||
|
value_box(ui, &name, false, false).on_hover_text(crate::tl!("Read-only"));
|
||||||
|
});
|
||||||
|
prop_row(ui, "Description", |ui| {
|
||||||
|
if let Some(text) = edit_field(ui, ui.id().with("block_description"), description) {
|
||||||
|
act("blocks.description", json!({ "description": text }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
prop_row(ui, "Color", |ui| {
|
prop_row(ui, "Color", |ui| {
|
||||||
ui.menu_button(format!("■ {}", crate::i18n::t(&Color::from_aci(h.i64("CECOLOR", 256) as i16).name())), |ui| {
|
ui.menu_button(format!("■ {}", crate::i18n::t(&Color::from_aci(h.i64("CECOLOR", 256) as i16).name())), |ui| {
|
||||||
for (n, c) in [
|
for (n, c) in [
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||||||
egui::Grid::new("qc-units-grid").num_columns(2).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
|
egui::Grid::new("qc-units-grid").num_columns(2).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
|
||||||
ui.label(crate::tl!("Type"));
|
ui.label(crate::tl!("Type"));
|
||||||
let kind_label = KINDS.get(st.kind).map_or("", |k| k.1);
|
let kind_label = KINDS.get(st.kind).map_or("", |k| k.1);
|
||||||
egui::ComboBox::from_id_salt("qc-kind").selected_text(crate::i18n::t(kind_label)).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("qc-kind").selected_text(crate::i18n::t(kind_label)).show_ui(ui, |ui| {
|
||||||
for (i, (_, label)) in KINDS.iter().enumerate() {
|
for (i, (_, label)) in KINDS.iter().enumerate() {
|
||||||
if ui.selectable_label(st.kind == i, crate::i18n::t(label)).clicked() {
|
if ui.selectable_label(st.kind == i, crate::i18n::t(label)).clicked() {
|
||||||
st.kind = i;
|
st.kind = i;
|
||||||
@@ -320,7 +320,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||||||
ui.label(label);
|
ui.label(label);
|
||||||
let sel = if which == 0 { &mut st.from } else { &mut st.to };
|
let sel = if which == 0 { &mut st.from } else { &mut st.to };
|
||||||
let shown = list.get(*sel).map(|u| u.1.clone()).unwrap_or_default();
|
let shown = list.get(*sel).map(|u| u.1.clone()).unwrap_or_default();
|
||||||
egui::ComboBox::from_id_salt(salt).selected_text(shown).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt(salt).selected_text(shown).show_ui(ui, |ui| {
|
||||||
for (i, (_, sym)) in list.iter().enumerate() {
|
for (i, (_, sym)) in list.iter().enumerate() {
|
||||||
if ui.selectable_label(*sel == i, sym).clicked() {
|
if ui.selectable_label(*sel == i, sym).clicked() {
|
||||||
*sel = i;
|
*sel = i;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||||||
egui::Window::new(title).id(egui::Id::unique("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| {
|
egui::Window::new(title).id(egui::Id::unique("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(crate::tl!("Plot style table"));
|
ui.label(crate::tl!("Plot style table"));
|
||||||
egui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| {
|
||||||
for n in &names {
|
for n in &names {
|
||||||
if ui.selectable_label(n.eq_ignore_ascii_case(&sel), n).clicked() {
|
if ui.selectable_label(n.eq_ignore_ascii_case(&sel), n).clicked() {
|
||||||
sel = n.clone();
|
sel = n.clone();
|
||||||
@@ -160,7 +160,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||||||
action = set(&table.name, style("screening", json!(screen)));
|
action = set(&table.name, style("screening", json!(screen)));
|
||||||
}
|
}
|
||||||
let lt = st.linetype.clone().unwrap_or_else(|| crate::tl!("Use object linetype").to_string());
|
let lt = st.linetype.clone().unwrap_or_else(|| crate::tl!("Use object linetype").to_string());
|
||||||
egui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| {
|
||||||
if ui.selectable_label(st.linetype.is_none(), crate::tl!("Use object linetype")).clicked() {
|
if ui.selectable_label(st.linetype.is_none(), crate::tl!("Use object linetype")).clicked() {
|
||||||
action = set(&table.name, style("linetype", Value::Null));
|
action = set(&table.name, style("linetype", Value::Null));
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||||||
.lineweight
|
.lineweight
|
||||||
.map(|w| format!("{:.2} mm", f64::from(w) / 100.0))
|
.map(|w| format!("{:.2} mm", f64::from(w) / 100.0))
|
||||||
.unwrap_or_else(|| crate::tl!("Use object lineweight").to_string());
|
.unwrap_or_else(|| crate::tl!("Use object lineweight").to_string());
|
||||||
egui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| {
|
||||||
if ui.selectable_label(st.lineweight.is_none(), crate::tl!("Use object lineweight")).clicked() {
|
if ui.selectable_label(st.lineweight.is_none(), crate::tl!("Use object lineweight")).clicked() {
|
||||||
action = set(&table.name, style("lineweight", Value::Null));
|
action = set(&table.name, style("lineweight", Value::Null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||||
ui.label(crate::tl!("Apply to:"));
|
ui.label(crate::tl!("Apply to:"));
|
||||||
egui::ComboBox::from_id_salt("qs_apply")
|
craft_ui::ComboBox::from_id_salt("qs_apply")
|
||||||
.width(220.0)
|
.width(220.0)
|
||||||
.selected_text(crate::i18n::t(if st.apply_selection { "Current selection" } else { "Entire drawing" }))
|
.selected_text(crate::i18n::t(if st.apply_selection { "Current selection" } else { "Entire drawing" }))
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
@@ -107,7 +107,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| {
|
||||||
ui.selectable_value(&mut st.ty, "Multiple".to_string(), crate::tl!("Multiple"));
|
ui.selectable_value(&mut st.ty, "Multiple".to_string(), crate::tl!("Multiple"));
|
||||||
for (n, c) in &types {
|
for (n, c) in &types {
|
||||||
ui.selectable_value(&mut st.ty, n.clone(), format!("{} ({c})", crate::i18n::t(n)));
|
ui.selectable_value(&mut st.ty, n.clone(), format!("{} ({c})", crate::i18n::t(n)));
|
||||||
@@ -136,7 +136,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
|||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label(crate::tl!("Operator:"));
|
ui.label(crate::tl!("Operator:"));
|
||||||
let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals");
|
let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals");
|
||||||
egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| {
|
||||||
for (o, l) in OPERATORS {
|
for (o, l) in OPERATORS {
|
||||||
ui.selectable_value(&mut st.operator, o.to_string(), crate::i18n::t(l));
|
ui.selectable_value(&mut st.operator, o.to_string(), crate::i18n::t(l));
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
|
|||||||
}
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label(RichText::new(crate::tl!("Layer")).color(Tokens::get().text_dim));
|
ui.label(RichText::new(crate::tl!("Layer")).color(Tokens::get().text_dim));
|
||||||
egui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| {
|
||||||
for l in &layers {
|
for l in &layers {
|
||||||
if ui.selectable_label(*l == layer, l).clicked() {
|
if ui.selectable_label(*l == layer, l).clicked() {
|
||||||
set = Some(("layer".into(), json!(l)));
|
set = Some(("layer".into(), json!(l)));
|
||||||
@@ -326,7 +326,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
|
|||||||
});
|
});
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label(RichText::new(crate::tl!("Linetype")).color(Tokens::get().text_dim));
|
ui.label(RichText::new(crate::tl!("Linetype")).color(Tokens::get().text_dim));
|
||||||
egui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| {
|
craft_ui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| {
|
||||||
for l in std::iter::once("ByLayer".to_string())
|
for l in std::iter::once("ByLayer".to_string())
|
||||||
.chain(std::iter::once("ByBlock".to_string()))
|
.chain(std::iter::once("ByBlock".to_string()))
|
||||||
.chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned())
|
.chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned())
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! Properties in the Block Editor: with nothing selected it shows the edited block's name and
|
||||||
|
//! description, and a description typed there is saved into the block by BSAVE.
|
||||||
|
|
||||||
|
use cadcraft_engine::Session;
|
||||||
|
use cadcraft_ui_egui::{CadApp, Services};
|
||||||
|
use egui::{Key, vec2};
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use egui_kittest::kittest::{Queryable, by};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// Block "Door", described "Hinged door", open in the Block Editor.
|
||||||
|
fn palettes() -> Harness<'static, CadApp> {
|
||||||
|
let mut app = CadApp::new(Session::default(), Services::default());
|
||||||
|
let l = app.run("line", json!({ "points": [[0, 0], [2, 0]] })).unwrap();
|
||||||
|
app.run("block", json!({ "name": "Door", "base": [0, 0], "handles": l["handles"], "description": "Hinged door" })).unwrap();
|
||||||
|
app.run("bedit", json!({ "name": "Door" })).unwrap();
|
||||||
|
let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).build_ui_state(
|
||||||
|
|ui, app: &mut CadApp| {
|
||||||
|
app.logic(&ui.ctx().clone());
|
||||||
|
cadcraft_ui_egui::palettes::right_palettes(app, ui);
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
h.run_steps(4);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field<'h>(h: &'h Harness<'static, CadApp>, shown: &'h str) -> egui_kittest::Node<'h> {
|
||||||
|
h.get(by().role(egui::accesskit::Role::TextInput).value(shown))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_block_editor_shows_and_changes_the_block_description() {
|
||||||
|
let mut h = palettes();
|
||||||
|
field(&h, "Hinged door").focus();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.key_press_modifiers(egui::Modifiers::COMMAND, Key::A);
|
||||||
|
h.run_steps(1);
|
||||||
|
field(&h, "Hinged door").type_text("Sliding door");
|
||||||
|
h.run_steps(2);
|
||||||
|
h.key_press(Key::Enter);
|
||||||
|
h.run_steps(3);
|
||||||
|
field(&h, "Sliding door");
|
||||||
|
|
||||||
|
let app = h.state_mut();
|
||||||
|
assert!(app.session.state().unwrap().block_unsaved());
|
||||||
|
app.run("bclose", json!({ "save": true })).unwrap();
|
||||||
|
assert_eq!(app.session.doc().unwrap().block("Door").unwrap().description, "Sliding door");
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod animation_effects;
|
mod animation_effects;
|
||||||
|
mod block_description_palette;
|
||||||
mod chrome_geometry;
|
mod chrome_geometry;
|
||||||
mod context_menu;
|
mod context_menu;
|
||||||
mod context_menus;
|
mod context_menus;
|
||||||
@@ -17,6 +18,7 @@ mod lisp_files;
|
|||||||
mod measure_distance_snapping;
|
mod measure_distance_snapping;
|
||||||
mod menubar_hover;
|
mod menubar_hover;
|
||||||
mod mspace_viewport_view;
|
mod mspace_viewport_view;
|
||||||
|
mod named_viewports_dialog;
|
||||||
mod native_menu_text_keys;
|
mod native_menu_text_keys;
|
||||||
mod native_windows;
|
mod native_windows;
|
||||||
mod options_dialog;
|
mod options_dialog;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! View > Viewports > Named Viewports... opens a dialog that lists the saved viewport
|
||||||
|
//! configurations: Restore shows one in the Model tab or starts placing it in a layout, and Delete
|
||||||
|
//! deletes it.
|
||||||
|
|
||||||
|
use cadcraft_engine::Session;
|
||||||
|
use cadcraft_ui_egui::{CadApp, Services, dialogs, menus};
|
||||||
|
use egui::vec2;
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// A drawing with "Two" (two tiles side by side) saved, showing one view, and the dialog open.
|
||||||
|
fn dialog() -> Harness<'static, CadApp> {
|
||||||
|
let mut app = CadApp::new(Session::default(), Services::default());
|
||||||
|
app.run("line", json!({ "points": [[0, 0], [100, 50]] })).unwrap();
|
||||||
|
app.run("-vports", json!({ "option": "2", "arrangement": "vertical" })).unwrap();
|
||||||
|
app.run("-vports", json!({ "option": "save", "name": "Two" })).unwrap();
|
||||||
|
app.run("-vports", json!({ "option": "single" })).unwrap();
|
||||||
|
menus::activate(&mut app, "vports.named");
|
||||||
|
assert_eq!(app.ui.dialog.as_deref(), Some("namedvports"));
|
||||||
|
let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state(
|
||||||
|
|ui, app: &mut CadApp| {
|
||||||
|
app.logic(&ui.ctx().clone());
|
||||||
|
dialogs::show(app, ui.ctx());
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
h.run_steps(3);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_and_delete_in_the_model_tab() {
|
||||||
|
let mut h = dialog();
|
||||||
|
h.get_by_label("Current name: *Active");
|
||||||
|
h.get_by_label("Two").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("Restore").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
let app = h.state_mut();
|
||||||
|
assert_eq!(app.session.model_tiles().len(), 2);
|
||||||
|
assert_eq!(app.ui.dialog, None, "Restore closes the dialog");
|
||||||
|
|
||||||
|
menus::activate(app, "vports.named");
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("Current name: Two");
|
||||||
|
h.get_by_label("Delete").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
let app = h.state_mut();
|
||||||
|
assert!(app.session.doc().unwrap().vports.iter().all(|c| c.name != "Two"));
|
||||||
|
assert_eq!(app.ui.dialog.as_deref(), Some("namedvports"), "Delete keeps the dialog open");
|
||||||
|
h.get_by_label("This drawing has no named viewport configurations.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_in_a_layout_asks_where_the_viewports_go() {
|
||||||
|
let mut h = dialog();
|
||||||
|
let app = h.state_mut();
|
||||||
|
app.ui.dialog = None;
|
||||||
|
app.run("layout.set", json!({ "name": "Layout1" })).unwrap();
|
||||||
|
menus::activate(app, "vports.named");
|
||||||
|
h.run_steps(3);
|
||||||
|
h.get_by_label("Two").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("Restore").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
let app = h.state_mut();
|
||||||
|
assert!(app.session.prompt_text().contains("Specify first corner"), "{}", app.session.prompt_text());
|
||||||
|
let before = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count();
|
||||||
|
app.cmdline("");
|
||||||
|
assert!(app.session.running.is_none());
|
||||||
|
let after = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count();
|
||||||
|
assert_eq!(after, before + 2, "Enter fits the two viewports to the printable area");
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# File-format parity
|
# File-format parity
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LAYOUT SAveas writes a layout as a DWT template) · **Target:** Autodesk AutoCAD 2027
|
||||||
|
|
||||||
Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
|
Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
|
||||||
items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
|
items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
|
||||||
@@ -108,7 +108,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
|||||||
|
|
||||||
| Format | AutoCAD 2027 | CADCraft | Hours to parity (est.) |
|
| Format | AutoCAD 2027 | CADCraft | Hours to parity (est.) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552) | 0–1 |
|
| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552); LAYOUT SAveas writes one layout and the named objects it uses as a `.dwt` (`layout.saveas`) | 0–1 |
|
||||||
| DWS standards | open, save, CHECKSTANDARDS | no | 6–10 |
|
| DWS standards | open, save, CHECKSTANDARDS | no | 6–10 |
|
||||||
| PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 |
|
| PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 |
|
||||||
| PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 |
|
| PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 |
|
||||||
@@ -140,6 +140,7 @@ our output in AutoCAD black-box.
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use |
|
||||||
| 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) |
|
| 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) |
|
||||||
| 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) |
|
| 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) |
|
||||||
| 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |
|
| 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |
|
||||||
|
|||||||
+5
-4
@@ -1,6 +1,6 @@
|
|||||||
# Where CADCraft falls short of AutoCAD
|
# Where CADCraft falls short of AutoCAD
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Named Viewports dialog: U11 narrowed; MVIEW Restore and PURGE of multiline styles had landed: F15 and F21 corrected) · **Target:** Autodesk AutoCAD 2027
|
||||||
|
|
||||||
Every known shortfall, one row each, ranked within each section by user impact. This is the work
|
Every known shortfall, one row each, ranked within each section by user impact. This is the work
|
||||||
list: agents pick from here (top of a section first), and remove or update a row in the same PR
|
list: agents pick from here (top of a section first), and remove or update a row in the same PR
|
||||||
@@ -37,12 +37,12 @@ do them first.
|
|||||||
| F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 |
|
| F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 |
|
||||||
| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 |
|
| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 |
|
||||||
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 |
|
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 |
|
||||||
| F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 |
|
| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453), MVIEW Restore in #633 | `cmd/layout/viewports.rs` | Low | 2–4 |
|
||||||
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
|
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
|
||||||
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
|
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
|
||||||
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
|
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
|
||||||
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 |
|
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 |
|
||||||
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
|
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data) | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
|
||||||
| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 |
|
| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 |
|
||||||
| F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 |
|
| F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 |
|
||||||
| F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 |
|
| F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 |
|
||||||
@@ -63,7 +63,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode
|
|||||||
| U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 |
|
| U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 |
|
||||||
| U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 |
|
| U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 |
|
||||||
| U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 |
|
| U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 |
|
||||||
| U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 |
|
| U11 | VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463, MVIEW Restore in #633, and the Named Viewports dialog, which restores or deletes a saved configuration) | Low | 1–2 |
|
||||||
| U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 |
|
| U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 |
|
||||||
| U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 |
|
| U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 |
|
||||||
|
|
||||||
@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles |
|
||||||
| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated |
|
| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated |
|
||||||
| 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed |
|
| 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed |
|
||||||
| 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |
|
| 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment.
|
Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment.
|
||||||
|
|
||||||
**Menu breadth: 296 / 491 items (60%)** · 441 registered commands.
|
**Menu breadth: 297 / 491 items (60%)** · 442 registered commands.
|
||||||
|
|
||||||
| Menu | Items | Covered | % |
|
| Menu | Items | Covered | % |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| File | 22 | 13 | 59% |
|
| File | 22 | 13 | 59% |
|
||||||
| Edit | 13 | 12 | 92% |
|
| Edit | 13 | 12 | 92% |
|
||||||
| View | 75 | 36 | 48% |
|
| View | 75 | 37 | 49% |
|
||||||
| Insert | 11 | 5 | 45% |
|
| Insert | 11 | 5 | 45% |
|
||||||
| Format | 27 | 26 | 96% |
|
| Format | 27 | 26 | 96% |
|
||||||
| Tools | 85 | 66 | 78% |
|
| Tools | 85 | 66 | 78% |
|
||||||
| Draw | 87 | 55 | 63% |
|
| Draw | 87 | 55 | 63% |
|
||||||
| Dimension | 28 | 28 | 100% |
|
| Dimension | 28 | 28 | 100% |
|
||||||
| Modify | 113 | 52 | 46% |
|
| Modify | 113 | 53 | 47% |
|
||||||
| Window | 22 | 3 | 14% |
|
| Window | 22 | 3 | 14% |
|
||||||
| Help | 8 | 0 | 0% |
|
| Help | 8 | 0 | 0% |
|
||||||
|
|
||||||
@@ -37,7 +37,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
|
|||||||
- View > Orbit > Free Orbit
|
- View > Orbit > Free Orbit
|
||||||
- View > Camera > Adjust Distance
|
- View > Camera > Adjust Distance
|
||||||
- View > Camera > Swivel
|
- View > Camera > Swivel
|
||||||
- View > Viewports > Named Viewports...
|
|
||||||
- View > 3D Views > Viewpoint
|
- View > 3D Views > Viewpoint
|
||||||
- View > 3D Views > Plan View > Current UCS
|
- View > 3D Views > Plan View > Current UCS
|
||||||
- View > 3D Views > Plan View > World UCS
|
- View > 3D Views > Plan View > World UCS
|
||||||
@@ -134,7 +133,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
|
|||||||
- Modify > Object > Image > Quality
|
- Modify > Object > Image > Quality
|
||||||
- Modify > Object > Image > Transparency
|
- Modify > Object > Image > Transparency
|
||||||
- Modify > Object > Image > Frame
|
- Modify > Object > Image > Frame
|
||||||
- Modify > Object > Block Description...
|
|
||||||
- Modify > Clip > Image
|
- Modify > Clip > Image
|
||||||
- Modify > Clip > Xref
|
- Modify > Clip > Xref
|
||||||
- Modify > 3D Operations > 3D Move
|
- Modify > 3D Operations > 3D Move
|
||||||
|
|||||||
Reference in New Issue
Block a user