mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 11:26:46 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05f444c9d4 | ||
|
|
d2ef3eaefb | ||
|
|
f50dce03bd |
Generated
+21
-246
@@ -434,12 +434,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "basic-udev"
|
||||
version = "0.1.2"
|
||||
@@ -553,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -577,7 +571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -588,14 +582,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -605,7 +599,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -616,14 +610,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -632,7 +626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -653,7 +647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust 0.14.0",
|
||||
@@ -663,14 +657,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -680,7 +674,6 @@ dependencies = [
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-pdf",
|
||||
"miniz_oxide 0.9.1",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
@@ -691,7 +684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -703,7 +696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -717,7 +710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -740,7 +733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -875,12 +868,6 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
@@ -959,28 +946,14 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -1338,7 +1311,7 @@ dependencies = [
|
||||
"ecolor",
|
||||
"emath",
|
||||
"epaint_default_fonts",
|
||||
"font-types 0.12.7",
|
||||
"font-types",
|
||||
"harfrust 0.12.0",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
@@ -1454,15 +1427,6 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float_next_after"
|
||||
version = "1.0.0"
|
||||
@@ -1475,15 +1439,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.7"
|
||||
@@ -1627,16 +1582,6 @@ dependencies = [
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -1830,39 +1775,6 @@ dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
|
||||
dependencies = [
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
@@ -2020,22 +1932,6 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -2196,32 +2092,6 @@ dependencies = [
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"imagesize",
|
||||
"indexmap",
|
||||
"pdf-writer",
|
||||
"png",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
@@ -2957,18 +2827,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdf-writer"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peniko"
|
||||
version = "0.6.1"
|
||||
@@ -3295,16 +3153,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.41.0"
|
||||
@@ -3312,7 +3160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types 0.12.7",
|
||||
"font-types",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -3324,7 +3172,7 @@ checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"font-types 0.12.7",
|
||||
"font-types",
|
||||
"once_cell",
|
||||
"yoke",
|
||||
]
|
||||
@@ -3613,16 +3461,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.44.0"
|
||||
@@ -3766,29 +3604,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"rustc-hash 2.1.3",
|
||||
"skrifa 0.42.1",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -3943,12 +3758,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -4085,21 +3894,6 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -4895,19 +4689,6 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "write-fonts"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8"
|
||||
dependencies = [
|
||||
"font-types 0.11.3",
|
||||
"indexmap",
|
||||
"kurbo",
|
||||
"log",
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
@@ -4977,15 +4758,9 @@ version = "0.8.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xmp-writer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+3
-11
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -25,10 +25,7 @@ cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
# 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 = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
# 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 = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -53,7 +50,7 @@ encoding_rs = "0.8.35"
|
||||
quick-xml = "0.42"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_kittest = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
@@ -61,11 +58,6 @@ egui-wgpu = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Op
|
||||
}
|
||||
|
||||
fn snapshot(ui: &UiState) -> Result<Value, String> {
|
||||
cadcraft_ui_egui::docking::validate(&ui.docking)?;
|
||||
ui.docking.validate()?;
|
||||
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
|
||||
return Err("toolsetTab must be Drafting or Modeling".into());
|
||||
}
|
||||
@@ -85,7 +85,7 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
|
||||
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
|
||||
if let Some(docking) = docking
|
||||
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
|
||||
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
|
||||
&& docking.validate().is_ok()
|
||||
{
|
||||
ui.docking = docking;
|
||||
}
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::{Panel, permissions};
|
||||
use cadcraft_ui_egui::docking::Panel;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
@@ -244,58 +244,19 @@ mod tests {
|
||||
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
|
||||
ui.docking = app.ui.docking;
|
||||
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
|
||||
ui.docking.set_visible(Panel::ToolSets, false).unwrap();
|
||||
ui.show_toolsets = false;
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
let (_, reloaded, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none());
|
||||
let mut reloaded = reloaded.unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap();
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap();
|
||||
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
|
||||
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_docking_file_still_loads() {
|
||||
use cadcraft_ui_egui::docking::{Panel, reveal_panel};
|
||||
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
|
||||
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
|
||||
let file = r#"{"version":1,"workspace":{
|
||||
"docking":{
|
||||
"layout":{
|
||||
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
|
||||
"first":{"Tabs":{"panels":["canvas"],"active":0}},
|
||||
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
|
||||
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
|
||||
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
|
||||
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
|
||||
"showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
fs::write(&path, file).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
let mut ui = restored.unwrap();
|
||||
assert_eq!(ui.toolset_tab, "Modeling");
|
||||
let docking = &ui.docking;
|
||||
assert_eq!(docking.layout.floating.len(), 1);
|
||||
let window = &docking.layout.floating[0];
|
||||
assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}}));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"],
|
||||
json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}})
|
||||
);
|
||||
assert!(!docking.layout.contains(&Panel::ToolSets));
|
||||
reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap();
|
||||
let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(tools.anchor, Some(Panel::Canvas));
|
||||
assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}]));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
|
||||
let directory = temp_dir();
|
||||
|
||||
@@ -87,7 +87,3 @@ real_name = "Richard Sauer"
|
||||
display_name = "Richard Sauer"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
@@ -177,11 +177,6 @@ pub struct DimStyle {
|
||||
pub dim_line_color: Color,
|
||||
pub ext_line_color: Color,
|
||||
pub text_color: Color,
|
||||
/// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color,
|
||||
/// 2 `text_fill_color`.
|
||||
pub text_fill: u8,
|
||||
/// DIMTFILLCLR: the text background color when `text_fill` is 2.
|
||||
pub text_fill_color: Color,
|
||||
pub text_style: String,
|
||||
/// DIMPOST prefix/suffix "<>" template.
|
||||
pub post: String,
|
||||
@@ -301,8 +296,6 @@ pub const DIMVARS: &[(&str, &str)] = &[
|
||||
("DIMCLRD", "dimLineColor"),
|
||||
("DIMCLRE", "extLineColor"),
|
||||
("DIMCLRT", "textColor"),
|
||||
("DIMTFILL", "textFill"),
|
||||
("DIMTFILLCLR", "textFillColor"),
|
||||
("DIMTXSTY", "textStyle"),
|
||||
("DIMPOST", "post"),
|
||||
("DIMCEN", "centerMark"),
|
||||
@@ -374,8 +367,6 @@ impl Default for DimStyle {
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
text_fill: 0,
|
||||
text_fill_color: Color::ByBlock,
|
||||
text_style: "Standard".into(),
|
||||
post: String::new(),
|
||||
center_mark: 0.09,
|
||||
@@ -485,19 +476,10 @@ impl DimStyle {
|
||||
let coerced = match (&old, &coerced) {
|
||||
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
|
||||
(Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => {
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64)
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64)
|
||||
}
|
||||
(Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced,
|
||||
// A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0.
|
||||
(Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => {
|
||||
let min = if field == "angularDecimals" { -1.0 } else { 0.0 };
|
||||
match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) {
|
||||
Some(x) => serde_json::json!(x.max(0.0) as u64),
|
||||
None => {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64)
|
||||
}
|
||||
_ => coerced,
|
||||
};
|
||||
@@ -507,7 +489,7 @@ impl DimStyle {
|
||||
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
|
||||
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
|
||||
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
|
||||
"textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0),
|
||||
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
|
||||
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
|
||||
_ => false,
|
||||
|
||||
@@ -897,7 +897,7 @@ fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
}
|
||||
|
||||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||||
pub(super) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||||
fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
|
||||
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
|
||||
|
||||
@@ -74,12 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
burst::burst_spec(),
|
||||
blockreplace::blockreplace_spec(),
|
||||
extract::dataextraction_spec(),
|
||||
extract::eattext_spec(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(attribs::attdisp_menu_specs())
|
||||
.chain(xref::specs())
|
||||
.chain(refedit::specs())
|
||||
.chain(bedit::specs())
|
||||
@@ -90,19 +88,12 @@ pub(crate) fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// The block an entity draws, if any: a block reference's block, a dimension's block or a
|
||||
/// multileader's block content.
|
||||
/// The block an entity references, when it is a block reference.
|
||||
fn referenced_block(e: &Entity) -> Option<String> {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => dm.block.clone(),
|
||||
EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()),
|
||||
_ => None,
|
||||
}
|
||||
if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }
|
||||
}
|
||||
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references,
|
||||
/// dimension blocks and multileader block content.
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
||||
pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -887,4 +878,3 @@ impl Interactive for AttdefM {
|
||||
|
||||
mod attribs;
|
||||
mod battman;
|
||||
mod blockreplace;
|
||||
|
||||
@@ -16,22 +16,6 @@ pub(super) fn attdisp_spec() -> CommandSpec {
|
||||
CommandSpec::new("attdisp", "Attribute Display", run_attdisp)
|
||||
.params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}")
|
||||
.interactive(|_| Ok(Box::new(AttdispM)))
|
||||
.transparent()
|
||||
}
|
||||
|
||||
/// Returns the View > Attribute Display > Normal, On and Off commands, which set ATTMODE as the matching ATTDISP option does.
|
||||
pub(super) fn attdisp_menu_specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("attdisp.normal", "Normal", |s, _| set_attmode(s, 1))
|
||||
.menu(&["View", "Attribute Display", "Normal"])
|
||||
.params("{} (ATTDISP Normal: ATTMODE 1) → {attmode}"),
|
||||
CommandSpec::new("attdisp.on", "On", |s, _| set_attmode(s, 2))
|
||||
.menu(&["View", "Attribute Display", "On"])
|
||||
.params("{} (ATTDISP ON: ATTMODE 2) → {attmode}"),
|
||||
CommandSpec::new("attdisp.off", "Off", |s, _| set_attmode(s, 0))
|
||||
.menu(&["View", "Attribute Display", "Off"])
|
||||
.params("{} (ATTDISP OFF: ATTMODE 0) → {attmode}"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn attsync_spec() -> CommandSpec {
|
||||
@@ -100,7 +84,7 @@ impl Interactive for AttdispM {
|
||||
/// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and
|
||||
/// shown as defined, keeping the values of attributes whose tag (any case) is still defined.
|
||||
/// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD.
|
||||
pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
defs.iter()
|
||||
.map(|ad| {
|
||||
@@ -135,16 +119,12 @@ fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, name: &str) -> Ve
|
||||
/// Apply `f` to every reference of block `name` (in model space, layouts and other blocks);
|
||||
/// returns how many references there were.
|
||||
fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize {
|
||||
update_reference_entities(d, name, |e| {
|
||||
let mut n = 0;
|
||||
let mut update = |e: &mut Entity| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
f(ins);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// [`update_references`], with `f` given the whole object.
|
||||
pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize {
|
||||
let mut n = 0;
|
||||
};
|
||||
let spaces: Vec<cadcraft_doc::Space> =
|
||||
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
|
||||
for sp in spaces {
|
||||
@@ -189,22 +169,18 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R
|
||||
}))
|
||||
}
|
||||
|
||||
/// The attribute definitions of `blk` that references carry (constant ones are not).
|
||||
pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> {
|
||||
blk.entities
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs: Vec<&Attrib> = blk
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) if !a.constant => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
|
||||
/// Returns the block's name as defined and how many references were updated.
|
||||
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
.collect();
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins));
|
||||
Ok((blk.name.clone(), n))
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space,
|
||||
//! the layouts and other blocks, becomes a reference of another block at the same insertion
|
||||
//! point, scale and rotation. The unreferenced block definition can then be purged.
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::attribs::{attdefs, synced, update_reference_entities};
|
||||
use super::purge::{override_arrows, used_blocks};
|
||||
use super::references_block;
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase};
|
||||
use crate::cmd::{CommandSpec, bad, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(super) fn blockreplace_spec() -> CommandSpec {
|
||||
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
|
||||
.menu(&["Tools", "Express Tools", "Replace Block..."])
|
||||
.alias(&["-blockreplace"])
|
||||
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
|
||||
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
|
||||
}
|
||||
|
||||
/// The defined name of block `name` (names are case-insensitive), when it is a named block of
|
||||
/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved.
|
||||
fn named_block(d: &Drawing, name: &str) -> Option<String> {
|
||||
d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their
|
||||
/// property values in data that names the block.
|
||||
fn not_replaceable(d: &Drawing, old: &str) -> Option<String> {
|
||||
cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))
|
||||
}
|
||||
|
||||
/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new`
|
||||
/// reference itself.
|
||||
fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option<String> {
|
||||
let blk = d.block(new)?;
|
||||
references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::<Vec<_>>(), old)
|
||||
.then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))
|
||||
}
|
||||
|
||||
/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s
|
||||
/// definition when nothing references it afterwards. Returns (references replaced, purged).
|
||||
fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> {
|
||||
let cmd = "blockreplace";
|
||||
if s.state()?.block_edit.is_some() {
|
||||
return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.refedit.is_some() {
|
||||
return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first"));
|
||||
}
|
||||
if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) {
|
||||
return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim())));
|
||||
}
|
||||
let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?;
|
||||
let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?;
|
||||
if old.eq_ignore_ascii_case(&new) {
|
||||
return Err(bad(cmd, "the replacement block must be a different block"));
|
||||
}
|
||||
if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) {
|
||||
return Err(bad(cmd, why));
|
||||
}
|
||||
let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?;
|
||||
let defs = attdefs(&blk);
|
||||
let scale = d.current_anno_scale();
|
||||
let d = s.doc_mut()?;
|
||||
let n = update_reference_entities(d, &old, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind {
|
||||
ins.block.clone_from(&blk.name);
|
||||
ins.attribs = synced(&blk, &defs, ins);
|
||||
}
|
||||
// A reference of an annotative block is annotative, as INSERT makes it.
|
||||
if !blk.annotative {
|
||||
e.common.anno_scales.clear();
|
||||
} else if e.common.anno_scales.is_empty() {
|
||||
e.common.anno_scales = vec![scale.clone()];
|
||||
}
|
||||
});
|
||||
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned();
|
||||
let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some());
|
||||
Ok((n, purged))
|
||||
}
|
||||
|
||||
/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions,
|
||||
/// multileaders and block references of every other block definition, used or not.
|
||||
fn referenced(d: &Drawing, name: &str) -> bool {
|
||||
if used_blocks(d).contains(&name.to_ascii_uppercase()) {
|
||||
return true;
|
||||
}
|
||||
let names = |e: &Entity| -> Vec<String> {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
d.blocks
|
||||
.values()
|
||||
.filter(|b| !b.name.eq_ignore_ascii_case(name))
|
||||
.flat_map(|b| b.entities.iter())
|
||||
.any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
fn run_blockreplace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "blockreplace";
|
||||
let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?;
|
||||
let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?;
|
||||
let purge = match p.get("purge") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))),
|
||||
};
|
||||
let (old, new) = (old.to_string(), new.to_string());
|
||||
let (n, purged) = replace(s, &old, &new, purge)?;
|
||||
let d = s.doc()?;
|
||||
let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string());
|
||||
Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Old,
|
||||
New,
|
||||
Select,
|
||||
Purge,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BlockreplaceM {
|
||||
stage: Stage,
|
||||
/// The stage a picked reference answers.
|
||||
back: Stage,
|
||||
sel: SelectPhase,
|
||||
old: String,
|
||||
new: String,
|
||||
}
|
||||
|
||||
impl BlockreplaceM {
|
||||
/// Take `name` as the answer to the current name prompt. A block that cannot take part is
|
||||
/// refused here, so the prompt asks again.
|
||||
fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> {
|
||||
let d = s.doc()?;
|
||||
let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?;
|
||||
if stage == Stage::Old {
|
||||
if let Some(why) = not_replaceable(d, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.old = name;
|
||||
self.stage = Stage::New;
|
||||
} else {
|
||||
if name.eq_ignore_ascii_case(&self.old) {
|
||||
return Err(EngineError::Other("The replacement block must be a different block.".into()));
|
||||
}
|
||||
if let Some(why) = cannot_replace_with(d, &self.old, &name) {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
self.new = name;
|
||||
self.stage = Stage::Purge;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self, s: &mut Session, purge: bool) -> Result<Step> {
|
||||
let (n, purged) = replace(s, &self.old, &self.new, purge)?;
|
||||
s.echo(format!(
|
||||
"{n} reference{} of block {} replaced with {}.{}",
|
||||
if n == 1 { "" } else { "s" },
|
||||
self.old,
|
||||
self.new,
|
||||
if purged { format!(" Block {} purged.", self.old) } else { String::new() }
|
||||
));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BlockreplaceM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BLOCKREPLACE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
|
||||
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
stage @ (Stage::Old | Stage::New) => match &i {
|
||||
Input::Keyword(k) if k == "?" => {
|
||||
let d = s.doc()?;
|
||||
let mut names: Vec<String> = d.blocks.keys().filter_map(|k| named_block(d, k)).collect();
|
||||
names.sort_by_key(|n| n.to_ascii_lowercase());
|
||||
s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) });
|
||||
}
|
||||
Input::Keyword(k) if k == "Select" => {
|
||||
self.back = stage;
|
||||
self.sel = SelectPhase::single();
|
||||
self.stage = Stage::Select;
|
||||
}
|
||||
Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?,
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
_ => {}
|
||||
},
|
||||
Stage::Select => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => self.stage = self.back,
|
||||
SelOutcome::Done(hs) => {
|
||||
s.set_selection(Vec::new());
|
||||
self.sel = SelectPhase::single();
|
||||
let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(ins)) => Some(ins.block.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match block {
|
||||
Some(b) => self.take(s, self.back, &b)?,
|
||||
None => return Err(EngineError::Other("That object is not a block reference.".into())),
|
||||
}
|
||||
}
|
||||
},
|
||||
Stage::Purge => match &i {
|
||||
Input::Keyword(k) if k == "No" => return self.finish(s, false),
|
||||
Input::Keyword(_) | Input::Enter => return self.finish(s, true),
|
||||
Input::Cancel => return Ok(Step::Done),
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
|
||||
}
|
||||
|
||||
/// The arrowhead block names a dimension's overrides set.
|
||||
pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
dm.overrides
|
||||
.iter()
|
||||
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
|
||||
|
||||
@@ -1083,9 +1083,7 @@ impl Interactive for RevcloudM {
|
||||
let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"];
|
||||
match (self.kind, self.pts.len()) {
|
||||
(CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Rect, _) => {
|
||||
Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) }
|
||||
}
|
||||
(CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
(CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
(CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
@@ -1633,9 +1631,10 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
|
||||
let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let hd = s.add_entity(k)?;
|
||||
// A rectangle has its own thickness option and does not take THICKNESS.
|
||||
let th = f64_or(p, "thickness", 0.0);
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
|
||||
if th != 0.0 {
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
|
||||
}
|
||||
added(hd)
|
||||
}
|
||||
|
||||
@@ -1684,8 +1683,10 @@ impl RectM2 {
|
||||
}
|
||||
let first = self.origin(w, h).unwrap_or(a);
|
||||
let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
if self.thickness != 0.0 {
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
|
||||
|
||||
@@ -26,10 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key("Cmd+X")
|
||||
.params("{handles?}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))),
|
||||
CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase)
|
||||
.menu(&["Edit", "Cut with Base Point"])
|
||||
.params("{handles?, base: [x,y]}")
|
||||
.interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))),
|
||||
CommandSpec::new("copyclip", "Copy", run_copyclip)
|
||||
.menu(&["Edit", "Copy"])
|
||||
.key("Cmd+C")
|
||||
@@ -438,11 +434,9 @@ impl Interactive for MredoM {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it.
|
||||
fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<usize> {
|
||||
let d = s.doc()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let ents: Vec<Entity> = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0)));
|
||||
let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO });
|
||||
let n = ents.len();
|
||||
@@ -457,59 +451,22 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_copybase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("copybase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let n = copy_to_clip(s, &hs, Some(base))?;
|
||||
let base = point_param(p, "base");
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
Ok(json!({ "copied": n }))
|
||||
}
|
||||
|
||||
/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how
|
||||
/// many were cut and the message to show: how many were on a locked layer, and that nothing was
|
||||
/// cut when that is all of them (the clipboard then keeps what it held).
|
||||
fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<(usize, Option<String>)> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let live: Vec<Handle> = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect()
|
||||
};
|
||||
let (hs, locked): (Vec<Handle>, Vec<Handle>) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h));
|
||||
let note = match locked.len() {
|
||||
0 => None,
|
||||
1 => Some("1 object was on a locked layer.".to_string()),
|
||||
n => Some(format!("{n} objects were on a locked layer.")),
|
||||
};
|
||||
if hs.is_empty() {
|
||||
let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut."));
|
||||
return Ok((0, Some(msg)));
|
||||
}
|
||||
let n = copy_to_clip(s, &hs, base)?;
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut hs = targets(s, p)?;
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let n = copy_to_clip(s, &hs, None)?;
|
||||
let d = s.doc_mut()?;
|
||||
for h in &hs {
|
||||
d.remove_entity(*h);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok((n, note))
|
||||
}
|
||||
|
||||
fn cut_result(n: usize, msg: Option<String>) -> Value {
|
||||
let mut r = json!({ "cut": n });
|
||||
if let Some(m) = msg {
|
||||
r["message"] = json!(m);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, None)?;
|
||||
Ok(cut_result(n, msg))
|
||||
}
|
||||
|
||||
fn run_cutbase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = point_req("cutbase", p, "base")?;
|
||||
let hs = targets(s, p)?;
|
||||
let (n, msg) = cut_to_clip(s, &hs, Some(base))?;
|
||||
Ok(cut_result(n, msg))
|
||||
Ok(json!({ "cut": n }))
|
||||
}
|
||||
|
||||
/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its
|
||||
@@ -692,31 +649,23 @@ impl Interactive for SelectM {
|
||||
}
|
||||
}
|
||||
|
||||
/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst
|
||||
/// selection). CUTBASE also erases the objects it copies.
|
||||
/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection).
|
||||
#[derive(Default)]
|
||||
struct CopyBaseM {
|
||||
base: Option<Vec2>,
|
||||
sel: SelectPhase,
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl CopyBaseM {
|
||||
fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
if self.cut {
|
||||
if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? {
|
||||
s.echo(m);
|
||||
}
|
||||
} else {
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
}
|
||||
copy_to_clip(s, hs, self.base)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for CopyBaseM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.cut { "CUTBASE" } else { "COPYBASE" }
|
||||
"COPYBASE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! drawing). Objects are matched by geometry and properties first (preferring the same handle),
|
||||
//! then by handle: the same handle with other geometry or properties is a change. What is left
|
||||
//! is only in this drawing or only in the other. The comparison stays on the session for the
|
||||
//! canvas overlay, next/previous navigation and COMPAREEXPORT until COMPARECLOSE.
|
||||
//! canvas overlay and next/previous navigation until COMPARECLOSE.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -16,8 +16,6 @@ use super::super::*;
|
||||
use super::{base64_decode, io};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod export;
|
||||
|
||||
/// Objects compared per drawing; more are left out (and the result says so).
|
||||
pub const MAX_OBJECTS: usize = 200_000;
|
||||
/// Objects listed per kind of difference in the JSON result.
|
||||
@@ -38,9 +36,6 @@ pub struct Comparison {
|
||||
pub other_name: String,
|
||||
/// The other drawing (its objects are drawn where they differ).
|
||||
pub other: Arc<Drawing>,
|
||||
/// The other drawing's file, when it has one: COMPAREEXPORT resolves its relative xref
|
||||
/// paths from that file's folder.
|
||||
pub other_path: Option<String>,
|
||||
/// Only in this drawing (handles here).
|
||||
pub added: Vec<Handle>,
|
||||
/// Only in the other drawing (handles there).
|
||||
@@ -76,7 +71,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(),
|
||||
CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(),
|
||||
CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(),
|
||||
export::spec(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -139,23 +133,11 @@ struct Signer<'a> {
|
||||
d: &'a Drawing,
|
||||
blocks: HashMap<String, u64>,
|
||||
work: usize,
|
||||
/// When set, a block's signature also covers its base point, units, annotative and overlay
|
||||
/// flags and xref file, with relative xref, image and data link paths taken from the folder
|
||||
/// given, and the
|
||||
/// signatures of a multileader and a dimension cover their blocks'. Each object in a block
|
||||
/// then also counts with its visibility, transparency, thickness and extrusion, and an
|
||||
/// anonymous block's signature leaves out its name (a dimension also leaves its block's name
|
||||
/// out of its own geometry; an INSERT or MLEADER keeps it).
|
||||
frames: Option<Option<&'a std::path::Path>>,
|
||||
}
|
||||
|
||||
impl<'a> Signer<'a> {
|
||||
fn new(d: &'a Drawing) -> Self {
|
||||
Signer { d, blocks: HashMap::new(), work: 0, frames: None }
|
||||
}
|
||||
/// A signer whose block signatures also cover what changes how a reference draws the block.
|
||||
fn with_frames(d: &'a Drawing, folder: Option<&'a std::path::Path>) -> Self {
|
||||
Signer { frames: Some(folder), ..Signer::new(d) }
|
||||
Signer { d, blocks: HashMap::new(), work: 0 }
|
||||
}
|
||||
fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -174,18 +156,6 @@ impl<'a> Signer<'a> {
|
||||
if let EntityKind::Insert(i) = k {
|
||||
self.block(&i.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::MLeader(m) = k
|
||||
&& let Some(b) = &m.block
|
||||
{
|
||||
self.block(&b.block, depth).hash(&mut h);
|
||||
}
|
||||
if self.frames.is_some()
|
||||
&& let EntityKind::Dimension(dm) = k
|
||||
&& let Some(b) = &dm.block
|
||||
{
|
||||
self.block(b, depth).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
fn block(&mut self, name: &str, depth: usize) -> u64 {
|
||||
@@ -194,42 +164,17 @@ impl<'a> Signer<'a> {
|
||||
return *v;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let block = self.d.block(name);
|
||||
// With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the
|
||||
// same block can have another generated name in another drawing. Only a dimension's
|
||||
// geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references.
|
||||
if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) {
|
||||
key.hash(&mut h);
|
||||
}
|
||||
key.hash(&mut h);
|
||||
if depth < MAX_BLOCK_DEPTH
|
||||
&& let Some(b) = block
|
||||
&& let Some(b) = self.d.block(name)
|
||||
{
|
||||
if let Some(folder) = self.frames {
|
||||
let mut xref = b.xref_path.clone();
|
||||
if let Some(x) = xref.as_mut() {
|
||||
export::rebase_path(x, folder);
|
||||
}
|
||||
(format!("{:?}", b.base), b.units, b.annotative, b.xref_overlay, xref).hash(&mut h);
|
||||
}
|
||||
for e in b.entities.iter() {
|
||||
if self.work >= MAX_BLOCK_WORK {
|
||||
break;
|
||||
}
|
||||
self.work += 1;
|
||||
match self.frames {
|
||||
// An image's file and a table's data link, from the folder given.
|
||||
Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => {
|
||||
let mut k = e.kind.clone();
|
||||
export::rebase(&mut k, folder);
|
||||
self.geometry(&k, depth + 1).hash(&mut h);
|
||||
}
|
||||
_ => self.geometry(&e.kind, depth + 1).hash(&mut h),
|
||||
}
|
||||
self.geometry(&e.kind, depth + 1).hash(&mut h);
|
||||
properties(e).hash(&mut h);
|
||||
if self.frames.is_some() {
|
||||
let c = &e.common;
|
||||
format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = h.finish();
|
||||
@@ -366,7 +311,6 @@ fn listed(d: &Drawing, hs: &[Handle]) -> Vec<Value> {
|
||||
|
||||
fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (other, name) = other_drawing(s, p)?;
|
||||
let other_path = export::source_path(s, p, &other);
|
||||
let st = s.state()?;
|
||||
let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone());
|
||||
if Arc::ptr_eq(&cur, &other) {
|
||||
@@ -417,7 +361,7 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"message": message,
|
||||
});
|
||||
let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect();
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, other_path, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None });
|
||||
s.touch();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
//! COMPAREEXPORT: the comparison as a new drawing (a snapshot). The drawings are compared again
|
||||
//! when the command runs, so edits made since COMPARE count. The new drawing's model space holds
|
||||
//! copies of the other drawing's objects that differ, then copies of the current drawing's
|
||||
//! objects in the compared tab, as two groups, `COMPARE_COMPARED` and `COMPARE_CURRENT`. Objects
|
||||
//! only in this drawing or changed in it are drawn in the overlay's green, objects only in the
|
||||
//! other drawing or changed there in its red, and objects in both in gray (color 8). A block
|
||||
//! reference, dimension or multileader refers to a copy of its block whose objects all have that
|
||||
//! color (`COMPARE_CURRENT_<name>`, `COMPARE_COMPARED_<name>`, `COMPARE_COMMON_<name>`; for a
|
||||
//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy
|
||||
//! of its style; xrefs keep their own colors. The new
|
||||
//! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other
|
||||
//! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of
|
||||
//! the other drawing whose name this one uses is copied under a free name. Relative xref, image
|
||||
//! and data link paths become absolute paths from the folder of the drawing they came from. It
|
||||
//! opens untitled as `compare_<this> vs <other>.dwg`, with unsaved changes; saving it asks for a
|
||||
//! file name.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
use super::{Comparison, MAX_OBJECTS};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Only in this drawing, or changed in it (the overlay's color).
|
||||
const THIS: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
|
||||
/// Only in the other drawing, or changed there.
|
||||
const OTHER: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
|
||||
/// In both drawings.
|
||||
const COMMON: Color = Color::Index(8);
|
||||
/// Block definitions copied from the other drawing (the ones they reference included), and
|
||||
/// recolored copies of blocks and styles, per export.
|
||||
const MAX_BLOCKS: usize = 10_000;
|
||||
/// Objects copied into block definitions (the other drawing's blocks and the recolored
|
||||
/// copies), per export.
|
||||
const MAX_COPIED: usize = 1_000_000;
|
||||
|
||||
pub(super) fn spec() -> CommandSpec {
|
||||
CommandSpec::new("compareexport", "Export Comparison", run)
|
||||
.params(
|
||||
"{} → {index, title, current, compared: group names, counts: {current, compared}, truncated, message}: the comparison \
|
||||
as a new drawing, opened untitled",
|
||||
)
|
||||
.noundo()
|
||||
}
|
||||
|
||||
/// What [`snapshot`] made: the drawing, the two group names, how many objects each holds,
|
||||
/// whether a drawing had more objects than were compared, whether the other drawing had more
|
||||
/// blocks to copy than [`MAX_BLOCKS`], and whether a block was left in its own colors.
|
||||
struct Snapshot {
|
||||
drawing: Drawing,
|
||||
current: String,
|
||||
compared: String,
|
||||
counts: (usize, usize),
|
||||
truncated: bool,
|
||||
blocks_left_out: bool,
|
||||
uncolored: bool,
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.state()?;
|
||||
let (uid, cur, title, cur_folder) = (st.uid, st.doc.clone(), st.title.clone(), folder_of(st.path.as_deref()));
|
||||
let c = s.compare.as_ref().filter(|c| c.uid == uid).ok_or_else(|| bad("compareexport", "no comparison: run COMPARE first"))?;
|
||||
let name = format!("compare_{} vs {}.dwg", stem(&title), stem(&c.other_name));
|
||||
let folders = Folders { here: cur_folder, there: folder_of(c.other_path.as_deref()) };
|
||||
let Snapshot { drawing, current, compared, counts, truncated, blocks_left_out, uncolored } = snapshot(&cur, c, &folders);
|
||||
let i = s.open_drawing(drawing, &name, None);
|
||||
// Not saved yet: closing it asks to save it under a name.
|
||||
let st = s.state_mut()?;
|
||||
st.saved = Arc::new((*st.doc).clone());
|
||||
let mut notes = Vec::new();
|
||||
if truncated {
|
||||
notes.push(format!("only the first {MAX_OBJECTS} objects of each drawing were compared"));
|
||||
}
|
||||
if blocks_left_out {
|
||||
notes.push("some blocks of the other drawing were too many or too large to copy".to_string());
|
||||
}
|
||||
if uncolored {
|
||||
notes.push("some blocks keep their own colors".to_string());
|
||||
}
|
||||
let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
|
||||
Ok(json!({
|
||||
"index": i,
|
||||
"title": name,
|
||||
"current": current,
|
||||
"compared": compared,
|
||||
"counts": { "current": counts.0, "compared": counts.1 },
|
||||
"truncated": truncated || blocks_left_out || uncolored,
|
||||
"message": format!("The comparison was exported to the new drawing {name}{notes}."),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file of the drawing COMPARE compares with: the `path` it was read from, or the open
|
||||
/// drawing's own file. `None` for `{data}` and for an open drawing never saved.
|
||||
pub(super) fn source_path(s: &Session, p: &Value, other: &Arc<Drawing>) -> Option<String> {
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) if p.get("document").is_none_or(Value::is_null) => Some(path.to_string()),
|
||||
_ => s.docs.iter().find(|d| Arc::ptr_eq(&d.doc, other)).and_then(|d| d.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders of the two drawings' files. The snapshot has no folder of its own, so the
|
||||
/// relative file paths it copies (xrefs, images, table data links) become absolute paths from
|
||||
/// the folder of the drawing they came from.
|
||||
struct Folders {
|
||||
here: Option<PathBuf>,
|
||||
there: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The absolute folder of the file at `path`.
|
||||
fn folder_of(path: Option<&str>) -> Option<PathBuf> {
|
||||
let parent = Path::new(path?).parent()?;
|
||||
let parent = if parent.as_os_str().is_empty() { Path::new(".") } else { parent };
|
||||
Some(std::path::absolute(parent).unwrap_or_else(|_| parent.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `p` as an absolute path from `folder` when it is relative.
|
||||
pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) {
|
||||
if let Some(folder) = folder
|
||||
&& !p.trim().is_empty()
|
||||
&& Path::new(p.as_str()).is_relative()
|
||||
{
|
||||
let joined = folder.join(p.as_str());
|
||||
*p = std::path::absolute(&joined).unwrap_or(joined).to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase the file an object refers to: an image's file, a table's data link.
|
||||
pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) {
|
||||
match k {
|
||||
EntityKind::Image(i) => rebase_path(&mut i.path, folder),
|
||||
EntityKind::Table(t) => {
|
||||
if let Some(l) = t.data_link.as_mut() {
|
||||
rebase_path(&mut l.path, folder);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebase a block's xref path and the files its objects refer to.
|
||||
fn rebase_block(b: &mut Block, folder: Option<&Path>) {
|
||||
if let Some(x) = b.xref_path.as_mut() {
|
||||
rebase_path(x, folder);
|
||||
}
|
||||
let mut store = EntityStore::new();
|
||||
for e in b.entities.iter() {
|
||||
let mut e = (**e).clone();
|
||||
rebase(&mut e.kind, folder);
|
||||
store.push(e);
|
||||
}
|
||||
b.entities = store;
|
||||
}
|
||||
|
||||
/// Whether a block holds a relative file path that [`rebase_block`] would change.
|
||||
fn has_relative_paths(b: &Block) -> bool {
|
||||
let rel = |p: &str| !p.trim().is_empty() && Path::new(p).is_relative();
|
||||
b.xref_path.as_deref().is_some_and(rel)
|
||||
|| b.entities.iter().any(|e| match &e.kind {
|
||||
EntityKind::Image(i) => rel(&i.path),
|
||||
EntityKind::Table(t) => t.data_link.as_ref().is_some_and(|l| rel(&l.path)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A drawing title without its file extension.
|
||||
fn stem(title: &str) -> String {
|
||||
std::path::Path::new(title).file_stem().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| title.to_string())
|
||||
}
|
||||
|
||||
fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
let other: &Drawing = &c.other;
|
||||
let mut d = cur.clone();
|
||||
if folders.here.is_some() {
|
||||
for b in d.blocks.values_mut().filter(|b| has_relative_paths(b)) {
|
||||
rebase_block(Arc::make_mut(b), folders.here.as_deref());
|
||||
}
|
||||
}
|
||||
d.model = EntityStore::new();
|
||||
for l in &mut d.layouts {
|
||||
l.entities = EntityStore::new();
|
||||
}
|
||||
d.foreign = Arc::default();
|
||||
d.groups.clear();
|
||||
d.constraints.clear();
|
||||
// The per-object constraint bar and dimension exceptions name objects the copy doesn't have.
|
||||
d.parametric.settings.bar_exceptions.clear();
|
||||
d.parametric.settings.dim_exceptions.clear();
|
||||
d.hidden_objects.clear();
|
||||
d.read_notes.clear();
|
||||
d.refedit = None;
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
merge(&mut d.dim_styles, &other.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &other.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &other.table_styles, |t| &t.name);
|
||||
merge(&mut d.mline_styles, &other.mline_styles, |t| &t.name);
|
||||
|
||||
// Compared again, so edits made since COMPARE ran are classified as they are now.
|
||||
let diff = super::diff(cur, other, &c.space);
|
||||
let ours: HashSet<Handle> = diff.added.iter().copied().chain(diff.changed.iter().map(|(a, ..)| *a)).collect();
|
||||
let here: Vec<(&Entity, Color)> = cur
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.take(MAX_OBJECTS)
|
||||
.map(|e| (e.as_ref(), if ours.contains(&e.handle) { THIS } else { COMMON }))
|
||||
.collect();
|
||||
// In the other drawing's draw order.
|
||||
let theirs: HashSet<Handle> = diff.removed.iter().copied().chain(diff.changed.iter().map(|(_, b, ..)| *b)).collect();
|
||||
let there: Vec<(&Entity, Color)> = other
|
||||
.space(&c.space)
|
||||
.into_iter()
|
||||
.flat_map(EntityStore::iter)
|
||||
.filter(|e| theirs.contains(&e.handle) && !matches!(e.kind, EntityKind::Viewport(_)))
|
||||
.map(|e| (e.as_ref(), OTHER))
|
||||
.collect();
|
||||
let mut tinted = Tinted::default();
|
||||
let (renames, blocks_left_out) = copy_blocks(&mut d, other, there.iter().map(|(e, _)| *e), folders.there.as_deref(), &mut tinted);
|
||||
|
||||
// The other drawing's objects first, so this drawing's are drawn over them.
|
||||
let compared = add_group(&mut d, "COMPARE_COMPARED", &there, &renames, folders.there.as_deref(), &mut tinted);
|
||||
let current = add_group(&mut d, "COMPARE_CURRENT", &here, &Renames::new(), folders.here.as_deref(), &mut tinted);
|
||||
let uncolored = tinted.uncolored;
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
for (e, color) in objs {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
rebase(&mut copy.kind, folder);
|
||||
rename_refs(&mut copy.kind, renames);
|
||||
paint(d, &mut copy, *color, tinted, 0);
|
||||
d.model.push(copy);
|
||||
members.push(h);
|
||||
}
|
||||
let name = free_name(name, d.groups.len(), |n| d.groups.iter().any(|g| g.name.eq_ignore_ascii_case(n)));
|
||||
if !members.is_empty() {
|
||||
d.groups.push(Group { name: name.clone(), description: String::new(), selectable: true, members });
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Drop the links of an associative dimension or hatch to the objects it follows: the copies
|
||||
/// have new handles, so the links would name objects the snapshot does not have.
|
||||
fn detach(k: &mut EntityKind) {
|
||||
match k {
|
||||
EntityKind::Dimension(dm) => dm.assoc.clear(),
|
||||
EntityKind::Hatch(h) => {
|
||||
h.associative = false;
|
||||
h.loops.iter_mut().for_each(|l| l.sources.clear());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The block names an object refers to.
|
||||
fn block_refs(k: &EntityKind) -> Vec<String> {
|
||||
match k {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::Dimension(dm) => dm.block.iter().cloned().collect(),
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` on each block name an object refers to.
|
||||
fn each_ref(k: &mut EntityKind, mut f: impl FnMut(&mut String)) {
|
||||
match k {
|
||||
EntityKind::Insert(i) => f(&mut i.block),
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Some(n) = dm.block.as_mut() {
|
||||
f(n);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(b) = m.block.as_mut() {
|
||||
f(&mut b.block);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the block references of an object at the names its blocks were copied under.
|
||||
fn rename_refs(k: &mut EntityKind, renames: &Renames) {
|
||||
each_ref(k, |n| {
|
||||
if let Some(new) = renames.get(&n.to_ascii_uppercase()) {
|
||||
n.clone_from(new);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Recolored copies of blocks and styles made so far, and whether a block was left in its own
|
||||
/// colors because the nesting or copy limit was reached.
|
||||
#[derive(Default)]
|
||||
struct Tinted {
|
||||
/// (comparison tag, "block", "table style" or "dimension style", upper-case name) → the
|
||||
/// copy's name.
|
||||
names: HashMap<(&'static str, &'static str, String), String>,
|
||||
uncolored: bool,
|
||||
/// Objects copied into block definitions so far, counted against [`MAX_COPIED`].
|
||||
copied: usize,
|
||||
next: NextFree,
|
||||
/// The drawing's block names in upper case, filled on the first block copy. Only
|
||||
/// [`tinted_block`] adds blocks after that, and it adds their names here too.
|
||||
blocks: Option<HashSet<String>>,
|
||||
}
|
||||
|
||||
/// The next index to try for each name base. Names are only added while a snapshot is built,
|
||||
/// so an index found taken stays taken, and each search starts where the last one for the same
|
||||
/// base stopped: a name costs O(1) amortised instead of a scan from 1.
|
||||
#[derive(Default)]
|
||||
struct NextFree(HashMap<(&'static str, String), usize>);
|
||||
|
||||
impl NextFree {
|
||||
/// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices
|
||||
/// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are,
|
||||
/// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name.
|
||||
fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String {
|
||||
let key = (kind, base.to_ascii_uppercase());
|
||||
let start = self.0.get(&key).copied().unwrap_or(1);
|
||||
let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start));
|
||||
self.0.insert(key, i.saturating_add(1));
|
||||
name(i)
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` for 1, `base_2`, `base_3`… after it.
|
||||
fn suffixed(base: &str, i: usize) -> String {
|
||||
if i == 1 { base.to_string() } else { format!("{base}_{i}") }
|
||||
}
|
||||
|
||||
/// The word a comparison color adds to the names of its recolored copies.
|
||||
fn tag(color: Color) -> &'static str {
|
||||
if color == THIS {
|
||||
"CURRENT"
|
||||
} else if color == OTHER {
|
||||
"COMPARED"
|
||||
} else {
|
||||
"COMMON"
|
||||
}
|
||||
}
|
||||
|
||||
/// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are.
|
||||
fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String {
|
||||
(1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2))
|
||||
}
|
||||
|
||||
/// Give an object its comparison color: its own color, its attributes' colors, and, through
|
||||
/// recolored copies of the blocks and styles it refers to, the color of everything they draw.
|
||||
fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, depth: usize) {
|
||||
e.common.color = color;
|
||||
let attribs = match &mut e.kind {
|
||||
EntityKind::Insert(i) => Some(&mut i.attribs),
|
||||
EntityKind::MLeader(m) => m.block.as_mut().map(|b| &mut b.attribs),
|
||||
_ => None,
|
||||
};
|
||||
for a in attribs.into_iter().flatten() {
|
||||
a.props.color = Some(color);
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
// Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes
|
||||
// too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element
|
||||
// colors, the style colors of a table or a tolerance (through a recolored copy of the style)
|
||||
// and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out.
|
||||
match &mut e.kind {
|
||||
EntityKind::MText(t) => t.contents = without_color_codes(&t.contents),
|
||||
EntityKind::AttDef(a) => {
|
||||
if let Some(ml) = a.multiline.as_mut() {
|
||||
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = m.text.as_mut() {
|
||||
t.contents = without_color_codes(&t.contents);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Ok(by_block) = serde_json::to_value(Color::ByBlock) {
|
||||
for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] {
|
||||
dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var));
|
||||
dm.overrides.insert(var.into(), by_block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Tolerance(t) => {
|
||||
if let Some(style) = tinted_dim_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) => {
|
||||
if let Some(g) = h.gradient.as_mut() {
|
||||
(g.color1, g.color2) = (color, color);
|
||||
}
|
||||
h.background = None;
|
||||
}
|
||||
EntityKind::MLine(m) => {
|
||||
for el in &mut m.def.elements {
|
||||
el.color = Color::ByBlock;
|
||||
}
|
||||
m.def.fill = None;
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
for cell in t.cells.iter_mut().flatten() {
|
||||
cell.text = without_color_codes(&cell.text);
|
||||
}
|
||||
if let Some(style) = tinted_table_style(d, &t.style, color, tinted) {
|
||||
t.style = style;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
// A dimension whose block can't be copied (past the limits) is drawn again from its
|
||||
// geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors.
|
||||
dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth));
|
||||
return;
|
||||
}
|
||||
each_ref(&mut e.kind, |n| {
|
||||
if let Some(copy) = tinted_block(d, n, color, tinted, depth) {
|
||||
*n = copy;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The name of a copy of dimension style `name` that draws lines and text in the object's
|
||||
/// color, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "dimension style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"dimension style",
|
||||
&base,
|
||||
d.dim_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
d.dim_styles.push(DimStyle {
|
||||
name: new.clone(),
|
||||
dim_line_color: Color::ByBlock,
|
||||
ext_line_color: Color::ByBlock,
|
||||
text_color: Color::ByBlock,
|
||||
..st
|
||||
});
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name of a copy of table style `name` whose cell styles draw text and borders in the
|
||||
/// table's color and fill no cells, made on first use. `None` when the drawing has no such style.
|
||||
fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
|
||||
let key = (tag(color), "table style", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
|
||||
let base = format!("COMPARE_{}_{}", tag(color), st.name);
|
||||
let new = tinted.next.find(
|
||||
"table style",
|
||||
&base,
|
||||
d.table_styles.len(),
|
||||
|i| suffixed(&base, i),
|
||||
|n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
|
||||
);
|
||||
let names =
|
||||
[CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone()));
|
||||
let mut cell_styles: Vec<CellStyle> = Vec::new();
|
||||
for n in names {
|
||||
if cell_styles.iter().any(|c| c.name.eq_ignore_ascii_case(&n)) {
|
||||
continue;
|
||||
}
|
||||
let mut cs = st.cell_style(&n);
|
||||
cs.name = n;
|
||||
cs.text_color = Color::ByBlock;
|
||||
cs.fill = None;
|
||||
let b = &mut cs.borders;
|
||||
for border in [&mut b.top, &mut b.inside_h, &mut b.bottom, &mut b.left, &mut b.inside_v, &mut b.right] {
|
||||
border.color = Color::ByBlock;
|
||||
}
|
||||
cell_styles.push(cs);
|
||||
}
|
||||
d.table_styles.push(TableStyle { name: new.clone(), cell_styles, ..st });
|
||||
tinted.names.insert(key, new.clone());
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// MTEXT contents without inline color codes (`\C1;`, `\c255;`), so the text takes the
|
||||
/// object's color. An escaped backslash (`\\`) stays.
|
||||
fn without_color_codes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match it.peek().copied() {
|
||||
Some('C' | 'c') => {
|
||||
for d in it.by_ref() {
|
||||
if d == ';' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(n) => {
|
||||
out.push('\\');
|
||||
out.push(n);
|
||||
it.next();
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The name of a copy of block `name` whose objects all have `color`, made on first use. `None`
|
||||
/// (the reference keeps the original block) for an xref, a missing block, or past the nesting
|
||||
/// and copy limits.
|
||||
fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, depth: usize) -> Option<String> {
|
||||
let key = (tag(color), "block", name.to_ascii_uppercase());
|
||||
if let Some(n) = tinted.names.get(&key) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let b = d.block(name)?.clone();
|
||||
if b.xref_path.is_some() {
|
||||
return None;
|
||||
}
|
||||
if depth >= MAX_BLOCK_DEPTH || tinted.names.len() >= MAX_BLOCKS || tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
tinted.uncolored = true;
|
||||
return None;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
// A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of
|
||||
// drawing the dimension again.
|
||||
let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D"));
|
||||
let Tinted { next, blocks, .. } = tinted;
|
||||
let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let new = if dim_block {
|
||||
next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken)
|
||||
} else {
|
||||
let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*'));
|
||||
next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken)
|
||||
};
|
||||
taken.insert(new.to_ascii_uppercase());
|
||||
tinted.names.insert(key, new.clone());
|
||||
// Hold the name while the nested copies pick theirs.
|
||||
d.blocks.insert(new.clone(), Arc::new(Block::new(&new)));
|
||||
let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
paint(d, &mut e, color, tinted, depth + 1);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
d.blocks.insert(new.clone(), Arc::new(copy));
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// The name each block copied from the other drawing has in the snapshot, by its upper-case
|
||||
/// name there.
|
||||
type Renames = HashMap<String, String>;
|
||||
|
||||
/// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks.
|
||||
/// A named block `d` already has keeps `d`'s definition when it draws the same thing there (the
|
||||
/// same objects, base point, units, flags and xref file, in it and in the blocks it refers to);
|
||||
/// otherwise the other drawing's block is copied as `PART_2` (or `PART_3`…). An anonymous block
|
||||
/// (`*D3`, `*U7`…) whose name `d` already uses is copied under the next free name with the same
|
||||
/// prefix. Copied
|
||||
/// objects get new handles, and relative file paths are rebased on `folder`. The flag is true
|
||||
/// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were
|
||||
/// left out.
|
||||
fn copy_blocks<'a>(
|
||||
d: &mut Drawing,
|
||||
other: &Drawing,
|
||||
objs: impl Iterator<Item = &'a Entity>,
|
||||
folder: Option<&Path>,
|
||||
tinted: &mut Tinted,
|
||||
) -> (Renames, bool) {
|
||||
let mut frontier: Vec<String> = objs.flat_map(|e| block_refs(&e.kind)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut needed: Vec<Arc<Block>> = Vec::new();
|
||||
let mut left_out = false;
|
||||
// `d`'s relative paths were made absolute from its own folder before the copy.
|
||||
let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder));
|
||||
// Whether `d`'s block `n` draws what `other`'s block `b` draws. Past the signers' work limit
|
||||
// the signatures no longer cover every object, so the blocks count as different.
|
||||
let mut same = |n: &str, b: &Block| {
|
||||
!b.anonymous
|
||||
&& d.block(n).is_some()
|
||||
&& mine.block(n, 0) == theirs.block(n, 0)
|
||||
&& mine.work < super::MAX_BLOCK_WORK
|
||||
&& theirs.work < super::MAX_BLOCK_WORK
|
||||
};
|
||||
while let Some(n) = frontier.pop() {
|
||||
let key = n.to_ascii_uppercase();
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() >= MAX_BLOCKS {
|
||||
left_out |= other.block(&n).is_some_and(|b| !same(&n, b));
|
||||
continue;
|
||||
}
|
||||
seen.insert(key);
|
||||
let Some(b) = other.block(&n) else { continue };
|
||||
if same(&n, b) {
|
||||
continue;
|
||||
}
|
||||
if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
|
||||
left_out = true;
|
||||
continue;
|
||||
}
|
||||
tinted.copied += b.entities.len();
|
||||
frontier.extend(b.entities.iter().flat_map(|e| block_refs(&e.kind)));
|
||||
needed.push(b.clone());
|
||||
}
|
||||
// Names `d` does not use stay; the others get free names, avoiding the kept ones.
|
||||
let mut renames = Renames::new();
|
||||
let mut taken: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
let mut next = NextFree::default();
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_none()) {
|
||||
taken.insert(b.name.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), b.name.clone());
|
||||
}
|
||||
for b in needed.iter().filter(|b| d.block(&b.name).is_some()) {
|
||||
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
|
||||
let free = if b.anonymous {
|
||||
let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect();
|
||||
next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken)
|
||||
} else {
|
||||
next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken)
|
||||
};
|
||||
taken.insert(free.to_ascii_uppercase());
|
||||
renames.insert(b.name.to_ascii_uppercase(), free);
|
||||
}
|
||||
for b in needed {
|
||||
let Some(name) = renames.get(&b.name.to_ascii_uppercase()).cloned() else { continue };
|
||||
let mut copy = Block { name: name.clone(), entities: EntityStore::new(), ..(*b).clone() };
|
||||
for e in b.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
let mut e = Entity { handle: h, ..(**e).clone() };
|
||||
detach(&mut e.kind);
|
||||
rename_refs(&mut e.kind, &renames);
|
||||
copy.entities.push(e);
|
||||
}
|
||||
rebase_block(&mut copy, folder);
|
||||
d.blocks.insert(name, Arc::new(copy));
|
||||
}
|
||||
(renames, left_out)
|
||||
}
|
||||
@@ -401,17 +401,8 @@ pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
|
||||
(a, per)
|
||||
}
|
||||
|
||||
/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an
|
||||
/// error, never a fall back to the selection.
|
||||
fn given_points(p: &Value, cmd: &str) -> Result<Option<Vec<Vec2>>> {
|
||||
match p.get("points") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, per) = if let Some(pts) = given_points(p, "area")? {
|
||||
let (a, per) = if let Some(pts) = points_param(p, "points") {
|
||||
area_of(&pts)
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
|
||||
@@ -424,37 +415,6 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
|
||||
format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per))
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object
|
||||
/// as AREA measures it (an open polyline as if closed), times the absolute value of a height.
|
||||
pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = if let Some(pts) = given_points(p, "volume.measure")? {
|
||||
if pts.len() < 3 {
|
||||
return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points"));
|
||||
}
|
||||
area_of(&pts).0
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?;
|
||||
object_area(s, h)?.0
|
||||
};
|
||||
let height = f64_req("volume.measure", p, "height")?;
|
||||
let v = a * height.abs();
|
||||
if !v.is_finite() {
|
||||
return Err(bad("volume.measure", "the volume is too large"));
|
||||
}
|
||||
Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) }))
|
||||
}
|
||||
|
||||
fn volume_text(s: &Session, v: f64) -> String {
|
||||
format!("Volume = {}", fmt_volume(s, v))
|
||||
}
|
||||
|
||||
/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units
|
||||
/// show it as a decimal number, not in feet and inches.
|
||||
fn fmt_volume(s: &Session, v: f64) -> String {
|
||||
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
|
||||
}
|
||||
|
||||
/// Area and perimeter of a closed object (AREA's Object option).
|
||||
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
let d = s.doc()?;
|
||||
@@ -929,19 +889,9 @@ pub(super) struct AreaM {
|
||||
object: bool,
|
||||
/// Asking for the length of a segment (the Length option).
|
||||
length: bool,
|
||||
/// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height.
|
||||
volume: bool,
|
||||
/// Areas measured in volume mode that still wait for their height, oldest first.
|
||||
pending: Vec<f64>,
|
||||
/// The first point of a height given by two points.
|
||||
height_from: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl AreaM {
|
||||
/// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area.
|
||||
pub(super) fn volume() -> Self {
|
||||
AreaM { volume: true, ..AreaM::default() }
|
||||
}
|
||||
fn mode_prefix(&self) -> &'static str {
|
||||
match self.mode {
|
||||
AreaMode::Single => "",
|
||||
@@ -949,49 +899,8 @@ impl AreaM {
|
||||
AreaMode::Subtract => "(SUBTRACT mode) ",
|
||||
}
|
||||
}
|
||||
/// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and
|
||||
/// "Subtract volume".
|
||||
fn mode_keywords(&self) -> (&'static str, &'static str) {
|
||||
if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") }
|
||||
}
|
||||
/// The running total with `v` added or subtracted; in single mode, the total as it is.
|
||||
fn total_with(&self, v: f64) -> f64 {
|
||||
match self.mode {
|
||||
AreaMode::Single => self.total,
|
||||
AreaMode::Add => self.total + v,
|
||||
AreaMode::Subtract => self.total - v,
|
||||
}
|
||||
}
|
||||
/// Measure the oldest pending area's volume with height `h`.
|
||||
fn height(&mut self, s: &mut Session, h: f64) -> Step {
|
||||
match self.pending.first().map(|a| a * h.abs()) {
|
||||
Some(v) if v.is_finite() && self.total_with(v).is_finite() => {
|
||||
self.pending.remove(0);
|
||||
self.volume_measured(s, v)
|
||||
}
|
||||
_ => {
|
||||
s.echo("The volume is too large.");
|
||||
Step::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Report a volume (and the running total in add/subtract mode); ends the command in single mode.
|
||||
fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step {
|
||||
s.echo(volume_text(s, v));
|
||||
if self.mode == AreaMode::Single {
|
||||
return Step::Done;
|
||||
}
|
||||
self.total = self.total_with(v);
|
||||
s.echo(format!("Total volume = {}", fmt_volume(s, self.total)));
|
||||
Step::Continue
|
||||
}
|
||||
/// 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.
|
||||
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
|
||||
if self.volume {
|
||||
self.pending.push(a);
|
||||
return Step::Continue;
|
||||
}
|
||||
s.echo(area_text(s, a, per));
|
||||
match self.mode {
|
||||
AreaMode::Single => return Step::Done,
|
||||
@@ -1014,15 +923,9 @@ impl AreaM {
|
||||
|
||||
impl Interactive for AreaM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.volume { "MEASUREGEOM" } else { "AREA" }
|
||||
"AREA"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if !self.pending.is_empty() {
|
||||
return match self.height_from {
|
||||
Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
||||
None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER),
|
||||
};
|
||||
}
|
||||
if self.object {
|
||||
return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT);
|
||||
}
|
||||
@@ -1030,35 +933,17 @@ impl Interactive for AreaM {
|
||||
return length_prompt();
|
||||
}
|
||||
let next = format!("{}Specify next point", self.mode_prefix());
|
||||
let (add, subtract) = self.mode_keywords();
|
||||
match (self.pts.len(), self.mode) {
|
||||
(0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"),
|
||||
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]),
|
||||
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]),
|
||||
(0, AreaMode::Single) => {
|
||||
Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object")
|
||||
}
|
||||
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]),
|
||||
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]),
|
||||
(1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.pending.is_empty() {
|
||||
// A height is typed, or given by two points (their distance).
|
||||
match (i, self.height_from) {
|
||||
(Input::Point(p), None) => self.height_from = Some(p),
|
||||
(Input::Point(p), Some(from)) => {
|
||||
self.height_from = None;
|
||||
return Ok(self.height(s, p.dist(from)));
|
||||
}
|
||||
(Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
|
||||
Some(h) => return Ok(self.height(s, h)),
|
||||
None => s.echo("Requires a distance."),
|
||||
},
|
||||
(Input::Enter, None) => s.echo("Requires a height."),
|
||||
(Input::Enter, Some(_)) => s.echo("Requires a second point."),
|
||||
(Input::Text(_), Some(_)) => s.echo("Requires a point."),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.object {
|
||||
match i {
|
||||
Input::Pick(hs) => {
|
||||
@@ -1068,10 +953,6 @@ impl Interactive for AreaM {
|
||||
if self.measured(s, a, per) == Step::Done {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
// A single volume takes the first object with an area.
|
||||
if self.volume && self.mode == AreaMode::Single {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => s.echo("The selected object has no area."),
|
||||
}
|
||||
@@ -1099,8 +980,8 @@ impl Interactive for AreaM {
|
||||
Input::Point(p) => self.pts.push(p),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Object" => self.object = true,
|
||||
"Add area" | "Add volume" => self.mode = AreaMode::Add,
|
||||
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
|
||||
"Add area" => self.mode = AreaMode::Add,
|
||||
"Subtract area" => self.mode = AreaMode::Subtract,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
|
||||
@@ -637,13 +637,13 @@ impl Interactive for MviewM {
|
||||
Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW)
|
||||
.kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"])
|
||||
.default("Right"),
|
||||
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
|
||||
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::Polygon(pi) => pi.prompt(),
|
||||
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
|
||||
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
|
||||
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
|
||||
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, point_req, points_param, str_param, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
|
||||
|
||||
@@ -7,10 +7,12 @@ use cadcraft_geom::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::*;
|
||||
use super::helpers::{arc, circle, line, lwpoly, v3};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::trimextend::{self, TrimM};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, points_param, str_param, targets};
|
||||
|
||||
use crate::select::{SelectKind, SelectMode};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
@@ -517,7 +519,7 @@ impl Interactive for StretchM {
|
||||
return m.prompt();
|
||||
}
|
||||
match (self.phase, self.corner) {
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c),
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
|
||||
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
|
||||
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
|
||||
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
|
||||
@@ -1397,17 +1399,21 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
let line_entity = |l: &Vec<Vec2>| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) })
|
||||
}
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
|
||||
let text_marks = &g.text_marks;
|
||||
let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l);
|
||||
let mut v: Vec<Entity> = marks(false).filter_map(&line_entity).collect();
|
||||
let mut v: Vec<Entity> = g
|
||||
.lines
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for t in &g.fills {
|
||||
if let [a, b, c] = t.as_slice() {
|
||||
v.push(Entity {
|
||||
@@ -1417,20 +1423,6 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and
|
||||
// arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
let fill = style.text_fill_color;
|
||||
let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill };
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: cadcraft_doc::Common { color, ..e.common.clone() },
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }),
|
||||
});
|
||||
}
|
||||
v.extend(marks(true).filter_map(&line_entity));
|
||||
if !g.value.is_empty() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
|
||||
@@ -9,9 +9,11 @@ use cadcraft_geom::{
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves::{self, Chain, MAX_GEN, TanObj};
|
||||
use super::helpers::*;
|
||||
use super::helpers::{arc, line, lwpoly, v3};
|
||||
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, str_param, targets};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod curve_edits;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! PEDIT Edit vertex: Break and Straighten between two marked vertices, Tangent directions
|
||||
//! that Fit honours, and Regen.
|
||||
|
||||
use super::*;
|
||||
use super::super::point_value;
|
||||
use super::{
|
||||
EntityKind, Handle, Input, LwPolyline, PPhase, PeditM, PolyVertex, Result, Session, Value, Vec2, bad, curves, json, other, push_entity, set_kind,
|
||||
};
|
||||
|
||||
fn ordered(pl: &LwPolyline, i: usize, j: usize) -> Result<(usize, usize)> {
|
||||
let n = pl.vertices.len();
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
//! parametrisation, end tangents and fit tolerance. Control-point edits change the curve
|
||||
//! directly and drop the fit data, as the curve no longer follows it.
|
||||
|
||||
use super::*;
|
||||
use super::super::{point_value, points_param};
|
||||
use super::{
|
||||
Accept, Entity, EntityKind, Handle, Input, Interactive, LwPolyline, MAX_GEN, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Space,
|
||||
Spline, Step, Value, Vec2, bad, bool_or, curves, f64_req, json, line, number, other, point_req, set_kind, str_param, targets,
|
||||
};
|
||||
|
||||
/// The spline after a JSON SPLINEDIT option (`option` plus that option's parameters).
|
||||
pub(super) fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -365,7 +365,7 @@ impl Interactive for PlotWindowM {
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
|
||||
@@ -32,11 +32,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
|
||||
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
|
||||
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
|
||||
CommandSpec::new("thickness", "Thickness", run_thickness)
|
||||
.menu(&["Format", "Thickness"])
|
||||
.params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}")
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ThicknessM))),
|
||||
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
|
||||
CommandSpec::new("style", "Text Style...", run_style)
|
||||
.menu(&["Format", "Text Style..."])
|
||||
@@ -539,56 +534,6 @@ impl Interactive for LtscaleM {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest THICKNESS, either way.
|
||||
const MAX_THICKNESS: f64 = 1e12;
|
||||
|
||||
fn thickness_in_range(t: f64) -> bool {
|
||||
t.is_finite() && t.abs() <= MAX_THICKNESS
|
||||
}
|
||||
|
||||
fn run_thickness(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = f64_req("thickness", p, "thickness")?;
|
||||
if !thickness_in_range(t) {
|
||||
return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)"));
|
||||
}
|
||||
s.doc_mut()?.header.set_f64("THICKNESS", t);
|
||||
Ok(json!({ "thickness": t }))
|
||||
}
|
||||
|
||||
/// Format > Thickness and typed THICKNESS: "Enter new value for THICKNESS <0.0000>:".
|
||||
struct ThicknessM;
|
||||
|
||||
impl Interactive for ThicknessM {
|
||||
fn name(&self) -> &'static str {
|
||||
"THICKNESS"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let cur = s.doc().map(|d| d.header.f64("THICKNESS", 0.0)).unwrap_or(0.0);
|
||||
Prompt::new("Enter new value for THICKNESS", Accept::NUMBER).default(format!("{cur:.4}"))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match i {
|
||||
Input::Enter | Input::Cancel => return Ok(Step::Done),
|
||||
Input::Text(t) => t,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
match super::machines::number(&text) {
|
||||
Some(t) if thickness_in_range(t) => {
|
||||
run_thickness(s, &json!({ "thickness": t }))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Some(_) => {
|
||||
s.echo("Requires a value between -1e12 and 1e12.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None => {
|
||||
s.echo("Requires a number.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Segment, Vec2};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -27,24 +27,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
|
||||
.params("{mode: distance|radius|angle|area|volume, ...}")
|
||||
.params("{mode: distance|radius|angle|area, ...}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<MeasureM>::default())),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius)
|
||||
.menu(&["Tools", "Inquiry", "Radius"])
|
||||
.params("{handle, point?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(RadiusM))),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle)
|
||||
.menu(&["Tools", "Inquiry", "Angle"])
|
||||
.params("{vertex, p1, p2} | {h1, h2}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<AngleM>::default())),
|
||||
CommandSpec::new("volume.measure", "Volume", super::inquiry::run_volume)
|
||||
.menu(&["Tools", "Inquiry", "Volume"])
|
||||
.params("{points: [[x,y],...] (3 or more) | handle (a closed 2D object), height} → {volume, area, height}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(super::inquiry::AreaM::volume()))),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -296,70 +283,11 @@ fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Circle(c)) => c.radius,
|
||||
Some(EntityKind::Arc(a)) => a.radius,
|
||||
Some(EntityKind::LwPolyline(_)) => {
|
||||
let at = point_param(p, "point").ok_or_else(|| bad("radius.measure", "`point` on the polyline's arc segment is required"))?;
|
||||
match super::annotate::segment_at(s, h, at) {
|
||||
Some(Segment::Arc { arc, .. }) => arc.radius,
|
||||
_ => return Err(bad("radius.measure", "the polyline segment at `point` is not an arc")),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("radius.measure", "select an arc, circle or polyline arc")),
|
||||
_ => return Err(bad("radius.measure", "select an arc or circle")),
|
||||
};
|
||||
Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) }))
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Radius option and Tools > Inquiry > Radius: the radius and diameter of a picked
|
||||
/// arc, circle or polyline arc segment. Tools > Inquiry > Radius measures a selected arc or circle
|
||||
/// at once.
|
||||
struct RadiusM;
|
||||
|
||||
impl RadiusM {
|
||||
/// Measure the arc, circle or polyline arc segment at the picked point; a pick on anything else keeps the prompt.
|
||||
fn pick(s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Input::Point(p) = i else { return Ok(Step::Done) };
|
||||
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0, "point": [p.x, p.y] })) {
|
||||
Ok(r) => {
|
||||
echo_message(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(_) => {
|
||||
s.echo("Select an arc, circle or polyline arc.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RadiusM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASUREGEOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Select arc or circle", Accept::POINT)
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let Some(h) = s.selection().first().copied() else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0 })) {
|
||||
Ok(r) => {
|
||||
echo_message(s, &r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(_) => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
Self::pick(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo each line of a result's `message`.
|
||||
fn echo_message(s: &mut Session, r: &Value) {
|
||||
for l in r["message"].as_str().unwrap_or_default().lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(v, a, b)
|
||||
@@ -371,29 +299,17 @@ fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
};
|
||||
let (l1, l2) =
|
||||
let ((a1, a2), (b1, b2)) =
|
||||
(line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?);
|
||||
lines_vertex(l1, l2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?
|
||||
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
|
||||
(x, far(a1, a2), far(b1, b2))
|
||||
};
|
||||
let ang = vertex_angle(v, a, b);
|
||||
Ok(json!({ "angle": ang, "message": angle_text(ang) }))
|
||||
}
|
||||
|
||||
/// The point where two lines (extended) meet and the end of each line farther from it; `None` when they are parallel.
|
||||
fn lines_vertex((a1, a2): (Vec2, Vec2), (b1, b2): (Vec2, Vec2)) -> Option<(Vec2, Vec2, Vec2)> {
|
||||
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
|
||||
Some((x, far(a1, a2), far(b1, b2)))
|
||||
}
|
||||
|
||||
/// The angle at `v` between the directions to `a` and `b`, in degrees from 0 to 180.
|
||||
fn vertex_angle(v: Vec2, a: Vec2, b: Vec2) -> f64 {
|
||||
let ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
|
||||
if ang > 180.0 { 360.0 - ang } else { ang }
|
||||
}
|
||||
|
||||
fn angle_text(ang: f64) -> String {
|
||||
format!("Angle = {ang:.4}°")
|
||||
let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
|
||||
if ang > 180.0 {
|
||||
ang = 360.0 - ang;
|
||||
}
|
||||
Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") }))
|
||||
}
|
||||
|
||||
fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -402,12 +318,11 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"radius" | "r" => run_radius(s, p),
|
||||
"angle" | "a" => run_angle(s, p),
|
||||
"area" | "ar" => s.execute("area", p),
|
||||
"volume" | "v" => super::inquiry::run_volume(s, p),
|
||||
_ => Err(bad("measuregeom", "mode must be distance, radius, angle, area or volume")),
|
||||
_ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")),
|
||||
}
|
||||
}
|
||||
|
||||
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA, a radius pick or an angle pick.
|
||||
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick.
|
||||
#[derive(Default)]
|
||||
struct MeasureM {
|
||||
inner: Option<Box<dyn Interactive>>,
|
||||
@@ -424,7 +339,7 @@ impl Interactive for MeasureM {
|
||||
Some(m) => m.prompt(s),
|
||||
None if self.radius => Prompt::new("Select arc or circle", Accept::POINT),
|
||||
None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true })
|
||||
.kw(&["Distance", "Radius", "Angle", "ARea", "Volume"])
|
||||
.kw(&["Distance", "Radius", "ARea"])
|
||||
.default("Distance"),
|
||||
}
|
||||
}
|
||||
@@ -433,15 +348,24 @@ impl Interactive for MeasureM {
|
||||
return m.input(s, i);
|
||||
}
|
||||
if self.radius {
|
||||
return RadiusM::pick(s, i);
|
||||
let Input::Point(p) = i else { return Ok(Step::Done) };
|
||||
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
|
||||
match run_radius(s, &json!({ "handle": h.0 })) {
|
||||
Ok(r) => {
|
||||
for l in r["message"].as_str().unwrap_or_default().lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Err(_) => s.echo("Select an arc or circle."),
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Radius" => self.radius = true,
|
||||
"Angle" => self.inner = Some(Box::<AngleM>::default()),
|
||||
"ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())),
|
||||
"Volume" => self.inner = Some(Box::new(super::inquiry::AreaM::volume())),
|
||||
_ => self.inner = Some(Box::new(super::inquiry::DistM::default())),
|
||||
},
|
||||
_ => {}
|
||||
@@ -453,103 +377,6 @@ impl Interactive for MeasureM {
|
||||
}
|
||||
}
|
||||
|
||||
/// MEASUREGEOM's Angle option and Tools > Inquiry > Angle: the angle of an arc, of two points on a
|
||||
/// circle about its center, between two lines or polyline line segments, or (Enter) at a vertex
|
||||
/// between two endpoints.
|
||||
#[derive(Default)]
|
||||
struct AngleM {
|
||||
/// The first line picked.
|
||||
first: Option<(Vec2, Vec2)>,
|
||||
/// The vertex and endpoints given so far (Enter, or a circle's center and the point picked on it).
|
||||
vertex: Option<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
impl AngleM {
|
||||
fn finish(s: &mut Session, ang: f64) -> Result<Step> {
|
||||
s.echo(angle_text(ang));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AngleM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MEASUREGEOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(pts) = &self.vertex {
|
||||
let msg = ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"].get(pts.len()).copied().unwrap_or("");
|
||||
return Prompt::new(msg, Accept::POINT).base_opt(pts.first().copied());
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
|
||||
Some(_) => Prompt::new("Select second line", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(pts) = &mut self.vertex {
|
||||
return match i {
|
||||
Input::Point(p) => {
|
||||
pts.push(p);
|
||||
match pts.as_slice() {
|
||||
[v, a, b] => Self::finish(s, vertex_angle(*v, *a, *b)),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
let p = match i {
|
||||
Input::Enter if self.first.is_none() => {
|
||||
self.vertex = Some(Vec::new());
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Point(p) => p,
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let Some(h) = super::curves::pick_at(s, p) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let circle = s
|
||||
.doc()?
|
||||
.entity(h)
|
||||
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
|
||||
if let Some(c) = circle.filter(|_| self.first.is_none()) {
|
||||
self.vertex = Some(vec![c.center, c.closest(p)]);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match super::annotate::segment_at(s, h, p) {
|
||||
Some(Segment::Arc { arc, .. }) if self.first.is_none() => Self::finish(s, arc.sweep().to_degrees()),
|
||||
Some(Segment::Line(l)) => match self.first {
|
||||
None => {
|
||||
self.first = Some((l.a, l.b));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(first) => match lines_vertex(first, (l.a, l.b)) {
|
||||
Some((v, a, b)) => Self::finish(s, vertex_angle(v, a, b)),
|
||||
None => {
|
||||
s.echo("The lines are parallel.");
|
||||
Ok(Step::Done)
|
||||
}
|
||||
},
|
||||
},
|
||||
_ => {
|
||||
s.echo(if self.first.is_none() { "Select an arc, circle or line." } else { "Select a line." });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.vertex.as_deref() {
|
||||
Some([v]) => vec![super::helpers::line(*v, c)],
|
||||
Some([v, a]) => vec![super::helpers::line(*v, *a), super::helpers::line(*v, c)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -279,7 +279,7 @@ impl Interactive for ViewM {
|
||||
Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT),
|
||||
Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT),
|
||||
Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]),
|
||||
Stage::Snapshot => {
|
||||
let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" };
|
||||
|
||||
@@ -885,19 +885,6 @@ impl Session {
|
||||
if d.annotative_by_style(&kind) {
|
||||
common.anno_scales = vec![d.current_anno_scale()];
|
||||
}
|
||||
// Lines, arcs, circles, points, polylines and 2D solids take the current 3D thickness
|
||||
// (THICKNESS). RECTANG sets its own, and text never takes it.
|
||||
if matches!(
|
||||
kind,
|
||||
EntityKind::Line(_)
|
||||
| EntityKind::Arc(_)
|
||||
| EntityKind::Circle(_)
|
||||
| EntityKind::Point(_)
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Solid(_)
|
||||
) {
|
||||
common.thickness = Some(d.header.f64("THICKNESS", 0.0)).filter(|t| t.is_finite()).unwrap_or(0.0);
|
||||
}
|
||||
let h = self.doc_mut()?.add(&space, common, kind)?;
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
@@ -434,7 +434,7 @@ impl SelectMode {
|
||||
};
|
||||
match last {
|
||||
None => Prompt::new(first, Accept::POINT),
|
||||
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p),
|
||||
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).base(p),
|
||||
Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,13 +379,6 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
return r;
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
// A dimension variable takes only the values its style field takes, as in DIMSTYLE and DIMOVERRIDE.
|
||||
if let Some((_, field)) = cadcraft_doc::DIMVARS.iter().find(|(var, _)| *var == n) {
|
||||
let mut st = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default();
|
||||
if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
let val = match v {
|
||||
Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)),
|
||||
Value::Number(x) => cadcraft_doc::HVal::Real(x.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?),
|
||||
|
||||
@@ -1309,64 +1309,6 @@ fn typed_ltscale_asks_for_the_scale() {
|
||||
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_thickness_asks_for_the_thickness() {
|
||||
let mut s = Session::new();
|
||||
s.start("thickness").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter new value for THICKNESS <0.0000>"), "{}", s.prompt_text());
|
||||
// After text that is not a number, the prompt asks again.
|
||||
s.cmdline("thick").unwrap();
|
||||
assert!(s.current_prompt().is_some(), "asked again");
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Requires a number."));
|
||||
// A value out of range says so, and the prompt asks again.
|
||||
s.cmdline("2e12").unwrap();
|
||||
assert!(s.current_prompt().is_some(), "asked again");
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12."));
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0);
|
||||
// A negative thickness is accepted.
|
||||
s.cmdline("-1.5").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
|
||||
// Typed, Enter keeps the current thickness.
|
||||
s.cmdline("thickness").unwrap();
|
||||
assert!(s.prompt_text().contains("<-1.5000>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
|
||||
assert!(s.execute("thickness", &json!({"thickness": "thick"})).is_err());
|
||||
assert!(s.execute("thickness", &json!({"thickness": 1e13})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_objects_take_the_current_thickness() {
|
||||
let mut s = Session::new();
|
||||
s.execute("thickness", &json!({"thickness": 2.5})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
s.execute("point", &json!({"at": [1, 1]})).unwrap();
|
||||
s.execute("pline", &json!({"vertices": [[0, 0], [5, 0], [5, 5]]})).unwrap();
|
||||
s.execute("polygon", &json!({"sides": 6, "center": [20, 20], "radius": 2})).unwrap();
|
||||
s.execute("donut", &json!({"center": [30, 30], "inside": 1, "outside": 2})).unwrap();
|
||||
let thick: Vec<(&str, f64)> = s.doc().unwrap().model.iter().map(|e| (e.kind.type_name(), e.common.thickness)).collect();
|
||||
assert_eq!(thick.len(), 7, "{thick:?}");
|
||||
assert!(thick.iter().all(|(_, t)| *t == 2.5), "{thick:?}");
|
||||
// RECTANG keeps its own thickness option, and text does not take THICKNESS.
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "thickness": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 1.0);
|
||||
let before = s.doc().unwrap().model.len();
|
||||
s.cmdline("rectang 0,0 4,4").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), before + 1);
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
s.execute("text", &json!({"at": [0, 0], "text": "A"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
// An ellipse has no thickness.
|
||||
s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
|
||||
}
|
||||
|
||||
fn setvar_prompt_open(s: &Session) -> bool {
|
||||
s.running.as_ref().is_some_and(|r| r.id == "setvar")
|
||||
}
|
||||
|
||||
@@ -170,118 +170,6 @@ fn pasteblock_makes_a_block_of_the_clipboard() {
|
||||
assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutbase_cuts_with_the_given_base_point() {
|
||||
let mut s = Session::empty();
|
||||
let mut da = drawing_a();
|
||||
if let Some(l) = da.layer_mut("WALLS") {
|
||||
l.locked = true;
|
||||
}
|
||||
let a = s.open_drawing(da, "A", None);
|
||||
let b = s.open_drawing(drawing_b(), "B", None);
|
||||
s.active = a;
|
||||
let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap();
|
||||
// The wall is on a locked layer, so only the door is cut.
|
||||
assert_eq!(r, json!({ "cut": 1, "message": "1 object was on a locked layer." }));
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0));
|
||||
let left = model(&s);
|
||||
assert_eq!(left.len(), 1);
|
||||
assert!(matches!(&left[0].kind, EntityKind::Line(_)), "the wall stays");
|
||||
|
||||
// PASTECLIP places the base point at the insertion point.
|
||||
s.active = b;
|
||||
s.execute("pasteclip", &json!({ "at": [105, 5] })).unwrap();
|
||||
let pasted = model(&s);
|
||||
assert!(matches!(&pasted[0].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0 && i.insert.y == 20.0));
|
||||
|
||||
// Undo in A brings the door back.
|
||||
s.active = a;
|
||||
s.execute("u", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutting_only_locked_objects_keeps_the_clipboard() {
|
||||
let mut s = Session::empty();
|
||||
let mut da = drawing_a();
|
||||
for l in &mut da.layers {
|
||||
l.locked = true;
|
||||
}
|
||||
s.open_drawing(da, "A", None);
|
||||
let hs = handles(&s);
|
||||
s.execute("copyclip", &json!({ "handles": [hs[0]] })).unwrap();
|
||||
let held = s.clipboard.clone();
|
||||
let r = s.execute("cutclip", &json!({ "handles": hs })).unwrap();
|
||||
assert_eq!(r, json!({ "cut": 0, "message": "2 objects were on a locked layer. Nothing was cut." }));
|
||||
assert_eq!(model(&s).len(), 2, "nothing was erased");
|
||||
assert_eq!(s.clipboard.len(), 1, "the clipboard keeps what it held");
|
||||
assert_eq!(s.clipboard[0].handle, held[0].handle);
|
||||
|
||||
// CUTBASE at the command line says so too.
|
||||
s.start("cutbase").unwrap();
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap();
|
||||
let wall = s.doc().unwrap().model.iter().next().unwrap().handle;
|
||||
s.input(cadcraft_engine::Input::Pick(vec![wall])).unwrap();
|
||||
s.input(cadcraft_engine::Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("1 object was on a locked layer. Nothing was cut.")), "{:?}", s.log);
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(model(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() {
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(drawing_a(), "A", None);
|
||||
let hs = handles(&s);
|
||||
for cmd in ["cutbase", "copybase"] {
|
||||
for p in [json!({ "handles": hs }), json!({ "handles": hs, "base": [5] })] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("`base` ([x, y]) is required"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(model(&s).len(), 2, "nothing was cut");
|
||||
assert!(s.clipboard.is_empty(), "nothing was copied");
|
||||
|
||||
// A handle listed twice is copied and cut once.
|
||||
let twice = json!({ "handles": [hs[1], hs[1]], "base": [0, 0] });
|
||||
assert_eq!(s.execute("copybase", &twice).unwrap(), json!({ "copied": 1 }));
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(s.execute("cutbase", &twice).unwrap(), json!({ "cut": 1 }));
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(model(&s).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutbase_asks_for_the_base_point_then_the_objects() {
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(drawing_a(), "A", None);
|
||||
let hs: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
let undo_before = s.state().unwrap().undo.len();
|
||||
s.start("cutbase").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Pick(vec![hs[1]])).unwrap();
|
||||
s.input(cadcraft_engine::Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.clipboard.len(), 1);
|
||||
assert_eq!(s.clipboard_base, Vec2::new(3.0, 4.0));
|
||||
assert_eq!(model(&s).iter().map(|e| e.handle).collect::<Vec<_>>(), [hs[0]]);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step");
|
||||
|
||||
// With a pickfirst selection, the base point is the only prompt.
|
||||
s.set_selection(vec![hs[0]]);
|
||||
s.start("cutbase").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(1.0, 2.0))).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.doc().unwrap().model.is_empty());
|
||||
assert_eq!(s.clipboard_base, Vec2::new(1.0, 2.0));
|
||||
assert!(s.selection().is_empty());
|
||||
}
|
||||
|
||||
/// A stand-in for the DXF writer and reader: "writes" the drawing to memory.
|
||||
static WRITTEN: Mutex<Option<Drawing>> = Mutex::new(None);
|
||||
|
||||
|
||||
@@ -104,40 +104,16 @@ fn navigates_areas_and_closes() {
|
||||
assert!(s.compare.is_none());
|
||||
}
|
||||
|
||||
/// The file-reading hook both tests here share (it is set once per process). Test file format:
|
||||
/// "xref" is a drawing with a reference to xref Y at `y.dwg`; a number x is a drawing holding a
|
||||
/// line from 0,0 to x,0.
|
||||
fn hooks() {
|
||||
#[test]
|
||||
fn compares_a_file_read_through_the_io_hooks() {
|
||||
// Test file format: the bytes are "x" — a drawing holding a line from 0,0 to x,0.
|
||||
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
|
||||
let mut d = Drawing::new_imperial();
|
||||
if bytes == b"xref" {
|
||||
let mut y = Block::new("Y");
|
||||
y.xref_path = Some("y.dwg".into());
|
||||
d.blocks.insert("Y".into(), Arc::new(y));
|
||||
let insert = Insert {
|
||||
block: "Y".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?;
|
||||
return Ok(d);
|
||||
}
|
||||
let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?;
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), line(0.0, 0.0, x, 0.0)).map_err(|e| e.to_string())?;
|
||||
Ok(d)
|
||||
}
|
||||
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_a_file_read_through_the_io_hooks() {
|
||||
hooks();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-compare-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("other.dxf");
|
||||
@@ -218,44 +194,3 @@ fn derived_dimension_fields_are_not_differences() {
|
||||
let r = s.execute("compare", &json!({"document": 0})).unwrap();
|
||||
assert_eq!(counts(&r), (0, 0, 0, 1), "{r}");
|
||||
}
|
||||
|
||||
/// COMPAREEXPORT (#2049): an xref path relative to the compared file keeps that file's folder.
|
||||
#[test]
|
||||
fn relative_xref_paths_of_a_compared_file_take_that_files_folder() {
|
||||
hooks();
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("other.dxf");
|
||||
std::fs::write(&path, "xref").unwrap();
|
||||
// Named by its absolute path and by a path relative to the working directory.
|
||||
let mut names = vec![path.to_string_lossy().to_string()];
|
||||
names.extend(relative_to_cwd(&path));
|
||||
for name in names {
|
||||
let mut s = Session::new();
|
||||
s.execute("compare", &json!({"path": name})).unwrap();
|
||||
// The compared file's xref is only there; the copy keeps the file's folder.
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap();
|
||||
let got = std::path::Path::new(&got);
|
||||
assert!(got.is_absolute(), "{got:?}, compared as {name}");
|
||||
assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg")));
|
||||
assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when
|
||||
/// the two share a root.
|
||||
fn relative_to_cwd(p: &std::path::Path) -> Option<String> {
|
||||
use std::path::Component;
|
||||
let cwd = std::env::current_dir().ok()?;
|
||||
let (a, b): (Vec<Component>, Vec<Component>) = (cwd.components().collect(), p.components().collect());
|
||||
let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
|
||||
if shared == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut rel = std::path::PathBuf::new();
|
||||
a[shared..].iter().for_each(|_| rel.push(".."));
|
||||
b[shared..].iter().for_each(|c| rel.push(c));
|
||||
Some(rel.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -59,38 +59,6 @@ fn attdisp_sets_attmode_and_the_display_follows_it() {
|
||||
assert!(s.execute("attdisp", &json!({"mode": "sometimes"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_attribute_display_menu_sets_attmode() {
|
||||
let mut s = Session::new();
|
||||
for (id, item, mode) in [("attdisp.on", "On", 2), ("attdisp.off", "Off", 0), ("attdisp.normal", "Normal", 1)] {
|
||||
let spec = cadcraft_engine::find_command(id).unwrap_or_else(|| panic!("no command {id}"));
|
||||
assert_eq!(spec.menu, ["View", "Attribute Display", item]);
|
||||
s.start(id).unwrap();
|
||||
assert!(s.running.is_none(), "{id}: {}", s.prompt_text());
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), mode, "{id}");
|
||||
}
|
||||
assert_eq!(s.execute("attdisp.off", &json!({})).unwrap(), json!({"attmode": 0}));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attdisp_runs_transparently_inside_another_command() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("LINE 0,0").unwrap();
|
||||
s.cmdline("'ATTDISP").unwrap();
|
||||
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("attdisp"));
|
||||
assert!(s.prompt_text().starts_with(">>"), "{}", s.prompt_text());
|
||||
s.cmdline("off").unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 0);
|
||||
// LINE prompts again for its next point.
|
||||
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("line"), "{}", s.prompt_text());
|
||||
s.cmdline("10,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attsync_brings_references_in_line_with_the_definitions() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -40,12 +40,6 @@ fn a_block_cannot_be_redefined_to_reference_itself() {
|
||||
let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] }));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("references itself")));
|
||||
assert_eq!(contents(&s, "A"), ["line"]);
|
||||
|
||||
// Through a multileader whose content is the block being redefined.
|
||||
let m = s.execute("mleader", &json!({ "points": [[0, 0], [10, 5]], "block": "A" })).unwrap()["handle"].clone();
|
||||
let r = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [m] }));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("references itself")));
|
||||
assert_eq!(contents(&s, "A"), ["line"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
//! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and
|
||||
//! other blocks, becomes a reference of another at the same insertion point, scale and rotation.
|
||||
|
||||
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
|
||||
use cadcraft_engine::doc::geom::Vec3;
|
||||
use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space};
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Make block `name` from what is in model space now (the objects are removed).
|
||||
fn make_block(s: &mut Session, name: &str) {
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": name, "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
|
||||
}
|
||||
|
||||
/// Block A: a circle and attribute TAG. Block B: a line, attributes TAG and NEW. Block N holds a
|
||||
/// reference of A. Model space: a reference of A on layer R at (10, 5), scaled 2 and rotated
|
||||
/// 30°, with TAG "kept", and a reference of N. Layout1: a reference of A.
|
||||
fn drawing() -> (Session, Handle) {
|
||||
let mut s = Session::new();
|
||||
s.execute("attdef", &json!({"tag": "TAG", "default": "a", "at": [0, 2], "height": 1.0})).unwrap();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
make_block(&mut s, "A");
|
||||
s.execute("attdef", &json!({"tag": "TAG", "default": "b", "at": [0, 3], "height": 1.0})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "NEW", "default": "new", "at": [0, 5], "height": 1.0})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
make_block(&mut s, "B");
|
||||
s.execute("insert", &json!({"name": "A", "at": [0, 0]})).unwrap();
|
||||
make_block(&mut s, "N");
|
||||
s.execute("insert", &json!({"name": "N", "at": [50, 0]})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "R", "current": true})).unwrap();
|
||||
let r = s.execute("insert", &json!({"name": "A", "at": [10, 5], "scale": 2, "rotation": 30, "attribs": {"TAG": "kept"}})).unwrap();
|
||||
let ins = Insert {
|
||||
block: "A".into(),
|
||||
insert: Vec3::new(0.0, 0.0, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
(s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap())
|
||||
}
|
||||
|
||||
/// The block names of the references in `ents`.
|
||||
fn refs<'a>(ents: impl Iterator<Item = &'a std::sync::Arc<Entity>>) -> Vec<String> {
|
||||
ents.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn insert(s: &Session, h: Handle) -> (Insert, String) {
|
||||
let e = s.doc().unwrap().entity(h).unwrap();
|
||||
let EntityKind::Insert(i) = &e.kind else { panic!("not a block reference") };
|
||||
(i.clone(), e.common.layer.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_replaces_every_reference_and_keeps_its_placement() {
|
||||
let (mut s, h) = drawing();
|
||||
let before = insert(&s, h).0;
|
||||
let r = s.execute("blockreplace", &json!({"block": "a", "with": "b"})).unwrap();
|
||||
assert_eq!(r, json!({"block": "A", "with": "B", "replaced": 3, "purged": false}));
|
||||
|
||||
let (after, layer) = insert(&s, h);
|
||||
assert_eq!(after.block, "B");
|
||||
assert_eq!((after.insert, after.scale, after.rotation, layer.as_str()), (before.insert, before.scale, before.rotation, "R"));
|
||||
// The replacement's attributes, with the value of the tag both blocks define.
|
||||
let values: Vec<(String, String)> = after.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect();
|
||||
assert_eq!(values, [("TAG".to_string(), "kept".to_string()), ("NEW".to_string(), "new".to_string())]);
|
||||
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(refs(d.model.iter()), ["N", "B"]);
|
||||
assert_eq!(refs(d.block("N").unwrap().entities.iter()), ["B"]);
|
||||
assert_eq!(refs(d.space(&Space::Paper("Layout1".into())).unwrap().iter()), ["B"]);
|
||||
assert!(d.block("A").is_some(), "the definition stays without `purge`");
|
||||
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(insert(&s, h).0, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_references_follow_the_replacement_blocks_annotative_setting() {
|
||||
let (mut s, h) = drawing();
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap()["handle"].clone();
|
||||
s.execute("block", &json!({"name": "AN", "base": [0, 0], "handles": [c], "keep": "delete", "annotative": true})).unwrap();
|
||||
let scales = |s: &Session| s.doc().unwrap().entity(h).unwrap().common.anno_scales.len();
|
||||
assert_eq!(scales(&s), 0);
|
||||
s.execute("blockreplace", &json!({"block": "A", "with": "AN"})).unwrap();
|
||||
assert_eq!(scales(&s), 1, "a reference of an annotative block supports the current scale");
|
||||
s.execute("blockreplace", &json!({"block": "AN", "with": "B"})).unwrap();
|
||||
assert_eq!(scales(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_purges_the_replaced_definition_on_request() {
|
||||
let (mut s, _) = drawing();
|
||||
let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap();
|
||||
assert_eq!(r["purged"], json!(true));
|
||||
assert!(s.doc().unwrap().block("A").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_keeps_a_definition_an_unused_block_still_names() {
|
||||
let (mut s, _) = drawing();
|
||||
// Block U, never inserted, holds a multileader whose content is block A.
|
||||
let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "A"})).unwrap()["handle"].clone();
|
||||
s.execute("block", &json!({"name": "U", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap();
|
||||
let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap();
|
||||
assert_eq!((r["replaced"].clone(), r["purged"].clone()), (json!(3), json!(false)));
|
||||
assert!(s.doc().unwrap().block("A").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_refuses_what_it_cannot_replace() {
|
||||
let (mut s, _) = drawing();
|
||||
let err = |s: &mut Session, p: Value| s.execute("blockreplace", &p).unwrap_err().to_string();
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "a"})).contains("different block"));
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "MISSING"})).contains("not found"));
|
||||
assert!(err(&mut s, json!({"block": "MISSING", "with": "B"})).contains("not found"));
|
||||
// N holds a reference of A, so N replacing A would reference itself.
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "N"})).contains("references"));
|
||||
// M holds a multileader whose content is N, so M reaches A too.
|
||||
let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "N"})).unwrap()["handle"].clone();
|
||||
s.execute("block", &json!({"name": "M", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap();
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "M"})).contains("references"));
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "B", "purge": "yes"})).contains("`purge`"));
|
||||
assert!(err(&mut s, json!({"with": "B"})).contains("`block`"));
|
||||
// A block an xref brings in is not saved with the drawing.
|
||||
s.doc_mut().unwrap().blocks.insert("X|A".into(), std::sync::Arc::new(Block::new("X|A")));
|
||||
assert!(err(&mut s, json!({"block": "X|A", "with": "B"})).contains("xref"));
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "X|A"})).contains("xref"));
|
||||
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
|
||||
// The Block Editor shows the block, not the drawing's references.
|
||||
s.execute("bedit", &json!({"name": "B"})).unwrap();
|
||||
assert!(err(&mut s, json!({"block": "A", "with": "B"})).contains("Block Editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_asks_for_both_blocks_and_whether_to_purge() {
|
||||
let (mut s, h) = drawing();
|
||||
s.cmdline("-blockreplace").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter the name of the block to replace or [?/Select]"), "{}", s.prompt_text());
|
||||
s.cmdline("?").unwrap();
|
||||
assert!(s.log.iter().any(|l| l == "Blocks: A, B, N"), "{:?}", s.log);
|
||||
s.cmdline("a").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter the name of the replacement block for A or [?/Select]"), "{}", s.prompt_text());
|
||||
// A cannot replace itself: the prompt asks again.
|
||||
s.cmdline("A").unwrap();
|
||||
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
|
||||
s.input(Input::Cancel).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
|
||||
|
||||
// The block to replace picked from one of its references; Enter purges it.
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("Select").unwrap();
|
||||
s.input(Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
|
||||
s.cmdline("B").unwrap();
|
||||
assert!(s.prompt_text().contains("Purge the unreferenced block definition A? [Yes/No] <Yes>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l == "3 references of block A replaced with B. Block A purged."), "{:?}", s.log);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("A").is_none());
|
||||
assert_eq!(refs(d.model.iter()), ["N", "B"]);
|
||||
assert_eq!(insert(&s, h).0.block, "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_no_keeps_the_replaced_definition() {
|
||||
let (mut s, _) = drawing();
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("A").unwrap();
|
||||
s.cmdline("B").unwrap();
|
||||
s.cmdline("No").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("A").is_some());
|
||||
assert_eq!(refs(d.model.iter()), ["N", "B"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() {
|
||||
let (mut s, _) = drawing();
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("A").unwrap();
|
||||
// N holds a reference of A: refused at once, and the prompt asks for another name.
|
||||
s.cmdline("N").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Block \"N\" references \"A\"")), "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
|
||||
s.cmdline("B").unwrap();
|
||||
assert!(s.prompt_text().contains("Purge the unreferenced block definition A?"), "{}", s.prompt_text());
|
||||
s.input(Input::Cancel).unwrap();
|
||||
|
||||
// A dynamic block is refused as the block to replace.
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
let mut f = Foreign::default();
|
||||
f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
|
||||
let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect();
|
||||
f.objects.push(ForeignObject {
|
||||
handle: Handle(0xF00),
|
||||
kind: "DICTIONARY".into(),
|
||||
tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]),
|
||||
});
|
||||
f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) });
|
||||
d.foreign = std::sync::Arc::new(f);
|
||||
s.open_drawing(d, "dynamic", None);
|
||||
let e = s.execute("blockreplace", &json!({"block": "A", "with": "B"})).unwrap_err().to_string();
|
||||
assert!(e.contains("dynamic block"), "{e}");
|
||||
s.cmdline("blockreplace").unwrap();
|
||||
s.cmdline("A").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Block \"A\" is a dynamic block")), "{:?}", s.log);
|
||||
assert!(s.prompt_text().contains("Enter the name of the block to replace"), "{}", s.prompt_text());
|
||||
s.input(Input::Cancel).unwrap();
|
||||
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
|
||||
}
|
||||
@@ -1,880 +0,0 @@
|
||||
//! COMPAREEXPORT: the comparison as a new drawing, with this drawing's objects in one group and
|
||||
//! the other drawing's differing objects in another, in the comparison colors. A compared file's
|
||||
//! relative xref paths, which need the file-reading hook, are tested in `tests/compare.rs`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
use cadcraft_engine::doc::geom::Vec3;
|
||||
use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, MLeader, Space};
|
||||
use serde_json::json;
|
||||
|
||||
fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(x: f64) -> EntityKind {
|
||||
EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::ZERO, b: Vec3::new(x, 0.0, 0.0) })
|
||||
}
|
||||
|
||||
fn handle_of(s: &Session, ty: &str) -> String {
|
||||
s.doc().unwrap().model.iter().find(|e| e.kind.type_name() == ty).unwrap().handle.hex()
|
||||
}
|
||||
|
||||
/// "Drawing1" holds a line, a circle and a text; "B" is a copy where the circle moved, the text
|
||||
/// is gone and an arc was added. The comparison runs in "B" against "Drawing1".
|
||||
fn compared() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap();
|
||||
s.execute("text", &json!({"at": [0, 10], "height": 1, "text": "Note"})).unwrap();
|
||||
let d: Drawing = s.doc().unwrap().clone();
|
||||
s.open_drawing(d, "B.dxf", None);
|
||||
let circle = handle_of(&s, "Circle");
|
||||
let text = handle_of(&s, "Text");
|
||||
s.execute("move", &json!({"handles": [circle], "from": [0, 0], "to": [3, 0]})).unwrap();
|
||||
s.execute("erase", &json!({"handles": [text]})).unwrap();
|
||||
s.execute("arc", &json!({"center": [20, 0], "radius": 1, "start": 0, "end": 90})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Drawing1"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The objects of a group of the active drawing, in group order.
|
||||
fn members(s: &Session, group: &str) -> Vec<Arc<Entity>> {
|
||||
let d = s.doc().unwrap();
|
||||
let g = d.groups.iter().find(|g| g.name == group).unwrap_or_else(|| panic!("no group {group}"));
|
||||
g.members.iter().map(|h| d.entity(*h).unwrap().clone()).collect()
|
||||
}
|
||||
|
||||
/// The (type, color) of each object in a group of the active drawing, sorted.
|
||||
fn contents(s: &Session, group: &str) -> Vec<(String, Color)> {
|
||||
let mut v: Vec<(String, Color)> = members(s, group).iter().map(|e| (e.kind.type_name().to_string(), e.common.color)).collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
}
|
||||
|
||||
/// The x of each line's end in a block of the active drawing.
|
||||
fn ends(s: &Session, block: &str) -> Vec<f64> {
|
||||
let d = s.doc().unwrap();
|
||||
d.block(block)
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some(l.b.x),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const GREEN: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
|
||||
const RED: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
|
||||
const GRAY: Color = Color::Index(8);
|
||||
|
||||
#[test]
|
||||
fn exports_both_drawings_as_colored_groups_in_a_new_drawing() {
|
||||
let mut s = compared();
|
||||
let before = s.docs.len();
|
||||
let source = s.doc().unwrap().clone();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(s.docs.len(), before + 1, "{r}");
|
||||
assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}");
|
||||
assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg");
|
||||
assert_eq!(s.state().unwrap().path, None, "the snapshot is untitled");
|
||||
assert!(s.state().unwrap().is_dirty(), "closing the snapshot asks to save it");
|
||||
assert_eq!((r["counts"]["current"].as_u64(), r["counts"]["compared"].as_u64()), (Some(3), Some(2)), "{r}");
|
||||
assert_eq!(r["truncated"], false);
|
||||
let (cur, other) = (r["current"].as_str().unwrap(), r["compared"].as_str().unwrap());
|
||||
assert_eq!((cur, other), ("COMPARE_CURRENT", "COMPARE_COMPARED"));
|
||||
// This drawing: the moved circle and the new arc in green, the unchanged line in gray.
|
||||
assert_eq!(contents(&s, cur), vec![("Arc".into(), GREEN), ("Circle".into(), GREEN), ("Line".into(), GRAY)]);
|
||||
// The other drawing: the circle where it was and the erased text, in red, in its draw order.
|
||||
assert_eq!(contents(&s, other), vec![("Circle".into(), RED), ("Text".into(), RED)]);
|
||||
let order: Vec<&str> = members(&s, other).iter().map(|e| e.kind.type_name()).collect();
|
||||
assert_eq!(order, ["Circle", "Text"]);
|
||||
let circle_there = members(&s, other).iter().find_map(|e| match &e.kind {
|
||||
EntityKind::Circle(c) => Some(c.center),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(circle_there.map(|c| (c.x, c.y)), Some((5.0, 5.0)));
|
||||
// Model space holds the other drawing's objects first, then this drawing's.
|
||||
let d = s.doc().unwrap();
|
||||
let order: Vec<Handle> = d.model.iter().map(|e| e.handle).collect();
|
||||
let groups: Vec<Handle> = members(&s, other).iter().chain(members(&s, cur).iter()).map(|e| e.handle).collect();
|
||||
assert_eq!(order, groups);
|
||||
// Every handle is new and unique.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for e in d.model.iter().chain(d.blocks.values().flat_map(|b| b.entities.iter())) {
|
||||
assert!(seen.insert(e.handle), "handle {} repeats", e.handle.hex());
|
||||
}
|
||||
// The drawing COMPARE ran in is unchanged, and the comparison stays on it.
|
||||
let back = s.docs.iter().position(|d| d.title == "B.dxf").unwrap();
|
||||
assert!(*s.docs[back].doc == source);
|
||||
assert!(s.compare.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_the_other_drawings_layers_and_blocks_it_needs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
// The other drawing holds a reference to a block this one lacks, on a layer this one lacks.
|
||||
let mut other = s.doc().unwrap().clone();
|
||||
other.handseed = 0x9000;
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(0x8000), EntityKind::Insert(insert("BOLT"))));
|
||||
let mut bolt = Block::new("BOLT");
|
||||
bolt.entities.push(Entity::new(Handle(0x8001), line(1.0)));
|
||||
other.blocks.insert("PART".into(), Arc::new(part));
|
||||
other.blocks.insert("BOLT".into(), Arc::new(bolt));
|
||||
other.layers.push(Layer::new("Parts"));
|
||||
other.add(&Space::Model, Common { layer: "Parts".into(), ..Common::default() }, EntityKind::Insert(insert("PART"))).unwrap();
|
||||
// A group of this drawing's name does not carry over, so the snapshot takes the first name.
|
||||
s.execute("group", &json!({"name": "COMPARE_CURRENT", "handles": [handle_of(&s, "Line")]})).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(r["current"], "COMPARE_CURRENT", "{r}");
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.groups.len(), 2);
|
||||
assert!(d.layer("Parts").is_some());
|
||||
assert!(d.block("PART").is_some() && d.block("BOLT").is_some(), "nested blocks are copied");
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_BOLT"), vec![1.0], "and shown through red copies");
|
||||
assert!(d.blocks.values().flat_map(|b| b.entities.iter()).all(|e| e.handle.0 < 0x8000 || e.handle.0 >= 0x9000));
|
||||
let refs: Vec<String> = members(&s, "COMPARE_COMPARED")
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(format!("{} on {}", i.block, e.common.layer)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(refs, vec!["COMPARE_COMPARED_PART on Parts".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_blocks_of_the_other_drawing_keep_their_own_definition() {
|
||||
let mut s = Session::new();
|
||||
let mut mine = Block::new("*U1");
|
||||
mine.entities.push(Entity::new(Handle(0x700), line(1.0)));
|
||||
s.doc_mut().unwrap().blocks.insert("*U1".into(), Arc::new(mine));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("*U1"))).unwrap();
|
||||
// The other drawing uses the same anonymous name for other geometry.
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
let mut theirs = Block::new("*U1");
|
||||
theirs.entities.push(Entity::new(Handle(0x8000), line(5.0)));
|
||||
other.blocks.insert("*U1".into(), Arc::new(theirs));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("*U1") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(ends(&s, "*U1"), vec![1.0], "this drawing's definition is kept");
|
||||
let referenced: Vec<String> = members(&s, r["compared"].as_str().unwrap())
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// The other drawing's `*U1` is copied as `*U2`, and the reference shows it through a red copy.
|
||||
assert_eq!(ends(&s, "*U2"), vec![5.0]);
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_U2".to_string()], "{r}");
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_U2"), vec![5.0]);
|
||||
assert_eq!(ends(&s, "COMPARE_CURRENT_U1"), vec![1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_defined_differently_in_the_other_drawing_shows_the_other_definition() {
|
||||
// PART holds a line, a reference to SUB, which holds a line of length 2, and a multileader
|
||||
// whose content is SYMBOL, which holds one line.
|
||||
let blocks = |part_end: f64, part_base: f64, sub_base: f64, symbol_end: f64, first: u64| {
|
||||
let mut part = Block::new("PART");
|
||||
part.base = Vec3::new(part_base, 0.0, 0.0);
|
||||
part.entities.push(Entity::new(Handle(first), line(part_end)));
|
||||
part.entities.push(Entity::new(Handle(first + 1), EntityKind::Insert(insert("SUB"))));
|
||||
let leader = MLeader {
|
||||
leaders: Vec::new(),
|
||||
landing: Vec3::ZERO,
|
||||
dogleg: 0.0,
|
||||
text: None,
|
||||
style: "Standard".into(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: Some(insert("SYMBOL")),
|
||||
};
|
||||
part.entities.push(Entity::new(Handle(first + 2), EntityKind::MLeader(leader)));
|
||||
let mut sub = Block::new("SUB");
|
||||
sub.base = Vec3::new(sub_base, 0.0, 0.0);
|
||||
sub.entities.push(Entity::new(Handle(first + 3), line(2.0)));
|
||||
let mut symbol = Block::new("SYMBOL");
|
||||
symbol.entities.push(Entity::new(Handle(first + 4), line(symbol_end)));
|
||||
[part, sub, symbol]
|
||||
};
|
||||
let run = |theirs_end: f64, theirs_base: f64, theirs_sub_base: f64, theirs_symbol_end: f64| {
|
||||
let mut s = Session::new();
|
||||
for b in blocks(1.0, 0.0, 0.0, 3.0, 0x700) {
|
||||
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
for b in blocks(theirs_end, theirs_base, theirs_sub_base, theirs_symbol_end, 0x8000) {
|
||||
other.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
// A second reference, so the other drawing has one that differs even when PART does not.
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let referenced: Vec<String> = members(&s, r["compared"].as_str().unwrap())
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(s, referenced)
|
||||
};
|
||||
// The other drawing's PART draws a longer line: it is copied as PART_2 and shown in red.
|
||||
let (s, referenced) = run(5.0, 0.0, 0.0, 3.0);
|
||||
assert_eq!(ends(&s, "PART"), vec![1.0], "this drawing's definition is kept");
|
||||
assert_eq!(ends(&s, "PART_2"), vec![5.0]);
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]);
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_PART_2"), vec![5.0]);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("SUB_2").is_none() && d.block("SYMBOL_2").is_none(), "SUB and SYMBOL are the same in both drawings");
|
||||
// The blocks a copy of PART refers to.
|
||||
let refers = |s: &Session| -> Vec<String> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.block("PART_2")
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.flat_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => vec![i.block.clone()],
|
||||
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Another base point on PART, another base point on the SUB inside it, or another SYMBOL
|
||||
// in its multileader: PART is copied too, with the blocks that differ.
|
||||
let (s, referenced) = run(1.0, 3.0, 0.0, 3.0);
|
||||
assert_eq!(s.doc().unwrap().block("PART_2").map(|b| b.base.x), Some(3.0));
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]);
|
||||
assert_eq!(refers(&s), ["SUB", "SYMBOL"]);
|
||||
let (s, _) = run(1.0, 0.0, 4.0, 3.0);
|
||||
assert_eq!(s.doc().unwrap().block("SUB_2").map(|b| b.base.x), Some(4.0));
|
||||
assert_eq!(refers(&s), ["SUB_2", "SYMBOL"]);
|
||||
let (s, _) = run(1.0, 0.0, 0.0, 6.0);
|
||||
assert_eq!(ends(&s, "SYMBOL_2"), vec![6.0]);
|
||||
assert_eq!(refers(&s), ["SUB", "SYMBOL_2"]);
|
||||
// The same definitions in both drawings: the red reference uses this drawing's PART.
|
||||
let (s, referenced) = run(1.0, 0.0, 0.0, 3.0);
|
||||
assert!(s.doc().unwrap().block("PART_2").is_none());
|
||||
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART".to_string()]);
|
||||
assert_eq!(ends(&s, "COMPARE_COMPARED_PART"), vec![1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension};
|
||||
// PART holds a dimension with the same parameters in both drawings; the block it draws, `*D1`,
|
||||
// holds a line of another length in the other drawing.
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
let blocks = |dim_end: f64, first: u64| {
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim.clone())));
|
||||
let mut drawn = Block::new("*D1");
|
||||
drawn.entities.push(Entity::new(Handle(first + 1), line(dim_end)));
|
||||
[part, drawn]
|
||||
};
|
||||
let mut s = Session::new();
|
||||
for b in blocks(1.0, 0x700) {
|
||||
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
for b in blocks(5.0, 0x8000) {
|
||||
other.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(ends(&s, "*D1"), vec![1.0], "this drawing's dimension block is kept");
|
||||
let d = s.doc().unwrap();
|
||||
let drawn: Vec<Option<String>> = d
|
||||
.block("PART_2")
|
||||
.map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block");
|
||||
assert_eq!(ends(&s, "*D2"), vec![5.0]);
|
||||
// The colored copies of PART refer to colored copies of the dimension block, which are
|
||||
// anonymous `*D` blocks of their own, not the originals.
|
||||
let d = s.doc().unwrap();
|
||||
let copies: Vec<String> = d
|
||||
.blocks
|
||||
.values()
|
||||
.filter(|b| b.name.starts_with("COMPARE_"))
|
||||
.flat_map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.block.clone().unwrap_or_default()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
assert!(!copies.is_empty(), "PART has colored copies");
|
||||
for n in &copies {
|
||||
assert!(n.starts_with("*D") && !["*D1", "*D2"].contains(&n.as_str()), "{n} in {copies:?}");
|
||||
assert!(d.block(n).is_some_and(|b| b.anonymous), "{n} is an anonymous block");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_drawings_definition() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension};
|
||||
// PART holds the same dimension in both drawings; the block it draws holds the same line but
|
||||
// is named `*D1` here and `*D3` in the other drawing.
|
||||
let blocks = |drawn_name: &str, first: u64| {
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some(drawn_name.into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim)));
|
||||
let mut drawn = Block::new(drawn_name);
|
||||
drawn.entities.push(Entity::new(Handle(first + 1), line(1.0)));
|
||||
[part, drawn]
|
||||
};
|
||||
let mut s = Session::new();
|
||||
for b in blocks("*D1", 0x700) {
|
||||
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
for b in blocks("*D3", 0x8000) {
|
||||
other.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_other_definition() {
|
||||
let run = |theirs: Common| {
|
||||
let part = |common: Common, first: u64| {
|
||||
let mut part = Block::new("PART");
|
||||
let mut e = Entity::new(Handle(first), line(1.0));
|
||||
e.common = common;
|
||||
part.entities.push(e);
|
||||
part
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(Common::default(), 0x700)));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
other.blocks.insert("PART".into(), Arc::new(part(theirs, 0x8000)));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
s.doc().unwrap().block("PART_2").is_some()
|
||||
};
|
||||
assert!(run(Common { visible: false, ..Common::default() }), "a hidden line");
|
||||
assert!(run(Common { thickness: 2.0, ..Common::default() }), "a line with thickness");
|
||||
assert!(!run(Common::default()), "the same line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_block_with_the_same_relative_image_path_from_another_folder_shows_the_other_definition() {
|
||||
// PART holds an image at `a.png` in both drawings; this drawing is unsaved, the other one is
|
||||
// a file in another folder, so its `a.png` is another file.
|
||||
let part = |first: u64| {
|
||||
let at = serde_json::to_value(Vec3::ZERO).unwrap();
|
||||
let image = serde_json::from_value(json!({"insert": at, "u": at, "v": at, "size": [1.0, 1.0], "path": "a.png"})).unwrap();
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(first), EntityKind::Image(image)));
|
||||
part
|
||||
};
|
||||
let dir = std::env::temp_dir().join("compareexport-image-folder");
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(0x700)));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = Drawing::new_imperial();
|
||||
other.handseed = 0x9000;
|
||||
other.blocks.insert("PART".into(), Arc::new(part(0x8000)));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
|
||||
s.open_drawing(other, "Other", Some(dir.join("other.dxf").to_string_lossy().to_string()));
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let paths: Vec<String> = d
|
||||
.block("PART_2")
|
||||
.map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Image(i) => Some(i.path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(paths.len(), 1, "the other drawing's PART is copied");
|
||||
assert_eq!(std::path::Path::new(&paths[0]).parent(), Some(std::path::absolute(&dir).unwrap().as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension};
|
||||
// The dimension's block holds a line its parameters would not draw: a block read from a file.
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let mut drawn = Block::new("*D1");
|
||||
drawn.entities.push(Entity::new(Handle(0x701), line(123.0)));
|
||||
s.doc_mut().unwrap().blocks.insert("*D1".into(), Arc::new(drawn));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim)).unwrap();
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
|
||||
let block = read
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => dm.block.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ends: Vec<f64> = read
|
||||
.block(&block)
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some(l.b.x),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ends, [123.0], "the saved dimension block is the copy's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_contents_take_the_comparison_color() {
|
||||
let mut s = Session::new();
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity { handle: Handle(0x700), common: Common { color: Color::Index(1), ..Common::default() }, kind: line(1.0) });
|
||||
part.entities.push(Entity::new(Handle(0x701), EntityKind::Insert(insert("PART"))));
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
|
||||
let mut other = s.doc().unwrap().clone();
|
||||
other.model = Default::default();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let objs = members(&s, r["current"].as_str().unwrap());
|
||||
let EntityKind::Insert(i) = &objs[0].kind else { panic!("{r}") };
|
||||
assert_eq!(i.block, "COMPARE_CURRENT_PART");
|
||||
let d = s.doc().unwrap();
|
||||
let copy = d.block("COMPARE_CURRENT_PART").unwrap();
|
||||
assert!(copy.entities.iter().all(|e| e.common.color == GREEN), "the line inside is green too");
|
||||
// The block that refers to itself refers to its own copy, which ends the copying.
|
||||
assert!(copy.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(n) if n.block == "COMPARE_CURRENT_PART")));
|
||||
// This drawing's definition is left as it was.
|
||||
assert_eq!(d.block("PART").unwrap().entities.iter().next().unwrap().common.color, Color::Index(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_carried_apart_from_the_object_color_are_replaced() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mtext", &json!({"at": [0, 0], "height": 1, "width": 20, "text": "a{\\C1;red} \\c16711680;b \\\\C"})).unwrap();
|
||||
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 3]})).unwrap();
|
||||
s.execute("table", &json!({"at": [0, 20], "rows": 1, "cols": 1, "cells": [["\\C1;x"]]})).unwrap();
|
||||
s.execute("tolerance", &json!({"at": [0, 30], "symbol": "flatness", "tol1": "0.1"})).unwrap();
|
||||
s.execute("mline", &json!({"points": [[0, 40], [10, 40]]})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.dim_styles[0].dim_line_color = Color::Index(1);
|
||||
d.dim_styles[0].text_color = Color::Index(5);
|
||||
let mut data = d.table_styles[0].cell_style(CellStyle::DATA);
|
||||
(data.text_color, data.fill, data.borders.top.color) = (Color::Index(1), Some(Color::Index(2)), Color::Index(3));
|
||||
d.table_styles[0].cell_styles.push(data);
|
||||
let mline = d.model.iter().find(|e| matches!(e.kind, EntityKind::MLine(_))).unwrap().handle;
|
||||
d.modify_entity(mline, |e| {
|
||||
if let EntityKind::MLine(m) = &mut e.kind {
|
||||
m.def.elements.iter_mut().for_each(|el| el.color = Color::Index(3));
|
||||
m.def.fill = Some(Color::Index(4));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let other = Drawing::new_imperial();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let mut kinds = Vec::new();
|
||||
let d = s.doc().unwrap();
|
||||
for e in members(&s, r["current"].as_str().unwrap()) {
|
||||
match &e.kind {
|
||||
EntityKind::MText(t) => assert_eq!(t.contents, "a{red} b \\\\C", "inline colors are taken out"),
|
||||
EntityKind::Dimension(dm) => {
|
||||
let st = d.dim_style(&dm.style).unwrap().with_overrides(&dm.overrides);
|
||||
assert_eq!((st.dim_line_color, st.ext_line_color, st.text_color), (Color::ByBlock, Color::ByBlock, Color::ByBlock));
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
assert_eq!(t.style, "COMPARE_CURRENT_Standard");
|
||||
assert_eq!(t.cells.iter().flatten().find(|c| !c.text.is_empty()).map(|c| c.text.as_str()), Some("x"));
|
||||
let st = d.table_styles.iter().find(|s| s.name == t.style).unwrap();
|
||||
let data = st.cell_style(CellStyle::DATA);
|
||||
assert_eq!((data.text_color, data.fill, data.borders.top.color), (Color::ByBlock, None, Color::ByBlock));
|
||||
}
|
||||
EntityKind::Tolerance(t) => {
|
||||
assert_eq!(t.style, "COMPARE_CURRENT_Standard");
|
||||
let st = d.dim_style(&t.style).unwrap();
|
||||
assert_eq!((st.dim_line_color, st.text_color), (Color::ByBlock, Color::ByBlock));
|
||||
}
|
||||
EntityKind::MLine(m) => {
|
||||
assert!(m.def.elements.iter().all(|el| el.color == Color::ByBlock));
|
||||
assert_eq!(m.def.fill, None);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
kinds.push(e.kind.type_name());
|
||||
}
|
||||
assert_eq!(kinds.len(), 5, "{kinds:?}");
|
||||
// This drawing's styles are left as they were.
|
||||
assert_eq!(d.table_styles[0].cell_style(CellStyle::DATA).text_color, Color::Index(1));
|
||||
assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_colors_of_multiline_attributes_are_replaced() {
|
||||
use cadcraft_engine::doc::Attrib;
|
||||
let attrib = |contents: &str| -> Attrib {
|
||||
let at = serde_json::to_value(Vec3::ZERO).unwrap();
|
||||
serde_json::from_value(json!({
|
||||
"tag": "T",
|
||||
"text": {"insert": at, "height": 1.0, "value": "x"},
|
||||
"multiline": {"mtext": {"insert": at, "height": 1.0, "contents": contents}, "readAs": "x"},
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let mut part = Block::new("PART");
|
||||
part.entities.push(Entity::new(Handle(0x700), EntityKind::AttDef(attrib("\\C1;x"))));
|
||||
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part));
|
||||
let reference = Insert { attribs: vec![attrib("\\C3;x")], ..insert("PART") };
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap();
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let multiline = |a: &Attrib| a.multiline.as_ref().map(|m| m.mtext.contents.clone());
|
||||
let mut blocks = Vec::new();
|
||||
for e in members(&s, r["current"].as_str().unwrap()) {
|
||||
if let EntityKind::Insert(i) = &e.kind {
|
||||
assert_eq!(i.attribs.iter().map(multiline).collect::<Vec<_>>(), [Some("x".to_string())], "the reference's attribute");
|
||||
blocks.push(i.block.clone());
|
||||
}
|
||||
}
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let d = s.doc().unwrap();
|
||||
let defs: Vec<Option<String>> = d
|
||||
.block(&blocks[0])
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) => Some(multiline(a)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(defs, [Some("x".to_string())], "the attribute definition in the recolored block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_after_compare_count_and_objects_keep_their_layers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let copy: Drawing = s.doc().unwrap().clone();
|
||||
s.open_drawing(copy, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
// A line drawn after COMPARE ran is only in this drawing.
|
||||
s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
let mut got = contents(&s, r["current"].as_str().unwrap());
|
||||
got.sort_by_key(|(_, c)| *c == GRAY);
|
||||
assert_eq!(got, vec![("Line".into(), GREEN), ("Line".into(), GRAY)], "{r}");
|
||||
// The copies sit in model space on their own layer, not inside a block.
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.model.iter().all(|e| e.common.layer == "0" && matches!(e.kind, EntityKind::Line(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_drop_associations_to_the_source_objects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let l = handle_of(&s, "Line");
|
||||
s.execute("dimlinear", &json!({"object": l, "at": [5, 3]})).unwrap();
|
||||
let assoc = |d: &Drawing| d.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.assoc.len()) } else { None });
|
||||
assert!(assoc(s.doc().unwrap()).is_some_and(|n| n > 0), "the dimension follows the line");
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(assoc(s.doc().unwrap()), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_xref_paths_become_paths_from_the_source_folder() {
|
||||
let folder = std::env::temp_dir().join("cadcraft-compareexport");
|
||||
let host = folder.join("host.dxf");
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut x = Block::new("X");
|
||||
x.xref_path = Some("parts/x.dwg".into());
|
||||
d.blocks.insert("X".into(), Arc::new(x));
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("X"))).unwrap();
|
||||
let image = cadcraft_engine::doc::Image { path: "img/a.png".into(), ..Default::default() };
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Image(image.clone())).unwrap();
|
||||
let mut b = Block::new("PIC");
|
||||
b.entities.push(Entity::new(Handle(0x900), EntityKind::Image(image)));
|
||||
d.blocks.insert("PIC".into(), Arc::new(b));
|
||||
let mut s = Session::new();
|
||||
s.open_drawing(d, "host.dxf", Some(host.to_string_lossy().to_string()));
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let got = d.block("X").unwrap().xref_path.clone().unwrap();
|
||||
assert_eq!(std::path::Path::new(&got), folder.join("parts").join("x.dwg"));
|
||||
// Images in model space and in blocks too.
|
||||
let want = folder.join("img").join("a.png");
|
||||
let images = d.model.iter().chain(d.block("PIC").unwrap().entities.iter()).filter_map(|e| match &e.kind {
|
||||
EntityKind::Image(i) => Some(i.path.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let images: Vec<String> = images.collect();
|
||||
assert_eq!(images.len(), 2);
|
||||
assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn says_when_blocks_of_the_other_drawing_were_left_out() {
|
||||
let mut other = Drawing::new_imperial();
|
||||
for i in 0..10_001 {
|
||||
let name = format!("B{i}");
|
||||
let mut b = Block::new(&name);
|
||||
b.entities.push(Entity::new(Handle(0x10_0000 + i), line(1.0)));
|
||||
other.blocks.insert(name.clone(), Arc::new(b));
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&name))).unwrap();
|
||||
}
|
||||
other.handseed = other.handseed.max(0x20_0000);
|
||||
let mut s = Session::new();
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(r["truncated"], true, "{}", r["message"]);
|
||||
assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]);
|
||||
// The same blocks in this drawing, compared with an empty one: past the limit of recolored
|
||||
// copies some references keep their blocks' own colors.
|
||||
let ours = s.docs[1].doc.as_ref().clone();
|
||||
let mut s = Session::new();
|
||||
s.open_drawing(ours, "Ours", None);
|
||||
s.execute("compare", &json!({"document": 0})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert_eq!(r["truncated"], true, "{}", r["message"]);
|
||||
assert!(r["message"].as_str().unwrap().contains("some blocks keep their own colors"), "{}", r["message"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn says_when_blocks_both_drawings_define_differently_were_left_out() {
|
||||
// Both drawings define B0 to B10000, each with a line of another length, so past the block
|
||||
// limit the blocks left are ones this drawing defines differently.
|
||||
let blocks = |end: f64, first: u64, d: &mut Drawing| {
|
||||
for i in 0..10_001 {
|
||||
let name = format!("B{i}");
|
||||
let mut b = Block::new(&name);
|
||||
b.entities.push(Entity::new(Handle(first + i), line(end)));
|
||||
d.blocks.insert(name, Arc::new(b));
|
||||
}
|
||||
};
|
||||
let mut other = Drawing::new_imperial();
|
||||
blocks(1.0, 0x10_0000, &mut other);
|
||||
for i in 0..10_001 {
|
||||
other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&format!("B{i}")))).unwrap();
|
||||
}
|
||||
other.handseed = other.handseed.max(0x20_0000);
|
||||
let mut s = Session::new();
|
||||
let mut mine = s.doc().unwrap().clone();
|
||||
blocks(2.0, 0x30_0000, &mut mine);
|
||||
mine.handseed = mine.handseed.max(0x40_0000);
|
||||
s.open_drawing(mine, "Mine", None);
|
||||
s.open_drawing(other, "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
let r = s.execute("compareexport", &json!({})).unwrap();
|
||||
assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() {
|
||||
use cadcraft_engine::doc::{DimKind, Dimension, MAX_BLOCK_DEPTH};
|
||||
// B1 holds B2, …, and the innermost block holds a dimension: its `*D1` copy would be past
|
||||
// the nesting limit.
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut drawn = Block::new("*D1");
|
||||
drawn.entities.push(Entity::new(Handle(0x600), line(30.0)));
|
||||
d.blocks.insert("*D1".into(), Arc::new(drawn));
|
||||
let dim = Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(15.0, 5.0, 0.0),
|
||||
text_mid: Vec3::new(7.0, 5.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(30.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 30.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: Some("*D1".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
for k in 1..=MAX_BLOCK_DEPTH {
|
||||
let mut b = Block::new(&format!("B{k}"));
|
||||
let kind = if k == MAX_BLOCK_DEPTH { EntityKind::Dimension(dim.clone()) } else { EntityKind::Insert(insert(&format!("B{}", k + 1))) };
|
||||
b.entities.push(Entity::new(Handle(0x700 + k as u64), kind));
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("B1"))).unwrap();
|
||||
s.open_drawing(Drawing::new_imperial(), "Other", None);
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("compare", &json!({"document": "Other"})).unwrap();
|
||||
s.execute("compareexport", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let dims: Vec<Option<String>> = d
|
||||
.blocks
|
||||
.values()
|
||||
.filter(|b| b.name.starts_with("COMPARE_"))
|
||||
.flat_map(|b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.block.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
// The colored copy keeps no block, so it is drawn in the comparison color, not with `*D1`'s.
|
||||
assert_eq!(dims, [None], "{:?}", d.blocks.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_a_comparison_of_the_current_drawing() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("compareexport", &json!({})).is_err(), "no comparison yet");
|
||||
let mut s = compared();
|
||||
s.execute("document.switch", &json!({"index": 0})).unwrap();
|
||||
assert!(s.execute("compareexport", &json!({})).is_err(), "the comparison belongs to the other drawing");
|
||||
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
||||
s.execute("compareclose", &json!({})).unwrap();
|
||||
assert!(s.execute("compareexport", &json!({})).is_err(), "closed");
|
||||
// Typed at the command line it runs too.
|
||||
let mut s = compared();
|
||||
let before = s.docs.len();
|
||||
s.cmdline("compareexport").unwrap();
|
||||
assert_eq!(s.docs.len(), before + 1);
|
||||
}
|
||||
@@ -100,34 +100,6 @@ fn new_variables_are_style_overrides_and_read_back() {
|
||||
assert_eq!(st.dim_linetype, "DASHED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fill_variables_are_style_overrides() {
|
||||
let mut s = Session::new();
|
||||
set(&mut s, "DIMTFILL", json!(2));
|
||||
set(&mut s, "DIMTFILLCLR", json!(3));
|
||||
let o = s.doc().unwrap().dim_overrides();
|
||||
assert_eq!(o.get("textFill"), Some(&json!(2)));
|
||||
linear(&mut s, 10.0);
|
||||
let (dm, g) = last_dim(&s);
|
||||
let st = DimStyle::default().with_overrides(&dm.overrides);
|
||||
assert_eq!((st.text_fill, st.text_fill_color), (2, cadcraft_engine::doc::color::Color::Index(3)));
|
||||
assert_eq!(g.text_fill.len(), 4, "the new dimension has a text background");
|
||||
// DIMTFILL takes 0, 1 or 2.
|
||||
let mut st = DimStyle::default();
|
||||
assert_eq!(st.apply_fields(&serde_json::Map::from_iter([("DIMTFILL".to_string(), json!(3))])).len(), 1);
|
||||
// EXPLODE keeps the background as a SOLID in DIMTFILLCLR, before the text.
|
||||
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
|
||||
s.execute("explode", &json!({ "handles": [h] })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let solid = d.model.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == cadcraft_engine::doc::color::Color::Index(3));
|
||||
let mtext = d.model.iter().position(|e| matches!(e.kind, EntityKind::MText(_)));
|
||||
assert!(solid.is_some() && solid < mtext, "{solid:?}, {mtext:?}");
|
||||
// The arrowheads (the other SOLIDs) come before the background, as the dimension draws them.
|
||||
let arrows: Vec<usize> =
|
||||
d.model.iter().enumerate().filter(|(i, e)| matches!(e.kind, EntityKind::Solid(_)) && Some(*i) != solid).map(|(i, _)| i).collect();
|
||||
assert!(!arrows.is_empty() && arrows.iter().all(|i| Some(*i) < solid), "{arrows:?} before {solid:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_moves_text_or_arrows_outside() {
|
||||
// 0.5 between the extension lines: the text "0.5000" doesn't fit, the arrows (2 × 0.18) do.
|
||||
@@ -224,37 +196,6 @@ fn text_move_settings() {
|
||||
assert!((dm.defpt.y - (12.0 - 0.18)).abs() < 1e-9, "{:?}", dm.defpt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_refuses_the_values_the_style_refuses() {
|
||||
let mut s = Session::new();
|
||||
set(&mut s, "DIMTMOVE", json!(1));
|
||||
let get = |s: &mut Session, name: &str| s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone();
|
||||
for (name, v) in [
|
||||
("DIMTMOVE", json!(3)),
|
||||
("DIMATFIT", json!(7)),
|
||||
("DIMLWD", json!(500)),
|
||||
("DIMJOGANG", json!(120)),
|
||||
("DIMFXL", json!(-1)),
|
||||
("DIMTXT", json!(0)),
|
||||
("DIMDEC", json!(300)),
|
||||
("DIMCLRT", json!("no such color")),
|
||||
] {
|
||||
let before = get(&mut s, name);
|
||||
assert!(s.execute("setvar", &json!({ "name": name, "value": v })).is_err(), "{name} {v}");
|
||||
assert_eq!(get(&mut s, name), before, "{name} keeps its value");
|
||||
// DIMSTYLE and DIMOVERRIDE refuse the same value.
|
||||
let mut st = DimStyle::default();
|
||||
assert_eq!(st.apply_fields(&serde_json::Map::from_iter([(name.to_string(), v)])).len(), 1, "{name}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().dim_overrides().get("textMove"), Some(&json!(1)));
|
||||
// Values in range still go in, typed as text too.
|
||||
set(&mut s, "DIMTMOVE", json!("2"));
|
||||
set(&mut s, "DIMCLRT", json!(3));
|
||||
let o = s.doc().unwrap().dim_overrides();
|
||||
assert_eq!(o.get("textMove"), Some(&json!(2)));
|
||||
assert_eq!(o.get("textColor"), serde_json::to_value(cadcraft_engine::doc::color::Color::Index(3)).ok().as_ref(), "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_justification_angular_zeros_and_arc_symbol() {
|
||||
let mut s = Session::new();
|
||||
@@ -321,69 +262,6 @@ fn linetypes_and_lineweights_of_dimension_and_extension_lines() {
|
||||
assert!(lws.contains(&0.5) && lws.contains(&0.13), "{lws:?}");
|
||||
}
|
||||
|
||||
fn ignored(r: &Value) -> Vec<&str> {
|
||||
r["ignored"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimstyle_and_dimoverride_refuse_negative_whole_numbers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("dimstyle", &json!({ "name": "Standard", "DIMTMOVE": 2, "DIMLWD": 50 })).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"dimstyle",
|
||||
&json!({
|
||||
"name": "Standard",
|
||||
"DIMDEC": -1,
|
||||
"DIMTMOVE": -1,
|
||||
"DIMLUNIT": "-1",
|
||||
"DIMTDEC": -0.6,
|
||||
"DIMADEC": -2,
|
||||
"DIMALTD": "300",
|
||||
"DIMLWE": "300",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let ign = ignored(&r);
|
||||
for k in ["DIMDEC", "DIMTMOVE", "DIMLUNIT", "DIMTDEC", "DIMADEC", "DIMALTD", "DIMLWE"] {
|
||||
assert!(ign.contains(&k), "{k}: {r}");
|
||||
}
|
||||
let st = s.doc().unwrap().dim_style("Standard").unwrap().clone();
|
||||
let def = DimStyle::default();
|
||||
assert_eq!((st.decimals, st.text_move, st.linear_unit, st.tol_decimals), (def.decimals, 2, def.linear_unit, def.tol_decimals));
|
||||
assert_eq!((st.angular_decimals, st.alt_decimals, st.ext_lineweight), (def.angular_decimals, def.alt_decimals, def.ext_lineweight));
|
||||
// DIMADEC -1 is a documented value (stored as 0); ByLayer lineweights stay -1.
|
||||
let r = s.execute("dimstyle", &json!({ "name": "Standard", "DIMADEC": -1, "DIMLWD": "ByLayer" })).unwrap();
|
||||
assert!(r.get("ignored").is_none(), "{r}");
|
||||
assert_eq!(s.doc().unwrap().dim_style("Standard").unwrap().dim_lineweight, -1);
|
||||
|
||||
// DIMOVERRIDE keeps the valid value and leaves the negative one out.
|
||||
linear(&mut s, 10.0);
|
||||
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
|
||||
s.execute("dimoverride", &json!({ "handles": [h], "DIMDEC": -1, "DIMTMOVE": 1 })).unwrap();
|
||||
let (dm, _) = last_dim(&s);
|
||||
assert!(!dm.overrides.contains_key("decimals"), "{:?}", dm.overrides);
|
||||
assert_eq!(dm.overrides.get("textMove"), Some(&json!(1)));
|
||||
assert!(s.execute("dimoverride", &json!({ "handles": [h], "DIMZIN": -3 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_negative_whole_numbers_do_not_reach_new_dimensions() {
|
||||
let mut s = Session::new();
|
||||
for (name, v) in [("DIMDEC", json!(-1)), ("DIMLUNIT", json!("-1")), ("DIMTDEC", json!(-0.6))] {
|
||||
// The header may hold the value; new dimensions keep the style's own.
|
||||
let _ = s.execute("setvar", &json!({ "name": name, "value": v }));
|
||||
}
|
||||
let o = s.doc().unwrap().dim_overrides();
|
||||
for f in ["decimals", "linearUnit", "tolDecimals"] {
|
||||
assert!(!o.contains_key(f), "{f}: {o:?}");
|
||||
}
|
||||
linear(&mut s, 10.0);
|
||||
let (dm, g) = last_dim(&s);
|
||||
assert_eq!(g.value, "10.0000");
|
||||
assert!(dm.overrides.is_empty(), "{:?}", dm.overrides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaders_use_the_leader_arrow() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! AREA's Object / Add area / Subtract area, DIST's Multiple points and MEASUREGEOM's Angle and
|
||||
//! Volume, typed as at the command line.
|
||||
//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
@@ -75,56 +74,6 @@ fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() {
|
||||
assert!(!has(&log, "Area = "), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_inquiry_radius_asks_for_the_object() {
|
||||
// Nothing selected: the menu item asks for the arc or circle.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 2").unwrap();
|
||||
s.start("radius.measure").unwrap();
|
||||
assert!(s.prompt_text().contains("Select arc or circle"), "{}", s.prompt_text());
|
||||
s.cmdline("2,0").unwrap();
|
||||
assert!(has(&s.log, "Radius = 2.0000"), "{:?}", s.log);
|
||||
assert!(has(&s.log, "Diameter = 4.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// A selected arc is measured at once.
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap();
|
||||
s.set_selection(vec![cadcraft_doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()]);
|
||||
s.start("radius.measure").unwrap();
|
||||
assert!(has(&s.log, "Radius = 3.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_radius_measures_a_polyline_arc_segment() {
|
||||
// A line from 0,0 to 10,0, then a half circle of radius 5 to 10,10 through 15,5.
|
||||
let pline = json!({"vertices": [[0, 0], {"p": [10, 0], "bulge": 1}, [10, 10]]});
|
||||
let mut s = Session::new();
|
||||
s.execute("pline", &pline).unwrap();
|
||||
s.cmdline("measuregeom").unwrap();
|
||||
s.cmdline("r").unwrap();
|
||||
// A pick on the line segment keeps the prompt.
|
||||
s.cmdline("5,0").unwrap();
|
||||
assert!(has(&s.log, "Select an arc, circle or polyline arc."), "{:?}", s.log);
|
||||
assert!(s.running.is_some());
|
||||
s.cmdline("15,5").unwrap();
|
||||
assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log);
|
||||
assert!(has(&s.log, "Diameter = 10.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// The same pick from Tools > Inquiry > Radius, and the JSON form with the point on the arc.
|
||||
let mut s = Session::new();
|
||||
let h = s.execute("pline", &pline).unwrap()["handle"].clone();
|
||||
s.start("radius.measure").unwrap();
|
||||
s.cmdline("15,5").unwrap();
|
||||
assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log);
|
||||
let r = s.execute("radius.measure", &json!({"handle": h, "point": [15, 5]})).unwrap();
|
||||
assert_eq!(r["radius"].as_f64(), Some(5.0), "{r}");
|
||||
assert!(s.execute("radius.measure", &json!({"handle": h, "point": [5, 0]})).is_err());
|
||||
assert!(s.execute("radius.measure", &json!({"handle": h})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_typed_asks_for_the_option_then_the_points() {
|
||||
// Distance is the default option (#1103: typed, it used to fail asking for `p1`).
|
||||
@@ -139,63 +88,6 @@ fn measuregeom_typed_asks_for_the_option_then_the_points() {
|
||||
assert!(has(&log, "Area = 6.0000"), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_angle_measures_an_arc_a_circle_two_lines_and_a_vertex() {
|
||||
let typed = |s: &mut Session, lines: &[&str]| {
|
||||
for l in lines {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
s.log.clone()
|
||||
};
|
||||
|
||||
// Arc: the angle between its endpoints about its center.
|
||||
let mut s = Session::new();
|
||||
s.execute("arc", &json!({"center": [0, 0], "radius": 2, "start": 0, "end": 270})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "-2,0"]), "Angle = 270.0000°"));
|
||||
|
||||
// Circle: the pick on the circle and a second point, about the center.
|
||||
let log = typed(&mut Session::new(), &["circle 0,0 2", "measuregeom", "a", "2,0", "0,5"]);
|
||||
assert!(has(&log, "Angle = 90.0000°"), "{log:?}");
|
||||
|
||||
// Two lines that do not meet, and two segments of one polyline.
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[1, 0], [10, 0]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 1], [5, 6]]})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "2,3"]), "Angle = 45.0000°"), "{:?}", s.log);
|
||||
let mut s = Session::new();
|
||||
s.execute("pline", &json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "10,5"]), "Angle = 90.0000°"), "{:?}", s.log);
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap();
|
||||
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "5,5"]), "The lines are parallel."), "{:?}", s.log);
|
||||
|
||||
// Enter: a vertex and two endpoints.
|
||||
let log = typed(&mut Session::new(), &["measuregeom", "a", "", "0,0", "1,0", "-1,1"]);
|
||||
assert!(has(&log, "Angle = 135.0000°"), "{log:?}");
|
||||
|
||||
// Empty space is not an object, and the prompt stays.
|
||||
let (s, log) = run(&["measuregeom", "a", "50,50"]);
|
||||
assert!(has(&log, "*Invalid selection*"), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_inquiry_angle_asks_for_the_objects() {
|
||||
let spec = cadcraft_engine::find_command("angle.measure").expect("no command angle.measure");
|
||||
assert_eq!(spec.menu, ["Tools", "Inquiry", "Angle"]);
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 2").unwrap();
|
||||
s.start("angle.measure").unwrap();
|
||||
assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text());
|
||||
for l in ["0,2", "-3,0"] {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(has(&s.log, "Angle = 90.0000°"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_of_an_ellipse_reports_its_exact_perimeter() {
|
||||
// References from the arithmetic-geometric mean (Gauss–Kummer), to 12 decimals. A closed-form
|
||||
@@ -211,104 +103,3 @@ fn area_of_an_ellipse_reports_its_exact_perimeter() {
|
||||
let (_, log) = run(&["ellipse c 0,0 10,0 4", "area", "o", "10,0"]);
|
||||
assert!(has(&log, "Perimeter = 46.0262"), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_volume_multiplies_an_area_by_a_height() {
|
||||
// Points: a 4 x 3 triangle's area is 6, times a height of 10.
|
||||
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "10"]);
|
||||
assert!(has(&log, "Volume = 60.0000"), "{log:?}");
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// Object (the default): a 10 x 10 square polyline, height 2.
|
||||
let (s, log) = run(&["rectang 0,0 10,10", "measuregeom", "v", "", "10,5", "2"]);
|
||||
assert!(has(&log, "Volume = 200.0000"), "{log:?}");
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// A height that is not a distance is asked for again.
|
||||
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "tall"]);
|
||||
assert!(has(&log, "Requires a distance."), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
|
||||
|
||||
// Enter is not a height; two points give the height as their distance.
|
||||
let (mut s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "", "1,1"]);
|
||||
assert!(has(&log, "Requires a height."), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify second point"), "{}", s.prompt_text());
|
||||
s.cmdline("7,9").unwrap();
|
||||
assert!(has(&s.log, "Volume = 60.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// The JSON forms, by points and by handle.
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap();
|
||||
assert_eq!(r["volume"], json!(60.0));
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
let r = s.execute("volume.measure", &json!({"handle": c["handle"], "height": -3})).unwrap();
|
||||
assert!((r["volume"].as_f64().unwrap() - 3.0 * std::f64::consts::PI).abs() < 1e-12, "{r}");
|
||||
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0]], "height": 1})).is_err());
|
||||
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0], [1, 1]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_with_points_that_are_not_points_is_an_error_not_the_selection() {
|
||||
let mut s = Session::new();
|
||||
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
s.set_selection(vec![cadcraft_engine::doc::Handle::parse_hex(c["handle"].as_str().unwrap()).unwrap()]);
|
||||
for bad in [json!([[0, 0], [4, 0], "north"]), json!([[0, 0], [4, 0], [4]]), json!(7)] {
|
||||
assert!(s.execute("volume.measure", &json!({"points": bad, "height": 10})).is_err(), "{bad}");
|
||||
assert!(s.execute("area", &json!({"points": bad})).is_err(), "{bad}");
|
||||
}
|
||||
// Without `points`, both measure the selected circle.
|
||||
let r = s.execute("volume.measure", &json!({"height": 1})).unwrap();
|
||||
assert!((r["volume"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
|
||||
let r = s.execute("area", &json!({"points": null})).unwrap();
|
||||
assert!((r["area"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuregeom_volume_too_large_to_hold_is_refused() {
|
||||
// A 2e150 square has an area of 4e300; times 1e10 it is past f64's range.
|
||||
let p = json!([[0, 0], [2e150, 0], [2e150, 2e150], [0, 2e150]]);
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1e10})).is_err());
|
||||
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1})).is_ok());
|
||||
|
||||
// Typed, the height is asked for again.
|
||||
let (s, log) = run(&["measuregeom", "v", "0,0", "2e150,0", "2e150,2e150", "0,2e150", "", "1e10"]);
|
||||
assert!(has(&log, "The volume is too large."), "{log:?}");
|
||||
assert!(!has(&log, "Volume = "), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
|
||||
|
||||
// Two volumes that each fit can overflow the running total; the second is asked for again.
|
||||
let sq = ["0,0", "1,0", "1,1", "0,1", ""];
|
||||
let mut lines = vec!["measuregeom", "v", "a"];
|
||||
lines.extend(sq);
|
||||
lines.push("1e308");
|
||||
lines.extend(sq);
|
||||
lines.push("1e308");
|
||||
let (s, log) = run(&lines);
|
||||
assert!(has(&log, "The volume is too large."), "{log:?}");
|
||||
assert!(!has(&log, "Total volume = 0"), "{log:?}");
|
||||
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_inquiry_volume_adds_and_subtracts_a_running_total() {
|
||||
let spec = cadcraft_engine::find_command("volume.measure").expect("no command volume.measure");
|
||||
assert_eq!(spec.menu, ["Tools", "Inquiry", "Volume"]);
|
||||
|
||||
// Add a 10 x 10 square by points at height 3, subtract a 2 x 2 square object at height 5.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("rectang 20,0 22,2").unwrap();
|
||||
s.start("volume.measure").unwrap();
|
||||
for l in ["a", "0,0", "10,0", "10,10", "0,10", "", "3", "s", "o", "21,0", "5", "", ""] {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
assert!(has(&s.log, "Total volume = 300.0000"), "{:?}", s.log);
|
||||
assert!(has(&s.log, "Total volume = 280.0000"), "{:?}", s.log);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
|
||||
// Architectural units show the volume as a number, not as a feet-and-inches length.
|
||||
let (_, log) = run(&["setvar lunits 4", "measuregeom", "v", "0,0", "10,0", "10,10", "0,10", "", "12"]);
|
||||
assert!(has(&log, "Volume = 1200"), "{log:?}");
|
||||
assert!(!has(&log, "'-"), "{log:?}");
|
||||
}
|
||||
|
||||
@@ -12,12 +12,10 @@ mod attrib_props;
|
||||
mod attribute_tools;
|
||||
mod block_keep;
|
||||
mod block_redefine;
|
||||
mod blockreplace;
|
||||
mod break_closed_polyline;
|
||||
mod break_join_locked_layers;
|
||||
mod break_keeps_z;
|
||||
mod burst;
|
||||
mod compare_export;
|
||||
mod constraint_geometry;
|
||||
mod constraint_infer;
|
||||
mod count;
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//! STRETCH's crossing window ignores ortho: the opposite corner is where the cursor is, so the
|
||||
//! window keeps its area and catches what it crosses. The same holds for every rectangle picked by
|
||||
//! two corners (PLOTWINDOW, MVIEW, -VIEW Window, REVCLOUD Rectangular).
|
||||
|
||||
use cadcraft_engine::snap::tracking::CursorQuery;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// Move the cursor to `raw` at the current prompt, as the canvas does.
|
||||
fn hover(s: &mut Session, raw: Vec2) -> Vec2 {
|
||||
let p = s.current_prompt().unwrap();
|
||||
let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false };
|
||||
let c = s.snap_cursor(raw, &q).point;
|
||||
s.cursor = c;
|
||||
c
|
||||
}
|
||||
|
||||
/// A session with ortho on, object snaps off and a line from 0,0 to 10,0.
|
||||
fn ortho_session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Pick the window's opposite corner at `to`, stretch by 5,0 and return the extents.
|
||||
fn stretch_from_corner(mut s: Session, to: Vec2) -> (Vec2, Vec2) {
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner");
|
||||
let c = hover(&mut s, to);
|
||||
assert!(c.near(to, 1e-9), "the corner is where the cursor is: {c:?}");
|
||||
s.input(Input::Point(c)).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
for t in ["0,0", "5,0"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(s.current_prompt().is_none(), "STRETCH is done, still at {:?}", s.current_prompt().map(|p| p.message));
|
||||
let b = s.doc().unwrap().extents(&s.space());
|
||||
(b.min, b.max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretch_implied_window_ignores_ortho() {
|
||||
let mut s = ortho_session();
|
||||
s.cmdline("stretch").unwrap();
|
||||
// Right to left: a crossing window.
|
||||
s.input(Input::Point(Vec2::new(12.0, 1.0))).unwrap();
|
||||
assert_eq!(stretch_from_corner(s, Vec2::new(8.0, -1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretch_crossing_mode_ignores_ortho() {
|
||||
let mut s = ortho_session();
|
||||
for t in ["stretch", "c", "8,-1"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert_eq!(stretch_from_corner(s, Vec2::new(12.0, 1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_corner_prompts_ignore_ortho() {
|
||||
for script in [&["plotwindow", "0,0"][..], &["mview", "0,0"], &["-view", "w", "v1", "0,0"], &["revcloud", "r", "0,0"]] {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap();
|
||||
if script[0] == "mview" {
|
||||
s.cmdline("layout s Layout1").unwrap();
|
||||
}
|
||||
for t in script {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Specify opposite corner", "{script:?}");
|
||||
assert!(p.corner, "{script:?}: the opposite corner ignores ortho");
|
||||
let c = hover(&mut s, Vec2::new(10.0, 4.0));
|
||||
assert!(c.near(Vec2::new(10.0, 4.0), 1e-9), "{script:?}: the corner is where the cursor is: {c:?}");
|
||||
}
|
||||
}
|
||||
+286
-8
@@ -1,13 +1,25 @@
|
||||
//! Whether a font may be embedded as a subset in exported files (PDF), from its licence bits
|
||||
//! (OS/2 `fsType`): fonts marked "restricted licence", "no subsetting" or "bitmap only" are
|
||||
//! never embedded; callers draw their text as outlines instead. Fonts without TrueType outlines
|
||||
//! (CFF-flavoured OpenType) aren't embedded either.
|
||||
//! TrueType subsets for embedding fonts in exported files (PDF): only the glyphs a document
|
||||
//! uses, plus the composite-glyph components they need. Glyph ids are kept (unused glyphs
|
||||
//! become empty), so text written with the original ids shows the same glyphs.
|
||||
//!
|
||||
//! Embedding follows the font's licence bits (OS/2 `fsType`): fonts marked "restricted
|
||||
//! licence", "no subsetting" or "bitmap only" are never embedded; callers draw their text as
|
||||
//! outlines instead. Fonts without TrueType outlines (CFF-flavoured OpenType) aren't subset
|
||||
//! here either.
|
||||
|
||||
use skrifa::FontRef;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::raw::types::Tag;
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
|
||||
use crate::ttf::Face;
|
||||
|
||||
/// Largest subset written (bytes).
|
||||
pub const MAX_SUBSET_BYTES: usize = 32 << 20;
|
||||
/// Most components followed in one composite glyph, and most glyphs a subset may reach.
|
||||
const MAX_COMPONENTS: usize = 256;
|
||||
|
||||
/// Whether a face may be embedded as a subset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Embedding {
|
||||
@@ -23,17 +35,40 @@ pub enum Embedding {
|
||||
NotTrueType,
|
||||
}
|
||||
|
||||
/// Why a subset couldn't be made.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SubsetError {
|
||||
/// The font may not be embedded as a subset.
|
||||
NotAllowed(Embedding),
|
||||
/// A table is missing or malformed.
|
||||
Malformed(&'static str),
|
||||
/// The subset would be larger than [`MAX_SUBSET_BYTES`].
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn font(face: &Face) -> Option<FontRef<'_>> {
|
||||
FontRef::from_index(&face.bytes, face.index).ok()
|
||||
}
|
||||
|
||||
fn tag(t: &[u8; 4]) -> Tag {
|
||||
Tag::new(t)
|
||||
}
|
||||
|
||||
fn table<'a>(f: &FontRef<'a>, t: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
f.table_data(Tag::new(t)).map(|d| d.as_bytes())
|
||||
f.table_data(tag(t)).map(|d| d.as_bytes())
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], i: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?]))
|
||||
}
|
||||
|
||||
/// The embedding permission of a face.
|
||||
pub fn embedding(face: &Face) -> Embedding {
|
||||
let Ok(f) = FontRef::from_index(&face.bytes, face.index) else { return Embedding::NotTrueType };
|
||||
let Some(f) = font(face) else { return Embedding::NotTrueType };
|
||||
if table(&f, b"glyf").is_none() || table(&f, b"loca").is_none() || table(&f, b"head").is_none() {
|
||||
return Embedding::NotTrueType;
|
||||
}
|
||||
@@ -49,14 +84,257 @@ pub fn embedding(face: &Face) -> Embedding {
|
||||
}
|
||||
}
|
||||
|
||||
/// Font-wide metrics for a font descriptor, in units of 1/1000 em.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FontInfo {
|
||||
/// PostScript name: letters, digits and hyphens only (never empty).
|
||||
pub postscript_name: String,
|
||||
/// Glyph bounding box: x min, y min, x max, y max.
|
||||
pub bbox: [f64; 4],
|
||||
pub ascent: f64,
|
||||
pub descent: f64,
|
||||
pub cap_height: f64,
|
||||
/// Degrees, counter-clockwise from vertical.
|
||||
pub italic_angle: f64,
|
||||
}
|
||||
|
||||
/// A finite value or 0.
|
||||
fn fin(v: f32) -> f64 {
|
||||
let v = f64::from(v);
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The descriptor metrics of a face.
|
||||
pub fn info(face: &Face) -> Option<FontInfo> {
|
||||
let f = font(face)?;
|
||||
let m = f.metrics(Size::new(1000.0), LocationRef::default());
|
||||
let bbox = m.bounds.map(|b| [fin(b.x_min), fin(b.y_min), fin(b.x_max), fin(b.y_max)]).unwrap_or([0.0, -250.0, 1000.0, 1000.0]);
|
||||
let name: String = f
|
||||
.localized_strings(skrifa::string::StringId::POSTSCRIPT_NAME)
|
||||
.english_or_first()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
|
||||
.take(63)
|
||||
.collect();
|
||||
Some(FontInfo {
|
||||
postscript_name: if name.is_empty() { "Font".into() } else { name },
|
||||
bbox,
|
||||
ascent: fin(m.ascent),
|
||||
descent: fin(m.descent),
|
||||
cap_height: m.cap_height.map(fin).unwrap_or(fin(m.ascent) * 0.72),
|
||||
italic_angle: fin(m.italic_angle),
|
||||
})
|
||||
}
|
||||
|
||||
/// The advance width of glyph `gid` in units of 1/1000 em.
|
||||
pub fn advance(face: &Face, gid: u16) -> f64 {
|
||||
font(face)
|
||||
.and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(u32::from(gid))))
|
||||
.map(fin)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Glyph data offsets from `loca`: `n + 1` entries, checked against the `glyf` length.
|
||||
fn loca_offsets(loca: &[u8], long: bool, n: usize, glyf_len: usize) -> Option<Vec<usize>> {
|
||||
let mut v = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let o = if long { u32_at(loca, i * 4)? as usize } else { usize::from(u16_at(loca, i * 2)?) * 2 };
|
||||
v.push(o.min(glyf_len));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// The data of glyph `g` (empty when its range is bad).
|
||||
fn glyph_data<'a>(glyf: &'a [u8], offsets: &[usize], g: usize) -> &'a [u8] {
|
||||
match (offsets.get(g), offsets.get(g + 1)) {
|
||||
(Some(a), Some(b)) if a < b => glyf.get(*a..*b).unwrap_or(&[]),
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph ids a composite glyph is built from.
|
||||
fn components(data: &[u8]) -> Vec<u16> {
|
||||
let mut out = Vec::new();
|
||||
// numberOfContours < 0: a composite.
|
||||
if u16_at(data, 0).is_none_or(|n| (n as i16) >= 0) {
|
||||
return out;
|
||||
}
|
||||
let mut pos = 10;
|
||||
while out.len() < MAX_COMPONENTS {
|
||||
let (Some(flags), Some(gid)) = (u16_at(data, pos), u16_at(data, pos + 2)) else { break };
|
||||
out.push(gid);
|
||||
pos += 4 + if flags & 0x0001 != 0 { 4 } else { 2 };
|
||||
pos += if flags & 0x0008 != 0 {
|
||||
2
|
||||
} else if flags & 0x0040 != 0 {
|
||||
4
|
||||
} else if flags & 0x0080 != 0 {
|
||||
8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if flags & 0x0020 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The sum of a table's big-endian 32-bit words (zero-padded).
|
||||
fn checksum(data: &[u8]) -> u32 {
|
||||
data.chunks(4).fold(0u32, |sum, c| {
|
||||
let mut w = [0u8; 4];
|
||||
for (d, s) in w.iter_mut().zip(c) {
|
||||
*d = *s;
|
||||
}
|
||||
sum.wrapping_add(u32::from_be_bytes(w))
|
||||
})
|
||||
}
|
||||
|
||||
/// A subset of `face` with the glyphs `gids` (and `.notdef` and the components they use). The
|
||||
/// other glyphs keep their ids but have no outline.
|
||||
pub fn subset(face: &Face, gids: &BTreeSet<u16>) -> Result<Vec<u8>, SubsetError> {
|
||||
let allowed = embedding(face);
|
||||
if allowed != Embedding::Allowed {
|
||||
return Err(SubsetError::NotAllowed(allowed));
|
||||
}
|
||||
let f = font(face).ok_or(SubsetError::Malformed("font"))?;
|
||||
let head = table(&f, b"head").filter(|h| h.len() >= 54).ok_or(SubsetError::Malformed("head"))?;
|
||||
let maxp = table(&f, b"maxp").ok_or(SubsetError::Malformed("maxp"))?;
|
||||
let glyf = table(&f, b"glyf").ok_or(SubsetError::Malformed("glyf"))?;
|
||||
let loca = table(&f, b"loca").ok_or(SubsetError::Malformed("loca"))?;
|
||||
let n = usize::from(u16_at(maxp, 4).ok_or(SubsetError::Malformed("maxp"))?);
|
||||
let long = u16_at(head, 50).ok_or(SubsetError::Malformed("head"))? != 0;
|
||||
let offsets = loca_offsets(loca, long, n, glyf.len()).ok_or(SubsetError::Malformed("loca"))?;
|
||||
// The glyphs to keep: asked for, .notdef, and composite components (transitively).
|
||||
let mut keep: BTreeSet<u16> = BTreeSet::new();
|
||||
let mut todo: Vec<u16> = std::iter::once(0).chain(gids.iter().copied()).filter(|g| usize::from(*g) < n).collect();
|
||||
while let Some(g) = todo.pop() {
|
||||
if !keep.insert(g) {
|
||||
continue;
|
||||
}
|
||||
for c in components(glyph_data(glyf, &offsets, usize::from(g))) {
|
||||
if usize::from(c) < n && !keep.contains(&c) {
|
||||
todo.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut new_glyf: Vec<u8> = Vec::new();
|
||||
let mut new_loca: Vec<u8> = Vec::with_capacity((n + 1) * 4);
|
||||
for g in 0..n {
|
||||
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
|
||||
if u16::try_from(g).is_ok_and(|g| keep.contains(&g)) {
|
||||
new_glyf.extend_from_slice(glyph_data(glyf, &offsets, g));
|
||||
while !new_glyf.len().is_multiple_of(4) {
|
||||
new_glyf.push(0);
|
||||
}
|
||||
}
|
||||
if new_glyf.len() > MAX_SUBSET_BYTES {
|
||||
return Err(SubsetError::TooLarge);
|
||||
}
|
||||
}
|
||||
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
|
||||
let mut new_head = head.to_vec();
|
||||
// checkSumAdjustment is set once the file is complete; offsets are long now.
|
||||
new_head.get_mut(8..12).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&[0; 4]);
|
||||
new_head.get_mut(50..52).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&1u16.to_be_bytes());
|
||||
let mut tables: Vec<([u8; 4], Vec<u8>)> = vec![(*b"glyf", new_glyf), (*b"head", new_head), (*b"loca", new_loca), (*b"maxp", maxp.to_vec())];
|
||||
for t in [b"cvt ", b"fpgm", b"hhea", b"hmtx", b"prep"] {
|
||||
if let Some(d) = table(&f, t) {
|
||||
tables.push((*t, d.to_vec()));
|
||||
}
|
||||
}
|
||||
if !tables.iter().any(|(t, _)| t == b"hhea") || !tables.iter().any(|(t, _)| t == b"hmtx") {
|
||||
return Err(SubsetError::Malformed("hhea/hmtx"));
|
||||
}
|
||||
tables.sort_by_key(|t| t.0);
|
||||
let out = sfnt(&tables);
|
||||
if out.len() > MAX_SUBSET_BYTES { Err(SubsetError::TooLarge) } else { Ok(out) }
|
||||
}
|
||||
|
||||
/// Write a TrueType file from `(tag, data)` tables sorted by tag, with checksums and the
|
||||
/// `head` checksum adjustment.
|
||||
fn sfnt(tables: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let count = tables.len() as u16;
|
||||
let mut pow = 1u16;
|
||||
let mut sel = 0u16;
|
||||
while pow.saturating_mul(2) <= count {
|
||||
pow = pow.saturating_mul(2);
|
||||
sel += 1;
|
||||
}
|
||||
let range = pow.saturating_mul(16);
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
for v in [count, range, sel, count.saturating_mul(16).saturating_sub(range)] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
let mut head_at = None;
|
||||
for (t, d) in tables {
|
||||
out.extend_from_slice(t);
|
||||
out.extend_from_slice(&checksum(d).to_be_bytes());
|
||||
out.extend_from_slice(&(offset as u32).to_be_bytes());
|
||||
out.extend_from_slice(&(d.len() as u32).to_be_bytes());
|
||||
offset += d.len().div_ceil(4) * 4;
|
||||
}
|
||||
for (t, d) in tables {
|
||||
if t == b"head" {
|
||||
head_at = Some(out.len());
|
||||
}
|
||||
out.extend_from_slice(d);
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
if let Some(h) = head_at {
|
||||
let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
|
||||
if let Some(slot) = out.get_mut(h + 8..h + 12) {
|
||||
slot.copy_from_slice(&adj.to_be_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn composite_components_are_followed_and_bounded() {
|
||||
// numberOfContours = -1, then two components (word args, MORE_COMPONENTS on the first).
|
||||
let mut g = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
g.extend_from_slice(&[0x00, 0x21, 0x00, 0x05, 0, 0, 0, 0]);
|
||||
g.extend_from_slice(&[0x00, 0x00, 0x00, 0x07, 0, 0]);
|
||||
assert_eq!(components(&g), [5, 7]);
|
||||
// Truncated and endless (always MORE_COMPONENTS) data stay bounded.
|
||||
assert_eq!(components(&g[..14]), [5]);
|
||||
let mut endless = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
for _ in 0..2000 {
|
||||
endless.extend_from_slice(&[0x00, 0x20, 0x00, 0x01, 0, 0]);
|
||||
}
|
||||
assert_eq!(components(&endless).len(), MAX_COMPONENTS);
|
||||
assert!(components(&[0, 1, 0]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_fonts_are_not_embedded() {
|
||||
let junk = Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 };
|
||||
assert_eq!(embedding(&junk), Embedding::NotTrueType);
|
||||
assert_eq!(embedding(&Face { bytes: std::sync::Arc::new(Vec::new()), index: 7 }), Embedding::NotTrueType);
|
||||
assert!(subset(&junk, &BTreeSet::from([1])).is_err());
|
||||
assert!(info(&junk).is_none());
|
||||
assert_eq!(advance(&junk, 3), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sfnt_directory_and_checksums() {
|
||||
let mut head = vec![0u8; 54];
|
||||
head[12..16].copy_from_slice(&0x5F0F_3CF5u32.to_be_bytes());
|
||||
let out = sfnt(&[(*b"head", head), (*b"maxp", vec![0, 0, 0x50, 0, 0, 1])]);
|
||||
assert_eq!(u16_at(&out, 4), Some(2));
|
||||
assert_eq!(out.len() % 4, 0);
|
||||
// The whole file sums to the magic number once the adjustment is in.
|
||||
assert_eq!(checksum(&out), 0xB1B0_AFBA);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,9 @@ thiserror = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
miniz_oxide = "0.9"
|
||||
craft-pdf = { workspace = true, features = ["write"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
|
||||
acadrust = "0.6.5"
|
||||
tiny-skia = { workspace = true }
|
||||
# Reads plotted PDFs back.
|
||||
craft-pdf = { workspace = true, features = ["read"] }
|
||||
|
||||
@@ -118,10 +118,6 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[
|
||||
("arrowBlock2", 344, K::Block),
|
||||
("fixedExtLength", 49, K::Real),
|
||||
("jogAngle", 50, K::Angle),
|
||||
// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1, DIMSTYLE). In a
|
||||
// DIMSTYLE record 70 also holds the record's flags, which come first.
|
||||
("textFill", 69, K::Int),
|
||||
("textFillColor", 70, K::Color),
|
||||
("angularZeroSuppression", 79, K::Int),
|
||||
("arcSymbol", 90, K::Int),
|
||||
("textVerticalPos", 145, K::Real),
|
||||
@@ -147,23 +143,14 @@ pub(crate) const DIMSAH: i32 = 173;
|
||||
/// DIMPOST's group in DIMSTYLE records.
|
||||
pub(crate) const DIMPOST: i32 = 3;
|
||||
|
||||
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMTFILL, DIMTFILLCLR, DIMARCSYM, DIMFXLON,
|
||||
/// DIMLTYPE, DIMLTEX1, DIMLTEX2. Files of a variable's [`newer_since`] version or later get it as
|
||||
/// a DIMSTYLE group (R2004, and R2007 for DIMTFILL and DIMTFILLCLR); older files get it in a
|
||||
/// style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of a
|
||||
/// group an older reader doesn't expect. The reader takes both.
|
||||
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 69, 70, 90, 290, 345, 346, 347];
|
||||
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups, except DIMTFILL and DIMTFILLCLR
|
||||
/// (see [`newer_since`]): the others came with AutoCAD 2006, whose files are the 2004 format
|
||||
/// (AC1018).
|
||||
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1,
|
||||
/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them
|
||||
/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of
|
||||
/// groups an R2000 reader doesn't expect. The reader takes both.
|
||||
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347];
|
||||
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose
|
||||
/// files are the 2004 format (AC1018).
|
||||
pub(crate) const NEWER_SINCE: Version = Version::R2004;
|
||||
|
||||
/// The first version that has `code` (one of [`NEWER_CODES`]) as a DIMSTYLE group: R2007 for
|
||||
/// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1), else
|
||||
/// [`NEWER_SINCE`].
|
||||
pub(crate) fn newer_since(code: i32) -> Version {
|
||||
if matches!(code, 69 | 70) { Version::R2007 } else { NEWER_SINCE }
|
||||
}
|
||||
/// Xdata list name of [`NEWER_CODES`] values.
|
||||
pub(crate) const DIMVARS_LIST: &str = "DIMVARS";
|
||||
|
||||
|
||||
@@ -1587,12 +1587,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
// Every variable with a DIMSTYLE group code; values the style can't take are
|
||||
// left at their defaults.
|
||||
let mut fields = serde_json::Map::new();
|
||||
// The first 70 is the record's flags; a later one is DIMTFILLCLR.
|
||||
let flags = tg.iter().position(|x| x.code == 70);
|
||||
for (i, tag) in tg.iter().enumerate().take_while(|(_, x)| x.code < 1000) {
|
||||
if Some(i) == flags {
|
||||
continue;
|
||||
}
|
||||
for tag in tg.iter().take_while(|x| x.code < 1000) {
|
||||
let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue };
|
||||
if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs, &rx.ltypes) {
|
||||
fields.insert(field.to_string(), v);
|
||||
|
||||
@@ -1393,21 +1393,16 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
|
||||
// dimension's own colour (#1052).
|
||||
let in_color = |color: Color| Common { layer: layer.into(), color, ..Common::default() };
|
||||
let (dim_c, ext_c, text_c) = (in_color(style.dim_line_color), in_color(style.ext_line_color), in_color(style.text_color));
|
||||
let line_entities = |i: usize, l: &[Vec2]| -> Vec<Entity> {
|
||||
for (i, l) in g.lines.iter().enumerate() {
|
||||
let c = match g.line_roles.get(i) {
|
||||
Some(cadcraft_render::LineRole::Ext) => &ext_c,
|
||||
_ => &dim_c,
|
||||
};
|
||||
l.windows(2)
|
||||
.filter_map(|seg| match seg {
|
||||
[a, b] => Some(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
|
||||
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) {
|
||||
out.extend(line_entities(i, l));
|
||||
for seg in l.windows(2) {
|
||||
if let [a, b] = seg {
|
||||
out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in &g.fills {
|
||||
if let [a, b, cc] = t.as_slice() {
|
||||
@@ -1418,20 +1413,6 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background in DIMTFILLCLR, under the text and its marks (SOLID
|
||||
// corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
out.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: in_color(style.text_fill_color),
|
||||
kind: EntityKind::Solid(Solid { corners: [p0.to3(0.0), p1.to3(0.0), p3.to3(0.0), p2.to3(0.0)] }),
|
||||
});
|
||||
}
|
||||
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) {
|
||||
out.extend(line_entities(i, l));
|
||||
}
|
||||
if !g.value.is_empty() {
|
||||
out.push(Entity {
|
||||
handle: Handle(0),
|
||||
@@ -2309,13 +2290,8 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
w.dimval(*code, enc, false);
|
||||
continue;
|
||||
}
|
||||
// Table groups where the file version has them, else xdata. DIMTFILL and DIMTFILLCLR
|
||||
// are written only when set, as other writers do, so a style without a text
|
||||
// background has a single 70 (its flags).
|
||||
if version >= dxf_ext::newer_since(*code) {
|
||||
if matches!(*code, 69 | 70) && v.get(*field) == defaults.get(*field) {
|
||||
continue;
|
||||
}
|
||||
// Table groups where the file version has them, else xdata.
|
||||
if version >= dxf_ext::NEWER_SINCE {
|
||||
w.dimval(*code, enc, false);
|
||||
} else if v.get(*field) != defaults.get(*field) {
|
||||
newer.i(1070, i64::from(*code));
|
||||
|
||||
+379
-357
@@ -1,12 +1,11 @@
|
||||
//! Vector PDF plotting (PLOT / EXPORTPDF), written with craft-pdf's PDF writer (krilla; pure
|
||||
//! Rust, no file access, so it runs on the web too).
|
||||
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (pure Rust, no file
|
||||
//! access, so it runs on the web too).
|
||||
//!
|
||||
//! One page per sheet: model-space extents fitted to a sheet, or a layout at 1:1 (or the given
|
||||
//! `scale`) on its paper (paper units are millimetres in metric drawings, inches otherwise).
|
||||
//! Lines are stroked paths, fills are filled paths, colours are RGB with colour 7 printing
|
||||
//! black on white paper, and line widths come from lineweights when enabled. The page setup's
|
||||
//! plot style table (or the `plotStyleTable` option) sets the printed colours, linetypes and
|
||||
//! lineweights.
|
||||
//! Lines are stroked paths (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white
|
||||
//! paper, and line widths come from lineweights when enabled. The page setup's plot style
|
||||
//! table (or the `plotStyleTable` option) sets the printed colours, linetypes and lineweights.
|
||||
//!
|
||||
//! Several sheets go into one file ([`pdf_sheets`], the `layouts` option), each page labelled
|
||||
//! with its layout name. Drawing layers become PDF layers (optional content groups) that a PDF
|
||||
@@ -20,19 +19,6 @@ use cadcraft_geom::{Bounds2, Vec2};
|
||||
use cadcraft_render::{Kind, Sheet, clip, paper};
|
||||
use serde_json::Value;
|
||||
|
||||
use craft_pdf::write::backend as kr;
|
||||
use craft_pdf::write::{self, Metadata, WriteError, Writer};
|
||||
use kr::action::LinkAction;
|
||||
use kr::annotation::{Annotation, LinkAnnotation, Target};
|
||||
use kr::error::KrillaError;
|
||||
use kr::geom::{Path, PathBuilder, Point, Rect, Transform};
|
||||
use kr::num::NormalizedF32;
|
||||
use kr::optional_content::OcgId;
|
||||
use kr::page::{PageLabel, PageSettings};
|
||||
use kr::paint::{Fill, FillRule, LineCap, LineJoin, Stroke};
|
||||
use kr::surface::Surface;
|
||||
use kr::text::{GlyphId, KrillaGlyph};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
mod fonts;
|
||||
@@ -45,13 +31,9 @@ const MAX_SHEET_MM: f64 = 5080.0;
|
||||
pub const MAX_PAGES: usize = 256;
|
||||
/// Most PDF layers in one file; objects on further layers print without one.
|
||||
const MAX_LAYERS: usize = 4096;
|
||||
/// Longest PDF layer name, in characters.
|
||||
const MAX_LAYER_NAME: usize = 1000;
|
||||
/// Most link annotations in one file, and the longest link target (bytes).
|
||||
const MAX_LINKS: usize = 10_000;
|
||||
const MAX_URL: usize = 2048;
|
||||
/// Longest title and page label written (characters).
|
||||
const MAX_TEXT: usize = 1000;
|
||||
|
||||
/// Plot settings. `None` fields fall back to the layout's page setup (or sensible defaults for
|
||||
/// model space).
|
||||
@@ -82,8 +64,8 @@ pub struct PdfOptions {
|
||||
pub window: Option<Bounds2>,
|
||||
/// Drawing layers become PDF layers (optional content groups). Default on.
|
||||
pub layers: bool,
|
||||
/// With `layers`: objects on layers that are off are plotted on PDF layers that start
|
||||
/// hidden (frozen layers are never plotted). Default off: they aren't plotted.
|
||||
/// With `layers`: objects on layers that are off are included on PDF layers that start
|
||||
/// hidden (frozen layers are never included). Default off: they aren't plotted.
|
||||
pub include_off_layers: bool,
|
||||
/// Hyperlinks: an entity's handle and its link target (a URL). The area the entity covers
|
||||
/// on the sheet becomes a link annotation.
|
||||
@@ -374,121 +356,23 @@ pub fn pdf_items(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions) ->
|
||||
if items.len() > MAX_PAGES {
|
||||
return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));
|
||||
}
|
||||
match write_pdf(d, items, o, o.text_as_geometry) {
|
||||
// A font the PDF writer can't embed after all, or one that makes it fail internally:
|
||||
// plot the text as outlines instead. Outlines embed no font, so this runs at most once.
|
||||
Err(Failure::Write(e)) if !o.text_as_geometry && font_failure(&e) => write_pdf(d, items, o, true).map_err(Failure::into_io),
|
||||
r => r.map_err(Failure::into_io),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a writer failure may come from embedding a font: a font the writer refused, or a
|
||||
/// panic inside the writer (caught by `guard`), which a malformed font can cause.
|
||||
fn font_failure(e: &WriteError) -> bool {
|
||||
match e {
|
||||
WriteError::Serialization(e) => matches!(**e, kr::error::KrillaError::Font(..)),
|
||||
WriteError::BackendPanic(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why writing failed: our own error, or the PDF writer's.
|
||||
enum Failure {
|
||||
Io(IoError),
|
||||
Write(WriteError),
|
||||
}
|
||||
|
||||
impl From<IoError> for Failure {
|
||||
fn from(e: IoError) -> Self {
|
||||
Failure::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WriteError> for Failure {
|
||||
fn from(e: WriteError) -> Self {
|
||||
Failure::Write(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn into_io(self) -> IoError {
|
||||
match self {
|
||||
Failure::Io(e) => e,
|
||||
Failure::Write(e) => IoError::Format(format!("PDF export failed: {e}")),
|
||||
// Layers that are off plot on PDF layers that start hidden: draw them from a copy with them on.
|
||||
let with_off;
|
||||
let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) {
|
||||
let mut c = d.clone();
|
||||
for l in c.layers.iter_mut().filter(|l| !l.frozen) {
|
||||
l.on = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the file, one sheet at a time: each sheet's display list is built, written as its page
|
||||
/// and dropped before the next.
|
||||
fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as_geometry: bool) -> std::result::Result<Vec<u8>, Failure> {
|
||||
write::guard(|| {
|
||||
// Layers that are off plot on PDF layers that start hidden: draw them from a copy with
|
||||
// them on.
|
||||
let with_off;
|
||||
let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) {
|
||||
let mut c = d.clone();
|
||||
for l in c.layers.iter_mut().filter(|l| !l.frozen) {
|
||||
l.on = true;
|
||||
}
|
||||
with_off = c;
|
||||
&with_off
|
||||
} else {
|
||||
d
|
||||
};
|
||||
let links = all_links(d, o);
|
||||
let title = if o.title.is_empty() { items.first().map(|(s, _)| sheet_name(s)).unwrap_or_default() } else { o.title.clone() };
|
||||
let metadata = Metadata {
|
||||
creator: "CADCraft".into(),
|
||||
producer: "CADCraft".into(),
|
||||
title: Some(title.chars().take(MAX_TEXT).collect()),
|
||||
..Metadata::default()
|
||||
};
|
||||
let settings = kr::SerializeSettings {
|
||||
compress_content_streams: o.compress,
|
||||
xmp_metadata: false,
|
||||
enable_tagging: false,
|
||||
..kr::SerializeSettings::default()
|
||||
};
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let mut fonts = fonts::Fonts::new(!text_as_geometry);
|
||||
let mut groups = Groups::default();
|
||||
for (s, po) in items {
|
||||
let page = sheet(drawn, s, po, &links)?;
|
||||
let doc = writer.document_mut();
|
||||
// Groups are added to the document, so they must exist before the page starts.
|
||||
groups.add(doc, d, &page.layers);
|
||||
write_page(doc, &page, &groups, &mut fonts)?;
|
||||
}
|
||||
if items.len() == 1 && !groups.ids.is_empty() {
|
||||
writer.document_mut().set_optional_content_page_mode(true);
|
||||
}
|
||||
Ok(writer.finish()?)
|
||||
})
|
||||
}
|
||||
|
||||
/// The PDF layers (optional content groups) of a file: one per drawing layer used, shared by
|
||||
/// all pages, at most [`MAX_LAYERS`].
|
||||
#[derive(Default)]
|
||||
struct Groups {
|
||||
ids: std::collections::HashMap<u32, OcgId>,
|
||||
}
|
||||
|
||||
impl Groups {
|
||||
/// Add groups for the drawing layers in `layers` that don't have one yet, named after the
|
||||
/// layer and visible when the layer is on in `d`, the drawing as given (not the copy that
|
||||
/// turns layers that are off on to plot them).
|
||||
fn add(&mut self, doc: &mut kr::Document, d: &Drawing, layers: &[u32]) {
|
||||
for l in layers {
|
||||
if self.ids.len() >= MAX_LAYERS || self.ids.contains_key(l) {
|
||||
continue;
|
||||
}
|
||||
let Some(layer) = d.layers.get(*l as usize) else { continue };
|
||||
let name: String = layer.name.chars().take(MAX_LAYER_NAME).collect();
|
||||
let id = doc.add_optional_content_group(name, layer.on);
|
||||
self.ids.insert(*l, id);
|
||||
}
|
||||
}
|
||||
with_off = c;
|
||||
&with_off
|
||||
} else {
|
||||
d
|
||||
};
|
||||
let links = all_links(d, o);
|
||||
let pages = items.iter().map(|(s, po)| sheet(drawn, s, po, &links)).collect::<Result<Vec<_>>>()?;
|
||||
let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() };
|
||||
let fonts = if o.text_as_geometry { fonts::Fonts::default() } else { fonts::Fonts::collect(pages.iter().map(|p| &p.list)) };
|
||||
Ok(assemble(d, &pages, &fonts, o, &title))
|
||||
}
|
||||
|
||||
/// One plotted sheet, ready to be written as a page.
|
||||
@@ -499,8 +383,7 @@ struct SheetPage {
|
||||
list: cadcraft_render::DisplayList,
|
||||
map: Map,
|
||||
clip_pt: Bounds2,
|
||||
/// The drawing layers (indices in the layer table) drawn on, in order of first use: the
|
||||
/// candidates for PDF layers.
|
||||
/// The drawing layers (indices in the layer table) of the content's `/L<i>` marks.
|
||||
layers: Vec<u32>,
|
||||
/// Link annotations: rectangle in points and target.
|
||||
links: Vec<(Bounds2, String)>,
|
||||
@@ -673,170 +556,125 @@ pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f6
|
||||
if s.is_finite() && s > 0.0 { s } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Largest coordinate written (points); a primitive reaching further is clipped to the plot
|
||||
/// area first. The PDF writer's geometry is `f32`.
|
||||
const MAX_COORD: f64 = 1.0e7;
|
||||
|
||||
/// A point in points as the writer's `f32`, or `None` when it is not finite or out of range.
|
||||
fn coord(p: Vec2) -> Option<(f32, f32)> {
|
||||
let ok = |v: f64| v.is_finite() && v.abs() <= MAX_COORD;
|
||||
(ok(p.x) && ok(p.y)).then_some((p.x as f32, p.y as f32))
|
||||
}
|
||||
|
||||
/// A rectangle path between two corners (points), if it has an area.
|
||||
fn rect_path(min: Vec2, max: Vec2) -> Option<Path> {
|
||||
let (x0, y0) = coord(min)?;
|
||||
let (x1, y1) = coord(max)?;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_rect(Rect::from_ltrb(x0, y0, x1, y1)?);
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Diameter (points) of the dot a zero-length hairline plots: a hairline has no width of its
|
||||
/// own, and this is about the thinnest mark that still prints.
|
||||
const HAIRLINE_DOT_PT: f32 = 0.25;
|
||||
|
||||
/// A circle path (points) around `(x, y)`: four cubic arcs.
|
||||
fn circle_path(x: f32, y: f32, r: f32) -> Option<Path> {
|
||||
// Control-point distance of a quarter-circle cubic, as a fraction of the radius.
|
||||
let k = r * 0.552_284_8;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to(x + r, y);
|
||||
pb.cubic_to(x + r, y + k, x + k, y + r, x, y + r);
|
||||
pb.cubic_to(x - k, y + r, x - r, y + k, x - r, y);
|
||||
pb.cubic_to(x - r, y - k, x - k, y - r, x, y - r);
|
||||
pb.cubic_to(x + k, y - r, x + r, y - k, x + r, y);
|
||||
pb.close();
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Write one sheet as a page: its size, label, link annotations and marks.
|
||||
fn write_page(doc: &mut kr::Document, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
|
||||
let (w, h) = (p.media.x as f32, p.media.y as f32);
|
||||
let label = PageLabel::new(None, Some(p.label.chars().take(MAX_TEXT).collect()), None);
|
||||
let settings = PageSettings::from_wh(w, h).ok_or_else(|| IoError::Format("the sheet has no area".into()))?.with_page_label(label);
|
||||
let mut page = doc.start_page_with(settings);
|
||||
for (r, url) in &p.links {
|
||||
let Some(uri) = uri(url) else { continue };
|
||||
// Annotation rectangles are given top-down, like the writer's page surface.
|
||||
let Some(rect) = Rect::from_ltrb(r.min.x as f32, h - r.max.y as f32, r.max.x as f32, h - r.min.y as f32) else { continue };
|
||||
let link = LinkAnnotation::new(rect, Target::Action(LinkAction::new(uri).into()));
|
||||
page.add_annotation(Annotation::new_link(link, None));
|
||||
/// A number for a content stream: short, finite, no exponent.
|
||||
fn num(out: &mut String, v: f64) {
|
||||
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
|
||||
let mut s = format!("{v:.3}");
|
||||
if s.contains('.') {
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if s.ends_with('.') {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
let mut surface = page.surface();
|
||||
// The surface's y axis points down; flip it so we draw in PDF points (y up).
|
||||
surface.push_transform(&Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, h));
|
||||
// Clip to the plot area.
|
||||
let mut drawn = Ok(());
|
||||
if let Some(clip) = rect_path(p.clip_pt.min, p.clip_pt.max) {
|
||||
surface.push_clip_path(&clip, &FillRule::NonZero);
|
||||
drawn = marks(&mut surface, p, groups, fonts);
|
||||
surface.pop();
|
||||
if s == "-0" {
|
||||
s = "0".into();
|
||||
}
|
||||
surface.pop();
|
||||
surface.finish();
|
||||
page.finish();
|
||||
drawn
|
||||
out.push_str(&s);
|
||||
}
|
||||
|
||||
/// What consecutive strokes share to be painted as one path: colour, width (points) and
|
||||
/// transparency (percent).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct StrokeStyle {
|
||||
rgb: Rgb,
|
||||
width: f32,
|
||||
transparency: u8,
|
||||
fn pt(out: &mut String, p: Vec2) {
|
||||
num(out, p.x);
|
||||
out.push(' ');
|
||||
num(out, p.y);
|
||||
}
|
||||
|
||||
/// Opacity of a transparency in percent.
|
||||
fn opacity(t: u8) -> NormalizedF32 {
|
||||
NormalizedF32::new(f32::from(100 - t.min(cadcraft_render::MAX_TRANSPARENCY)) / 100.0).unwrap_or(NormalizedF32::ONE)
|
||||
}
|
||||
|
||||
fn paint(c: Rgb) -> kr::paint::Paint {
|
||||
kr::color::rgb::Color::new(c.0, c.1, c.2).into()
|
||||
}
|
||||
|
||||
fn fill_path(s: &mut Surface<'_>, path: &Path, rgb: Rgb, t: u8) {
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(t), rule: FillRule::NonZero }));
|
||||
s.draw_path(path);
|
||||
}
|
||||
|
||||
/// Paint the strokes collected so far as one path.
|
||||
fn flush(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>) {
|
||||
let Some((style, pb)) = batch.take() else { return };
|
||||
let Some(path) = pb.finish() else { return };
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke {
|
||||
paint: paint(style.rgb),
|
||||
width: style.width,
|
||||
line_cap: LineCap::Round,
|
||||
line_join: LineJoin::Round,
|
||||
opacity: opacity(style.transparency),
|
||||
..Stroke::default()
|
||||
}));
|
||||
s.draw_path(&path);
|
||||
}
|
||||
|
||||
/// The PDF layer of the following marks changed from `from` to `to` (`None`: no layer): end
|
||||
/// the old one's marked content and start the new one's.
|
||||
fn layer_changed(s: &mut Surface<'_>, from: Option<OcgId>, to: Option<OcgId>) -> std::result::Result<(), Failure> {
|
||||
if from.is_some() {
|
||||
s.end_optional_content().map_err(krilla_failure)?;
|
||||
fn color(out: &mut String, c: Rgb, op: &str) {
|
||||
for v in [c.0, c.1, c.2] {
|
||||
num(out, f64::from(v) / 255.0);
|
||||
out.push(' ');
|
||||
}
|
||||
if let Some(id) = to {
|
||||
s.start_optional_content(id).map_err(krilla_failure)?;
|
||||
}
|
||||
Ok(())
|
||||
out.push_str(op);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn krilla_failure(e: KrillaError) -> Failure {
|
||||
Failure::Io(IoError::Format(format!("PDF export failed: {e}")))
|
||||
}
|
||||
|
||||
/// The page's marks, clipped to the plot area by the caller. Those of primitives on a layer
|
||||
/// with a PDF layer in `groups` are marked as its optional content.
|
||||
/// The page's marks; those of primitives on `layers` are marked as optional content
|
||||
/// (`/OC /L<layer> BDC` … `EMC`).
|
||||
///
|
||||
/// Consecutive strokes with the same colour, width and transparency are one path; a
|
||||
/// transparent primitive is stroked on its own (one path is painted once), so where
|
||||
/// primitives overlap they build up, as in PNG plots and on screen. Block contents and
|
||||
/// viewport contents share their reference's handle, so this goes by primitive, not by object.
|
||||
/// Each filled primitive is one path of its triangles.
|
||||
/// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its
|
||||
/// embedded font (`/F<font>`), placed by a text matrix.
|
||||
///
|
||||
/// Primitives that stand for a glyph in an embeddable font (see [`fonts::Fonts`]) are written
|
||||
/// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one
|
||||
/// em).
|
||||
fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
|
||||
let (list, map) = (&p.list, &p.map);
|
||||
let texts = fonts::texts(list);
|
||||
/// Transparent primitives are drawn with a graphics state of their alpha (`/T<percent> gs`);
|
||||
/// the transparencies used are returned for the page's `/ExtGState` (see [`ext_gstates`]). A
|
||||
/// list without transparency writes no graphics states.
|
||||
fn content_stream(
|
||||
list: &cadcraft_render::DisplayList,
|
||||
map: &Map,
|
||||
clip_pt: &Bounds2,
|
||||
layers: &[u32],
|
||||
texts: &std::collections::HashMap<u32, (usize, &cadcraft_render::PlotText)>,
|
||||
) -> (String, Vec<u8>) {
|
||||
let marked: std::collections::HashSet<u32> = layers.iter().copied().collect();
|
||||
let white = Rgb(255, 255, 255);
|
||||
let mut batch: Option<(StrokeStyle, PathBuilder)> = None;
|
||||
let mut layer: Option<OcgId> = None;
|
||||
for (i, prim) in list.prims.iter().enumerate() {
|
||||
let want = list.prim_layers.get(i).and_then(|l| groups.ids.get(l)).copied();
|
||||
let mut c = String::new();
|
||||
c.push_str("q\n1 J 1 j\n");
|
||||
// Clip to the plot area.
|
||||
pt(&mut c, clip_pt.min);
|
||||
c.push(' ');
|
||||
num(&mut c, clip_pt.width());
|
||||
c.push(' ');
|
||||
num(&mut c, clip_pt.height());
|
||||
c.push_str(" re W n\n");
|
||||
let mut stroke: Option<Rgb> = None;
|
||||
let mut fill: Option<Rgb> = None;
|
||||
let mut width: Option<f64> = None;
|
||||
let mut pending = false;
|
||||
let flush = |c: &mut String, pending: &mut bool| {
|
||||
if *pending {
|
||||
c.push_str("S\n");
|
||||
*pending = false;
|
||||
}
|
||||
};
|
||||
// The PDF layer (optional content) the marks are in: `/OC /L<i> BDC` … `EMC`.
|
||||
let mut layer: Option<u32> = None;
|
||||
// Inside a text object (`BT` … `ET`), with this font set.
|
||||
let mut text_font: Option<Option<usize>> = None;
|
||||
// The transparency (percent) marks are drawn with, and those used on the page.
|
||||
let mut alpha: u8 = 0;
|
||||
let mut alphas: Vec<u8> = Vec::new();
|
||||
for (i, p) in list.prims.iter().enumerate() {
|
||||
let want = list.prim_layers.get(i).copied().filter(|l| marked.contains(l));
|
||||
let text = u32::try_from(i).ok().and_then(|i| texts.get(&i));
|
||||
if text_font.is_some() && (text.is_none() || want != layer) {
|
||||
c.push_str("ET\n");
|
||||
text_font = None;
|
||||
}
|
||||
if want != layer {
|
||||
flush(s, &mut batch);
|
||||
layer_changed(s, layer, want)?;
|
||||
flush(&mut c, &mut pending);
|
||||
if layer.is_some() {
|
||||
c.push_str("EMC\n");
|
||||
}
|
||||
if let Some(l) = want {
|
||||
let _ = writeln!(c, "/OC /L{l} BDC");
|
||||
}
|
||||
layer = want;
|
||||
}
|
||||
let rgb = prim.display_rgb(white);
|
||||
let raw = list.points(prim);
|
||||
let rgb = p.display_rgb(white);
|
||||
let raw = list.points(p);
|
||||
if raw.iter().any(|q| !q.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
// A wipeout's mask always hides what is under it.
|
||||
let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
|
||||
let w = f64::from(prim.lw) * PT_PER_MM;
|
||||
let t = if p.kind == Kind::Mask { 0 } else { p.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
|
||||
if t != alpha {
|
||||
flush(&mut c, &mut pending);
|
||||
let _ = writeln!(c, "/T{t} gs");
|
||||
if !alphas.contains(&t) {
|
||||
alphas.push(t);
|
||||
}
|
||||
alpha = t;
|
||||
}
|
||||
let w = f64::from(p.lw) * PT_PER_MM;
|
||||
let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
|
||||
match prim.kind {
|
||||
match p.kind {
|
||||
Kind::Polyline | Kind::Infinite { .. } => {
|
||||
let pts: Vec<Vec2> = match prim.kind {
|
||||
let pts: Vec<Vec2> = match p.kind {
|
||||
Kind::Infinite { ray } => match (raw.first(), raw.get(1)) {
|
||||
(Some(base), Some(dir)) => {
|
||||
let a = map.pt(*base);
|
||||
let dir_pt = map.pt(*base + *dir) - a;
|
||||
match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) {
|
||||
match clip::clip_infinite(a, dir_pt, ray, clip_pt) {
|
||||
Some((x, y)) => vec![x, y],
|
||||
None => continue,
|
||||
}
|
||||
@@ -848,117 +686,301 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts:
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// With a vertex out of range, keep the pieces inside the plot area, widened so
|
||||
// a wide stroke just outside it still shows its edge.
|
||||
let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) };
|
||||
let style = StrokeStyle { rgb, width: w as f32, transparency: t };
|
||||
if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) {
|
||||
flush(s, &mut batch);
|
||||
// A transparent primitive is stroked on its own (one path is painted once), so
|
||||
// where primitives overlap they build up, as in PNG plots and on screen. Block
|
||||
// contents and viewport contents share their reference's handle, so this goes
|
||||
// by primitive, not by object.
|
||||
if t > 0 {
|
||||
flush(&mut c, &mut pending);
|
||||
}
|
||||
for run in runs {
|
||||
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
// The writer leaves out a path without extent, so a zero-length line is
|
||||
// painted as the round-cap dot its stroke would make.
|
||||
if let Some(&(x, y)) = pts.first()
|
||||
&& pts.iter().all(|q| *q == (x, y))
|
||||
{
|
||||
flush(s, &mut batch);
|
||||
let d = if w > 0.0 { w as f32 } else { HAIRLINE_DOT_PT };
|
||||
if let Some(path) = circle_path(x, y, d / 2.0) {
|
||||
fill_path(s, &path, rgb, t);
|
||||
}
|
||||
continue;
|
||||
if stroke != Some(rgb) || width != Some(w) {
|
||||
flush(&mut c, &mut pending);
|
||||
if stroke != Some(rgb) {
|
||||
color(&mut c, rgb, "RG");
|
||||
stroke = Some(rgb);
|
||||
}
|
||||
let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new()));
|
||||
for (j, (x, y)) in pts.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
if width != Some(w) {
|
||||
num(&mut c, w);
|
||||
c.push_str(" w\n");
|
||||
width = Some(w);
|
||||
}
|
||||
}
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
pt(&mut c, *q);
|
||||
c.push_str(if i == 0 { " m\n" } else { " l\n" });
|
||||
}
|
||||
pending = true;
|
||||
}
|
||||
Kind::Tris | Kind::Mask => {
|
||||
flush(s, &mut batch);
|
||||
flush(&mut c, &mut pending);
|
||||
// A wipeout's mask is paper white.
|
||||
let rgb = if prim.kind == Kind::Mask { white } else { rgb };
|
||||
if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i))
|
||||
&& let Some(font) = fonts.font(&text.glyph.face)
|
||||
{
|
||||
glyph(s, font, text, map, rgb, t);
|
||||
let rgb = if p.kind == Kind::Mask { white } else { rgb };
|
||||
if fill != Some(rgb) {
|
||||
color(&mut c, rgb, "rg");
|
||||
fill = Some(rgb);
|
||||
}
|
||||
if let Some((font, t)) = text {
|
||||
glyph(&mut c, &mut text_font, *font, t, map);
|
||||
continue;
|
||||
}
|
||||
let mapped: Vec<Vec2> = raw.iter().map(|q| map.pt(*q)).collect();
|
||||
// With a vertex out of range, keep the parts inside the plot area.
|
||||
let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) };
|
||||
let mut pb = PathBuilder::new();
|
||||
for tri in tris.as_chunks::<3>().0 {
|
||||
let Some(v) = tri.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
|
||||
for (j, (x, y)) in v.iter().enumerate() {
|
||||
if j == 0 {
|
||||
pb.move_to(*x, *y);
|
||||
} else {
|
||||
pb.line_to(*x, *y);
|
||||
}
|
||||
let mut any = false;
|
||||
for t in raw.as_chunks::<3>().0 {
|
||||
for (i, q) in t.iter().enumerate() {
|
||||
pt(&mut c, map.pt(*q));
|
||||
c.push_str(if i == 0 { " m\n" } else { " l\n" });
|
||||
}
|
||||
pb.close();
|
||||
c.push_str("h\n");
|
||||
any = true;
|
||||
}
|
||||
if let Some(path) = pb.finish() {
|
||||
fill_path(s, &path, rgb, t);
|
||||
if any {
|
||||
c.push_str("f\n");
|
||||
}
|
||||
}
|
||||
Kind::Point => {
|
||||
let Some(q) = raw.first() else { continue };
|
||||
flush(s, &mut batch);
|
||||
let half = (w.max(0.5)) / 2.0;
|
||||
let c = map.pt(*q);
|
||||
if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) {
|
||||
fill_path(s, &path, rgb, t);
|
||||
flush(&mut c, &mut pending);
|
||||
if fill != Some(rgb) {
|
||||
color(&mut c, rgb, "rg");
|
||||
fill = Some(rgb);
|
||||
}
|
||||
let half = (w.max(0.5)) / 2.0;
|
||||
pt(&mut c, map.pt(*q) - Vec2::new(half, half));
|
||||
c.push(' ');
|
||||
num(&mut c, half * 2.0);
|
||||
c.push(' ');
|
||||
num(&mut c, half * 2.0);
|
||||
c.push_str(" re f\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(s, &mut batch);
|
||||
layer_changed(s, layer, None)
|
||||
}
|
||||
|
||||
/// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one
|
||||
/// em along each axis). A glyph whose matrix is out of range or collapses is left out.
|
||||
fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) {
|
||||
let k = map.s * map.k;
|
||||
let g = &t.glyph;
|
||||
let (Some((a, b)), Some((c, d)), Some((e, f))) = (coord(g.x * k), coord(g.y * k), coord(map.pt(g.origin))) else { return };
|
||||
let det = a * d - b * c;
|
||||
if !det.is_finite() || det == 0.0 {
|
||||
return;
|
||||
flush(&mut c, &mut pending);
|
||||
if text_font.is_some() {
|
||||
c.push_str("ET\n");
|
||||
}
|
||||
let text = g.ch.to_string();
|
||||
let glyphs = [KrillaGlyph::new(GlyphId::new(u32::from(g.gid)), 0.0, 0.0, 0.0, 0.0, 0..text.len(), None)];
|
||||
// The writer sets glyphs upright on a y-down surface: flip the em's y axis back.
|
||||
s.push_transform(&Transform::from_row(a, b, -c, -d, e, f));
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(transparency), rule: FillRule::NonZero }));
|
||||
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, 1.0, false);
|
||||
s.pop();
|
||||
if layer.is_some() {
|
||||
c.push_str("EMC\n");
|
||||
}
|
||||
c.push_str("Q\n");
|
||||
(c, alphas)
|
||||
}
|
||||
|
||||
/// A link target as a URI: 7-bit printable ASCII, other bytes percent-encoded. `None` for
|
||||
/// script and data URLs, which a plot never carries.
|
||||
fn uri(url: &str) -> Option<String> {
|
||||
/// The page resources' graphics states for transparencies `alphas` (percent): `/T<percent>`
|
||||
/// sets the stroke and fill alpha (`/CA`, `/ca`). Empty when there are none.
|
||||
fn ext_gstates(alphas: &[u8]) -> String {
|
||||
if alphas.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from(" /ExtGState <<");
|
||||
for t in alphas {
|
||||
let mut a = String::new();
|
||||
num(&mut a, f64::from(100 - u32::from((*t).min(cadcraft_render::MAX_TRANSPARENCY))) / 100.0);
|
||||
let _ = write!(out, " /T{t} << /Type /ExtGState /CA {a} /ca {a} >>");
|
||||
}
|
||||
out.push_str(" >>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Write one glyph as text: opens a text object and sets the font when needed, then places the
|
||||
/// glyph with a text matrix (font size 1: the matrix axes are one em).
|
||||
fn glyph(c: &mut String, text_font: &mut Option<Option<usize>>, font: usize, t: &cadcraft_render::PlotText, map: &Map) {
|
||||
if text_font.is_none() {
|
||||
c.push_str("BT\n");
|
||||
*text_font = Some(None);
|
||||
}
|
||||
if *text_font != Some(Some(font)) {
|
||||
let _ = writeln!(c, "/F{font} 1 Tf");
|
||||
*text_font = Some(Some(font));
|
||||
}
|
||||
let s = map.s * map.k;
|
||||
let g = &t.glyph;
|
||||
for v in [g.x.x * s, g.x.y * s, g.y.x * s, g.y.y * s] {
|
||||
num(c, v);
|
||||
c.push(' ');
|
||||
}
|
||||
pt(c, map.pt(g.origin));
|
||||
let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid);
|
||||
}
|
||||
|
||||
/// A PDF text string: a literal with `(`, `)` and `\` escaped when it is printable ASCII,
|
||||
/// else UTF-16BE with a byte order mark (hex). At most 1000 characters.
|
||||
fn pdf_string(s: &str) -> String {
|
||||
let s: String = s.chars().take(1000).collect();
|
||||
if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
|
||||
let mut out = String::from("(");
|
||||
for ch in s.chars() {
|
||||
if matches!(ch, '(' | ')' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push(')');
|
||||
return out;
|
||||
}
|
||||
let mut out = String::from("<FEFF");
|
||||
for u in s.encode_utf16() {
|
||||
let _ = write!(out, "{u:04X}");
|
||||
}
|
||||
out.push('>');
|
||||
out
|
||||
}
|
||||
|
||||
/// A link target as a PDF URI string: 7-bit ASCII (other bytes percent-encoded), `(`, `)` and
|
||||
/// `\` escaped. `None` for script and data URLs, which a plot never carries.
|
||||
fn uri_string(url: &str) -> Option<String> {
|
||||
let url = url.trim();
|
||||
let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
|
||||
if url.is_empty() || url.len() > MAX_URL || matches!(scheme.as_str(), "javascript" | "vbscript" | "data") {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(url.len());
|
||||
let mut out = String::from("(");
|
||||
for b in url.bytes() {
|
||||
match b {
|
||||
b'(' | b')' | b'\\' => {
|
||||
out.push('\\');
|
||||
out.push(char::from(b));
|
||||
}
|
||||
0x21..=0x7E => out.push(char::from(b)),
|
||||
_ => {
|
||||
let _ = write!(out, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(')');
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A stream object body, Flate-compressed when asked.
|
||||
fn stream(data: &[u8], compress: bool, extra: &str) -> Vec<u8> {
|
||||
let (data, filter) = if compress { (miniz_oxide::deflate::compress_to_vec_zlib(data, 6), " /Filter /FlateDecode") } else { (data.to_vec(), "") };
|
||||
let mut out = format!("<< /Length {}{filter}{extra} >>\nstream\n", data.len()).into_bytes();
|
||||
out.extend_from_slice(&data);
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
out
|
||||
}
|
||||
|
||||
/// PDF objects being written: bodies by object number - 1.
|
||||
#[derive(Default)]
|
||||
struct Objects {
|
||||
bodies: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Objects {
|
||||
/// Reserve an object number (its body is set later).
|
||||
fn reserve(&mut self) -> usize {
|
||||
self.bodies.push(Vec::new());
|
||||
self.bodies.len()
|
||||
}
|
||||
fn set(&mut self, n: usize, body: impl Into<Vec<u8>>) {
|
||||
if let Some(b) = n.checked_sub(1).and_then(|i| self.bodies.get_mut(i)) {
|
||||
*b = body.into();
|
||||
}
|
||||
}
|
||||
fn add(&mut self, body: impl Into<Vec<u8>>) -> usize {
|
||||
let n = self.reserve();
|
||||
self.set(n, body);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// `n 0 R` references, space-separated.
|
||||
fn refs(ids: impl IntoIterator<Item = usize>) -> String {
|
||||
ids.into_iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Assemble the file: catalog, page tree, pages with their content streams and link
|
||||
/// annotations, the PDF layers (one optional content group per drawing layer used, named
|
||||
/// like it), page labels, document information, cross-reference table and trailer.
|
||||
fn assemble(d: &Drawing, pages: &[SheetPage], fonts: &fonts::Fonts, o: &PdfOptions, title: &str) -> Vec<u8> {
|
||||
let mut objs = Objects::default();
|
||||
let catalog = objs.reserve();
|
||||
let tree = objs.reserve();
|
||||
// Optional content groups shared by all pages, by layer index: (layer, object).
|
||||
let mut ocg_ids: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
|
||||
let mut ocgs: Vec<(u32, usize)> = Vec::new();
|
||||
let mut kids = Vec::new();
|
||||
// Page dictionaries are completed once the fonts are written: (page, its fonts, the rest).
|
||||
let mut page_dicts: Vec<(usize, Vec<usize>, String, String, String)> = Vec::new();
|
||||
for p in pages {
|
||||
let page = objs.reserve();
|
||||
let texts = fonts.texts(&p.list);
|
||||
let (marks, alphas) = content_stream(&p.list, &p.map, &p.clip_pt, &p.layers, &texts);
|
||||
let content = objs.add(stream(marks.as_bytes(), o.compress, ""));
|
||||
let mut used: Vec<usize> = texts.values().map(|(f, _)| *f).collect();
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
let mut props = String::new();
|
||||
for l in &p.layers {
|
||||
let Some(layer) = d.layers.get(*l as usize) else { continue };
|
||||
let id = match ocg_ids.get(l) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
let id = objs.add(format!("<< /Type /OCG /Name {} >>", pdf_string(&layer.name)));
|
||||
ocg_ids.insert(*l, id);
|
||||
ocgs.push((*l, id));
|
||||
id
|
||||
}
|
||||
};
|
||||
let _ = write!(props, " /L{l} {id} 0 R");
|
||||
}
|
||||
let mut annots = Vec::new();
|
||||
for (r, url) in &p.links {
|
||||
let Some(uri) = uri_string(url) else { continue };
|
||||
let mut rect = String::new();
|
||||
pt(&mut rect, r.min);
|
||||
rect.push(' ');
|
||||
pt(&mut rect, r.max);
|
||||
annots.push(objs.add(format!("<< /Type /Annot /Subtype /Link /Rect [{rect}] /Border [0 0 0] /A << /S /URI /URI {uri} >> >>")));
|
||||
}
|
||||
let mut mb = String::new();
|
||||
pt(&mut mb, p.media);
|
||||
let annots = if annots.is_empty() { String::new() } else { format!(" /Annots [{}]", refs(annots)) };
|
||||
page_dicts.push((page, used, props, ext_gstates(&alphas), format!("/MediaBox [0 0 {mb}] /Contents {content} 0 R{annots}")));
|
||||
kids.push(page);
|
||||
}
|
||||
let font_ids = if fonts.is_empty() { Vec::new() } else { fonts.write(&mut objs, o.compress) };
|
||||
for (page, used, props, gstates, rest) in page_dicts {
|
||||
let mut res = String::new();
|
||||
if !props.is_empty() {
|
||||
let _ = write!(res, " /Properties <<{props} >>");
|
||||
}
|
||||
res.push_str(&gstates);
|
||||
let f: Vec<String> = used.iter().filter_map(|i| Some(format!("/F{i} {} 0 R", font_ids.get(*i)?))).collect();
|
||||
if !f.is_empty() {
|
||||
let _ = write!(res, " /Font << {} >>", f.join(" "));
|
||||
}
|
||||
objs.set(page, format!("<< /Type /Page /Parent {tree} 0 R /Resources <<{res} >> {rest} >>"));
|
||||
}
|
||||
objs.set(tree, format!("<< /Type /Pages /Kids [{}] /Count {} >>", refs(kids.iter().copied()), kids.len()));
|
||||
let mut cat = format!("<< /Type /Catalog /Pages {tree} 0 R");
|
||||
if !ocgs.is_empty() {
|
||||
// Optional content needs PDF 1.5; the catalog's version overrides the header's.
|
||||
let off: Vec<usize> = ocgs.iter().filter(|(l, _)| d.layers.get(*l as usize).is_some_and(|l| !l.on)).map(|(_, id)| *id).collect();
|
||||
let all = refs(ocgs.iter().map(|(_, id)| *id));
|
||||
let _ = write!(cat, " /Version /1.5 /OCProperties << /OCGs [{all}] /D << /Name (Layers) /Order [{all}] /OFF [{}] >> >>", refs(off));
|
||||
if pages.len() == 1 {
|
||||
cat.push_str(" /PageMode /UseOC");
|
||||
}
|
||||
}
|
||||
let labels: Vec<String> = pages.iter().enumerate().map(|(i, p)| format!("{i} << /P {} >>", pdf_string(&p.label))).collect();
|
||||
let _ = write!(cat, " /PageLabels << /Nums [{}] >> >>", labels.join(" "));
|
||||
objs.set(catalog, cat);
|
||||
let info = objs.add(format!("<< /Producer (CADCraft) /Creator (CADCraft) /Title {} >>", pdf_string(title)));
|
||||
let size: usize = objs.bodies.iter().map(Vec::len).sum();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(size + 64 * objs.bodies.len() + 256);
|
||||
out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
|
||||
let mut offsets = Vec::with_capacity(objs.bodies.len());
|
||||
for (i, body) in objs.bodies.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1);
|
||||
for o in &offsets {
|
||||
let _ = writeln!(x, "{o:010} 00000 n ");
|
||||
}
|
||||
let _ = write!(x, "trailer\n<< /Size {} /Root {catalog} 0 R /Info {info} 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1);
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
+151
-40
@@ -1,22 +1,25 @@
|
||||
//! Real text in PDF plots: the outline-font glyphs a plot draws ([`DisplayList::texts`]) are
|
||||
//! written as text in their font, which the PDF writer embeds as a subset with a `ToUnicode`
|
||||
//! map, so the text can be selected, searched and copied.
|
||||
//! written as text in embedded TrueType subsets, so the text can be selected, searched and
|
||||
//! copied. Each font is a `Type0` font with `Identity-H` encoding over a `CIDFontType2`
|
||||
//! descendant whose CIDs are the glyph ids (`/CIDToGIDMap /Identity`), with a `ToUnicode`
|
||||
//! map for copying and searching.
|
||||
//!
|
||||
//! Fonts whose licence bits forbid embedding a subset, fonts without TrueType outlines, and
|
||||
//! anything past the caps on fonts and font data stay as filled outlines.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use cadcraft_fonts::subset::{self, Embedding};
|
||||
use cadcraft_fonts::subset::{self, FontInfo};
|
||||
use cadcraft_fonts::ttf::Face;
|
||||
use cadcraft_render::{DisplayList, PlotText};
|
||||
use craft_pdf::write::backend::text::Font;
|
||||
|
||||
use super::{Objects, num, refs, stream};
|
||||
|
||||
/// Most embedded fonts in one file.
|
||||
const MAX_FONTS: usize = 64;
|
||||
/// Most font file data embedded from in one file (bytes of the font files the subsets are
|
||||
/// taken from).
|
||||
const MAX_FONT_BYTES: usize = 256 << 20;
|
||||
/// Most embedded font data in one file (uncompressed bytes).
|
||||
const MAX_FONT_BYTES: usize = 64 << 20;
|
||||
|
||||
/// A face's identity: its loaded file and face index.
|
||||
type FaceKey = (usize, usize, u32);
|
||||
@@ -25,45 +28,153 @@ fn key(f: &Face) -> FaceKey {
|
||||
(f.bytes.as_ptr() as usize, f.bytes.len(), f.index)
|
||||
}
|
||||
|
||||
/// The fonts of a document, decided face by face as glyphs are written.
|
||||
/// An embedded font.
|
||||
struct Font {
|
||||
face: Face,
|
||||
/// Glyphs used, and the character each shows.
|
||||
chars: BTreeMap<u16, char>,
|
||||
/// The subset file.
|
||||
data: Vec<u8>,
|
||||
info: FontInfo,
|
||||
}
|
||||
|
||||
/// The fonts of a document: those that can be embedded, by face.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fonts {
|
||||
/// Off: every glyph stays an outline.
|
||||
enabled: bool,
|
||||
/// Each face seen, with its font when it is embedded. The face is kept so its key isn't
|
||||
/// reused by another file loaded at the same address.
|
||||
by_face: HashMap<FaceKey, (Face, Option<Font>)>,
|
||||
count: usize,
|
||||
bytes: usize,
|
||||
fonts: Vec<Font>,
|
||||
by_face: HashMap<FaceKey, Option<usize>>,
|
||||
}
|
||||
|
||||
impl Fonts {
|
||||
pub(super) fn new(enabled: bool) -> Fonts {
|
||||
Fonts { enabled, by_face: HashMap::new(), count: 0, bytes: 0 }
|
||||
/// Collect the glyphs of every plotted text glyph and subset their fonts.
|
||||
pub(super) fn collect<'a>(lists: impl IntoIterator<Item = &'a DisplayList>) -> Fonts {
|
||||
let mut used: Vec<(Face, BTreeMap<u16, char>)> = Vec::new();
|
||||
let mut index: HashMap<FaceKey, usize> = HashMap::new();
|
||||
for list in lists {
|
||||
for t in usable(list) {
|
||||
let k = key(&t.glyph.face);
|
||||
let i = match index.get(&k) {
|
||||
Some(i) => *i,
|
||||
None => {
|
||||
index.insert(k, used.len());
|
||||
used.push((t.glyph.face.clone(), BTreeMap::new()));
|
||||
used.len() - 1
|
||||
}
|
||||
};
|
||||
if let Some((_, chars)) = used.get_mut(i) {
|
||||
chars.entry(t.glyph.gid).or_insert(t.glyph.ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Fonts::default();
|
||||
let mut total = 0usize;
|
||||
for (face, chars) in used {
|
||||
let k = key(&face);
|
||||
let font = (out.fonts.len() < MAX_FONTS)
|
||||
.then(|| subset::subset(&face, &chars.keys().copied().collect::<BTreeSet<u16>>()).ok())
|
||||
.flatten()
|
||||
.filter(|data| total.saturating_add(data.len()) <= MAX_FONT_BYTES)
|
||||
.and_then(|data| Some(Font { info: subset::info(&face)?, face, chars, data }));
|
||||
match font {
|
||||
Some(f) => {
|
||||
total = total.saturating_add(f.data.len());
|
||||
out.by_face.insert(k, Some(out.fonts.len()));
|
||||
out.fonts.push(f);
|
||||
}
|
||||
None => {
|
||||
out.by_face.insert(k, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The font a glyph of `face` is written in; `None`: draw its outline.
|
||||
pub(super) fn font(&mut self, face: &Face) -> Option<Font> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
let k = key(face);
|
||||
if let Some((_, f)) = self.by_face.get(&k) {
|
||||
return f.clone();
|
||||
}
|
||||
let len = face.bytes.len();
|
||||
let font = (self.count < MAX_FONTS && self.bytes.saturating_add(len) <= MAX_FONT_BYTES && subset::embedding(face) == Embedding::Allowed)
|
||||
.then(|| Font::new(face.bytes.clone().into(), face.index))
|
||||
.flatten();
|
||||
if font.is_some() {
|
||||
self.count += 1;
|
||||
self.bytes = self.bytes.saturating_add(len);
|
||||
}
|
||||
self.by_face.insert(k, (face.clone(), font.clone()));
|
||||
font
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.fonts.is_empty()
|
||||
}
|
||||
|
||||
/// The embedded font (index) a glyph is written in; `None`: draw its outline.
|
||||
fn font_of(&self, t: &PlotText) -> Option<usize> {
|
||||
self.by_face.get(&key(&t.glyph.face)).copied().flatten()
|
||||
}
|
||||
|
||||
/// The plotted glyphs of `list` written as text: outline primitive → (font, glyph).
|
||||
pub(super) fn texts<'a>(&self, list: &'a DisplayList) -> HashMap<u32, (usize, &'a PlotText)> {
|
||||
usable(list).filter_map(|t| Some((t.prim, (self.font_of(t)?, t)))).collect()
|
||||
}
|
||||
|
||||
/// Write the font objects; returns each font's `Type0` object, by font index.
|
||||
pub(super) fn write(&self, objs: &mut Objects, compress: bool) -> Vec<usize> {
|
||||
self.fonts.iter().map(|f| write_font(objs, f, compress)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The text glyphs of a list that stand for a primitive of it, by primitive.
|
||||
pub(super) fn texts(list: &DisplayList) -> HashMap<u32, &PlotText> {
|
||||
list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0).map(|t| (t.prim, t)).collect()
|
||||
/// The text glyphs of a list that stand for a primitive of it.
|
||||
fn usable(list: &DisplayList) -> impl Iterator<Item = &PlotText> {
|
||||
list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0)
|
||||
}
|
||||
|
||||
/// Six capital letters naming a subset (PDF subset fonts are `ABCDEF+Name`), from its glyphs.
|
||||
fn subset_tag(f: &Font) -> String {
|
||||
let mut h: u64 = 0xCBF2_9CE4_8422_2325;
|
||||
for b in f.info.postscript_name.bytes().chain(f.chars.keys().flat_map(|g| g.to_be_bytes())) {
|
||||
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01B3);
|
||||
}
|
||||
(0..6).map(|i| char::from(b'A' + ((h >> (i * 8)) % 26) as u8)).collect()
|
||||
}
|
||||
|
||||
fn write_font(objs: &mut Objects, f: &Font, compress: bool) -> usize {
|
||||
let name = format!("{}+{}", subset_tag(f), f.info.postscript_name);
|
||||
let file = objs.add(stream(&f.data, compress, &format!(" /Length1 {}", f.data.len())));
|
||||
let mut bbox = String::new();
|
||||
for (i, v) in f.info.bbox.iter().enumerate() {
|
||||
if i > 0 {
|
||||
bbox.push(' ');
|
||||
}
|
||||
num(&mut bbox, v.round());
|
||||
}
|
||||
let mut metrics = String::new();
|
||||
for (k, v) in [("Ascent", f.info.ascent), ("Descent", f.info.descent), ("CapHeight", f.info.cap_height), ("ItalicAngle", f.info.italic_angle)] {
|
||||
let _ = write!(metrics, " /{k} ");
|
||||
num(&mut metrics, v.round());
|
||||
}
|
||||
let descriptor =
|
||||
objs.add(format!("<< /Type /FontDescriptor /FontName /{name} /Flags 4 /FontBBox [{bbox}]{metrics} /StemV 80 /FontFile2 {file} 0 R >>"));
|
||||
let mut widths = String::new();
|
||||
for g in f.chars.keys() {
|
||||
let _ = write!(widths, "{g} [");
|
||||
num(&mut widths, subset::advance(&f.face, *g).round());
|
||||
widths.push_str("] ");
|
||||
}
|
||||
let cid = objs.add(format!(
|
||||
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>",
|
||||
widths.trim_end()
|
||||
));
|
||||
let to_unicode = objs.add(stream(to_unicode(&f.chars).as_bytes(), compress, ""));
|
||||
objs.add(format!(
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H /DescendantFonts [{}] /ToUnicode {to_unicode} 0 R >>",
|
||||
refs([cid])
|
||||
))
|
||||
}
|
||||
|
||||
/// A `ToUnicode` CMap from glyph ids (2-byte codes) to the characters they show.
|
||||
fn to_unicode(chars: &BTreeMap<u16, char>) -> String {
|
||||
let mut s = String::from(
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
|
||||
);
|
||||
let entries: Vec<(&u16, &char)> = chars.iter().collect();
|
||||
// At most 100 entries per block.
|
||||
for block in entries.chunks(100) {
|
||||
let _ = writeln!(s, "{} beginbfchar", block.len());
|
||||
for (g, c) in block {
|
||||
let _ = write!(s, "<{g:04X}> <");
|
||||
for u in c.encode_utf16(&mut [0; 2]) {
|
||||
let _ = write!(s, "{u:04X}");
|
||||
}
|
||||
s.push_str(">\n");
|
||||
}
|
||||
s.push_str("endbfchar\n");
|
||||
}
|
||||
s.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||
s
|
||||
}
|
||||
|
||||
+62
-102
@@ -4,9 +4,6 @@ use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[path = "../tests/support/pdf_read.rs"]
|
||||
mod pdf_read;
|
||||
|
||||
fn sample() -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
|
||||
@@ -421,22 +418,50 @@ fn hostile_export_window_is_an_error() {
|
||||
assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok());
|
||||
}
|
||||
|
||||
/// Read a PDF back (its structure checked by the reader) and return the first page's decoded
|
||||
/// content.
|
||||
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
|
||||
fn check_pdf(bytes: &[u8]) -> String {
|
||||
assert!(String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).trim_end().ends_with("%%EOF"));
|
||||
let doc = pdf_read::read(bytes);
|
||||
assert!(!doc.pages.is_empty());
|
||||
doc.content().to_string()
|
||||
assert!(bytes.starts_with(b"%PDF-1.4"));
|
||||
let find = |pat: &[u8]| bytes.windows(pat.len()).position(|w| w == pat);
|
||||
let rfind = |pat: &[u8]| bytes.windows(pat.len()).rposition(|w| w == pat);
|
||||
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
|
||||
assert!(tail.trim_end().ends_with("%%EOF"));
|
||||
let sx = rfind(b"startxref\n").unwrap();
|
||||
let after = String::from_utf8_lossy(&bytes[sx + 10..]).to_string();
|
||||
let xref_off: usize = after.lines().next().unwrap().trim().parse().unwrap();
|
||||
assert!(bytes[xref_off..].starts_with(b"xref\n"));
|
||||
let xref = String::from_utf8_lossy(&bytes[xref_off..]).to_string();
|
||||
let mut lines = xref.lines().skip(1);
|
||||
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
let objs = bytes.windows(7).filter(|w| w == b" 0 obj\n").count();
|
||||
assert_eq!(count, objs + 1, "xref size = objects + free entry");
|
||||
assert_eq!(bytes.windows(6).filter(|w| w == b"endobj").count(), objs);
|
||||
let entries: Vec<&str> = lines.take(count).collect();
|
||||
assert!(entries[0].starts_with("0000000000 65535 f"));
|
||||
for (i, e) in entries.iter().enumerate().skip(1) {
|
||||
let off: usize = e[..10].parse().unwrap();
|
||||
assert!(bytes[off..].starts_with(format!("{i} 0 obj").as_bytes()), "object {i} offset");
|
||||
}
|
||||
assert!(xref.contains(&format!("/Size {count}")));
|
||||
// The content stream's /Length matches its data.
|
||||
let sp = find(b"/Length ").unwrap();
|
||||
let after = String::from_utf8_lossy(&bytes[sp + 8..sp + 30]).to_string();
|
||||
let len: usize = after.split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
|
||||
let start = find(b"stream\n").unwrap() + 7;
|
||||
assert!(bytes[start + len..].starts_with(b"\nendstream"));
|
||||
let compressed = find(b"/FlateDecode").is_some();
|
||||
let data = &bytes[start..start + len];
|
||||
if compressed {
|
||||
String::from_utf8(miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()).unwrap()
|
||||
} else {
|
||||
String::from_utf8(data.to_vec()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn media_box(bytes: &[u8]) -> (f64, f64) {
|
||||
pdf_read::read(bytes).media()
|
||||
}
|
||||
|
||||
/// The line widths (`w`) the content sets.
|
||||
fn widths(content: &str) -> Vec<f64> {
|
||||
pdf_read::ops(content).iter().filter(|o| o.op == "w").filter_map(|o| o.nums.first().copied()).collect()
|
||||
let text = String::from_utf8_lossy(bytes).to_string();
|
||||
let i = text.find("/MediaBox [0 0 ").unwrap() + 15;
|
||||
let v: Vec<f64> = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
|
||||
(v[0], v[1])
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -446,17 +471,16 @@ fn pdf_model_fitted_to_sheet() {
|
||||
let content = check_pdf(&bytes);
|
||||
let (w, h) = media_box(&bytes);
|
||||
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}");
|
||||
assert!(pdf_read::count(&content, "m") > 0 && pdf_read::count(&content, "l") > 0 && pdf_read::count(&content, "S") > 0);
|
||||
assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
|
||||
// Walls layer is red; colour 7 prints black.
|
||||
assert!(content.contains("1 0 0 RG"));
|
||||
assert!(content.contains("0 0 0 RG"));
|
||||
// Uncompressed output is plain text and also valid.
|
||||
let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap();
|
||||
let c2 = check_pdf(&raw);
|
||||
assert!(!pdf_read::read(&raw).pages[0].compressed);
|
||||
assert!(pdf_read::read(&bytes).pages[0].compressed);
|
||||
assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
|
||||
assert_eq!(media_box(&raw), (612.0, 792.0));
|
||||
assert!(pdf_read::clip_rect(&c2).is_some(), "clipped to the plot area: {c2}");
|
||||
assert!(c2.contains("re W n"));
|
||||
// write() picks PDF by extension.
|
||||
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
|
||||
}
|
||||
@@ -489,12 +513,11 @@ fn pdf_layout_one_to_one_with_viewport() {
|
||||
// ANSI A landscape (inches → points).
|
||||
assert_eq!(media_box(&bytes), (792.0, 612.0));
|
||||
// Viewport border at 1:1: x from 1in to 9in = 72pt..648pt.
|
||||
let corner = |o: &pdf_read::Op| matches!(o.op.as_str(), "m" | "l") && (o.nums[0] - 72.0).abs() < 1e-6 && (o.nums[1] - 72.0).abs() < 1e-6;
|
||||
assert!(pdf_read::drawn(&content).iter().any(corner), "{content}");
|
||||
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
|
||||
// Lineweights on: default 0.25 mm = 0.709 pt.
|
||||
assert!(widths(&content).iter().any(|w| (w - 0.709).abs() < 0.001), "{content}");
|
||||
assert!(content.contains("0.709 w"));
|
||||
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
|
||||
assert!(widths(&check_pdf(&off)).contains(&0.0));
|
||||
assert!(check_pdf(&off).contains("0 w"));
|
||||
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
|
||||
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
|
||||
}
|
||||
@@ -2415,16 +2438,15 @@ fn pdf_plots_transparency_only_when_asked() {
|
||||
};
|
||||
let on = plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap();
|
||||
let content = check_pdf(&on);
|
||||
// One graphics state: fill alpha 0.5 (strokes stay opaque).
|
||||
assert_eq!(pdf_read::read(&on).pages[0].alphas, [(1.0, 0.5)]);
|
||||
let text = String::from_utf8_lossy(&on).to_string();
|
||||
assert!(text.contains("/ExtGState << /T50 << /Type /ExtGState /CA 0.5 /ca 0.5 >> >>"), "{text}");
|
||||
// The line is opaque, the solid at half alpha.
|
||||
let ops = pdf_read::ops(&content);
|
||||
assert!(ops.windows(2).any(|w| w[0].op == "gs" && w[1].op == "rg" && w[1].nums == [1.0, 0.0, 0.0]), "{content}");
|
||||
assert_eq!(pdf_read::count(&content, "gs"), 1, "{content}");
|
||||
assert!(content.contains("/T50 gs\n1 0 0 rg"), "{content}");
|
||||
// Off (the default, and explicitly): no graphics states, and the same bytes either way.
|
||||
let off = plot(&d, &Space::Model, &opts(serde_json::json!({}))).unwrap();
|
||||
assert_eq!(off, plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": false}))).unwrap());
|
||||
assert!(pdf_read::read(&off).pages[0].alphas.is_empty() && pdf_read::count(&check_pdf(&off), "gs") == 0);
|
||||
let off_text = String::from_utf8_lossy(&off).to_string();
|
||||
assert!(!off_text.contains("ExtGState") && !off_text.contains(" gs\n"));
|
||||
// On, but nothing transparent: the same bytes as off.
|
||||
let opaque = transparent_over_line(0);
|
||||
assert_eq!(
|
||||
@@ -2443,13 +2465,13 @@ fn layout_page_setup_plot_transparency_is_used() {
|
||||
let faint = Common { color: Color::Index(5), transparency: Transparency::Percent(90), ..Common::default() };
|
||||
d.add(&paper, faint, EntityKind::Solid(Solid { corners })).unwrap();
|
||||
let raw = serde_json::json!({"compress": false});
|
||||
let alphas = |d: &Drawing, o: &serde_json::Value| pdf_read::read(&plot(d, &paper, o).unwrap()).pages[0].alphas.clone();
|
||||
assert!(alphas(&d, &raw).is_empty());
|
||||
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).contains("ExtGState"));
|
||||
d.layouts[0].page.plot_transparency = true;
|
||||
let on = alphas(&d, &raw);
|
||||
assert!(on.iter().any(|(_, f)| (f - 0.1).abs() < 1e-6), "the faint solid fills at alpha 0.1: {on:?}");
|
||||
let text = String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).to_string();
|
||||
assert!(text.contains("/T90 << /Type /ExtGState /CA 0.1 /ca 0.1 >>"), "{text}");
|
||||
// The plot's own option wins over the page setup.
|
||||
assert!(alphas(&d, &serde_json::json!({"compress": false, "plotTransparency": false})).is_empty());
|
||||
let off = serde_json::json!({"compress": false, "plotTransparency": false});
|
||||
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &off).unwrap()).contains("ExtGState"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2504,14 +2526,11 @@ fn pdf_strokes_overlapping_transparent_objects_one_by_one() {
|
||||
};
|
||||
// 50 %: each line is its own stroke, so they build up where they cross.
|
||||
let glass = content(&crossing(50), true);
|
||||
assert!(pdf_read::count(&glass, "gs") > 0, "{glass}");
|
||||
let page =
|
||||
pdf_read::read(&plot(&crossing(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap()).pages.remove(0);
|
||||
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
|
||||
assert_eq!(pdf_read::count(&glass, "S"), 2, "{glass}");
|
||||
assert!(glass.contains("/T50 gs"), "{glass}");
|
||||
assert_eq!(glass.matches("S\n").count(), 2, "{glass}");
|
||||
// Opaque: one path as before, whether the option is on or off.
|
||||
let opaque = content(&crossing(0), true);
|
||||
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
|
||||
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
|
||||
assert_eq!(opaque, content(&crossing(0), false));
|
||||
// Transparency off: the transparent layer plots as before too.
|
||||
assert_eq!(content(&crossing(50), false), opaque);
|
||||
@@ -2548,15 +2567,11 @@ fn pdf_strokes_each_transparent_primitive_inside_a_block() {
|
||||
check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap())
|
||||
};
|
||||
let half = content(&crossing_block(50), true);
|
||||
assert!(pdf_read::count(&half, "gs") > 0, "{half}");
|
||||
let page = pdf_read::read(&plot(&crossing_block(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap())
|
||||
.pages
|
||||
.remove(0);
|
||||
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
|
||||
assert_eq!(pdf_read::count(&half, "S"), 2, "{half}");
|
||||
assert!(half.contains("/T50 gs"), "{half}");
|
||||
assert_eq!(half.matches("S\n").count(), 2, "{half}");
|
||||
// Opaque: one path, the same with the option on or off.
|
||||
let opaque = content(&crossing_block(0), true);
|
||||
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
|
||||
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
|
||||
assert_eq!(opaque, content(&crossing_block(0), false));
|
||||
assert_eq!(content(&crossing_block(50), false), opaque);
|
||||
}
|
||||
@@ -2601,61 +2616,6 @@ fn drawing_without_insunits_stays_unitless() {
|
||||
assert_eq!(insunits(&write_dxf(&Drawing::new_imperial())), Some("1".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_clips_a_primitive_beyond_the_coordinate_range() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let paper = Space::Paper("Layout1".into());
|
||||
let v = |x: f64, y: f64| Vec3::new(x, y, 0.0);
|
||||
// 4e7 mm at 1:1 is about 1.1e8 pt, past the range the writer's f32 geometry takes.
|
||||
d.add(&paper, Common::default(), EntityKind::Line(Line { a: v(10.0, 20.0), b: v(4.0e7, 20.0) })).unwrap();
|
||||
d.add(&paper, Common::default(), EntityKind::Solid(Solid { corners: [v(10.0, 50.0), v(4.0e7, 50.0), v(10.0, 60.0), v(10.0, 60.0)] })).unwrap();
|
||||
let bytes = plot(&d, &paper, &serde_json::json!({"fit": false, "scale": 1.0, "compress": false})).unwrap();
|
||||
let content = check_pdf(&bytes);
|
||||
let clip = pdf_read::clip_rect(&content).unwrap();
|
||||
// The x extent of what each paint operator paints.
|
||||
let (mut xs, mut painted) = (Vec::new(), Vec::new());
|
||||
for o in pdf_read::drawn(&content) {
|
||||
match o.op.as_str() {
|
||||
"m" | "l" => xs.push(o.nums[0]),
|
||||
"S" | "f" => painted.push((o.op, xs.drain(..).fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(x), b.max(x))))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (stroke, fill) = (painted.iter().find(|p| p.0 == "S"), painted.iter().find(|p| p.0 == "f"));
|
||||
// Both run from inside the plot area to its right edge: the stroke to just past it.
|
||||
let (_, (lo, hi)) = stroke.expect("the line is drawn");
|
||||
assert!(*lo < clip[2] && *hi > clip[2] && *hi < clip[2] + 5.0, "line from {lo} to {hi}, clip {clip:?}");
|
||||
let (_, (lo, hi)) = fill.expect("the solid is filled");
|
||||
assert!(*lo < clip[2] && (*hi - clip[2]).abs() < 1e-3, "fill from {lo} to {hi}, clip {clip:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_plots_a_zero_length_line_as_a_dot() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let red = |lw| Common { color: Color::Index(1), lineweight: lw, ..Common::default() };
|
||||
// Points at 1:1 on A4, so the dot sits away from the clip edges.
|
||||
let at = Vec3::new(50.0, 50.0, 0.0);
|
||||
d.add(&Space::Model, red(Lineweight::Mm100(100)), EntityKind::Line(Line { a: at, b: at })).unwrap();
|
||||
// The x extent of what the content fills in red: the dot's diameter.
|
||||
let red_fill_width = |o: serde_json::Value| {
|
||||
let content = check_pdf(&plot(&d, &Space::Model, &o).unwrap());
|
||||
let ops = pdf_read::ops(&content);
|
||||
let start = ops.iter().position(|o| o.op == "rg" && o.nums == [1.0, 0.0, 0.0]).expect("a red fill");
|
||||
let end = start + ops[start..].iter().position(|o| o.op == "f").expect("filled");
|
||||
assert!(ops[start..end].iter().filter(|o| o.op == "c").count() == 4, "{content}");
|
||||
let xs: Vec<f64> =
|
||||
ops[start..end].iter().filter(|o| matches!(o.op.as_str(), "m" | "c")).flat_map(|o| o.nums.iter().step_by(2).copied()).collect();
|
||||
xs.iter().copied().fold(f64::MIN, f64::max) - xs.iter().copied().fold(f64::MAX, f64::min)
|
||||
};
|
||||
let opts = |lineweights: bool| serde_json::json!({"paper": "A4", "fit": false, "scale": 1.0, "compress": false, "lineweights": lineweights});
|
||||
// 1 mm lineweight: a dot 1 mm across.
|
||||
let w = red_fill_width(opts(true));
|
||||
assert!((w - 72.0 / 25.4).abs() < 0.01, "{w}");
|
||||
// A hairline still leaves a mark.
|
||||
let w = red_fill_width(opts(false));
|
||||
assert!((w - 0.25).abs() < 0.01, "{w}");
|
||||
}
|
||||
|
||||
/// DXF text as (code, value) pairs.
|
||||
fn dxf_pairs(text: &str) -> Vec<(String, String)> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
//! DIMTFILL (group 69) and DIMTFILLCLR (group 70) survive DXF: as DIMSTYLE groups from R2007,
|
||||
//! after the record's own flags (also 70) and only when set, and in the style's `CADCRAFT`
|
||||
//! "DIMVARS" xdata in older files. The `*D` block of a dimension with DIMTFILL 2 holds the background as a SOLID.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version};
|
||||
|
||||
fn filled() -> DimStyle {
|
||||
DimStyle { name: "Fill".into(), text_fill: 2, text_fill_color: Color::Index(3), ..DimStyle::default() }
|
||||
}
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(filled());
|
||||
d
|
||||
}
|
||||
|
||||
fn dxf(d: &Drawing, v: Version) -> String {
|
||||
String::from_utf8(cadcraft_io::write_with(d, "x.dxf", &SaveOptions { version: Some(v), binary: false }).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The DIMSTYLE record named `name`, as trimmed lines.
|
||||
fn record<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
|
||||
let l: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let at = l.windows(2).position(|w| w == ["2", name]).unwrap();
|
||||
let end = l[at..].windows(2).position(|w| w == ["0", "DIMSTYLE"] || w == ["0", "ENDTAB"]).map_or(l.len(), |e| at + e);
|
||||
l[at..end].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fill_roundtrips_in_the_dimstyle_table() {
|
||||
let d = drawing();
|
||||
for v in [Version::R2007, Version::R2018] {
|
||||
let text = dxf(&d, v);
|
||||
let rec = record(&text, "Fill");
|
||||
let groups: Vec<(&str, &str)> = rec.chunks(2).filter_map(|p| Some((*p.first()?, *p.get(1)?))).collect();
|
||||
assert!(groups.contains(&("69", "2")), "{v:?}: DIMTFILL as group 69");
|
||||
let seventies: Vec<&str> = groups.iter().filter(|g| g.0 == "70").map(|g| g.1).collect();
|
||||
assert_eq!(seventies, ["0", "3"], "{v:?}: the flags, then DIMTFILLCLR");
|
||||
assert!(!rec.contains(&"DIMVARS"), "{v:?}: no xdata");
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}");
|
||||
}
|
||||
for v in [Version::R2000, Version::R2004] {
|
||||
let text = dxf(&d, v);
|
||||
let rec = record(&text, "Fill");
|
||||
assert!(!rec.chunks(2).any(|p| p[0] == "69"), "{v:?}: no group 69");
|
||||
assert!(rec.windows(4).any(|w| w == ["1070", "69", "1070", "2"]), "{v:?}: DIMTFILL in the xdata");
|
||||
assert!(rec.windows(4).any(|w| w == ["1070", "70", "1070", "3"]), "{v:?}: DIMTFILLCLR in the xdata");
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_text_fill_writes_no_groups() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(DimStyle { name: "Plain".into(), ..DimStyle::default() });
|
||||
d.dim_styles.push(DimStyle { name: "Canvas".into(), text_fill: 1, ..DimStyle::default() });
|
||||
let text = dxf(&d, Version::R2018);
|
||||
let groups = |name| -> Vec<(String, String)> {
|
||||
record(&text, name).chunks(2).filter_map(|p| Some((p.first()?.to_string(), p.get(1)?.to_string()))).collect()
|
||||
};
|
||||
let plain = groups("Plain");
|
||||
// The record's flags are its only 70.
|
||||
assert_eq!(plain.iter().filter(|g| g.0 == "70").count(), 1, "{plain:?}");
|
||||
assert!(!plain.iter().any(|g| g.0 == "69"), "{plain:?}");
|
||||
let canvas = groups("Canvas");
|
||||
assert!(canvas.contains(&("69".into(), "1".into())), "{canvas:?}");
|
||||
assert_eq!(canvas.iter().filter(|g| g.0 == "70").count(), 1, "the default DIMTFILLCLR is not written: {canvas:?}");
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.dim_style("Plain").unwrap().text_fill, 0);
|
||||
assert_eq!(back.dim_style("Canvas").unwrap().text_fill, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_fill_groups_fall_back_to_defaults() {
|
||||
let text = dxf(&drawing(), Version::R2018);
|
||||
for (fill, color) in [("99", "3"), ("-5", "3"), ("2", "40000"), ("2", "-7")] {
|
||||
let edited = text.replacen(" 69\r\n2\r\n 70\r\n3\r\n", &format!(" 69\r\n{fill}\r\n 70\r\n{color}\r\n"), 1);
|
||||
assert_ne!(edited, text, "groups replaced");
|
||||
let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();
|
||||
let st = back.dim_style("Fill").unwrap();
|
||||
assert!(st.text_fill <= 2, "{fill}/{color}: {}", st.text_fill);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_flags_are_not_read_as_dimtfillclr() {
|
||||
let text = dxf(&drawing(), Version::R2018);
|
||||
// Flags 64 (referenced) and no DIMTFILLCLR group: the color stays ByBlock.
|
||||
let edited =
|
||||
text.replacen(" 2\r\nFill\r\n 70\r\n0\r\n", " 2\r\nFill\r\n 70\r\n64\r\n", 1).replacen(" 69\r\n2\r\n 70\r\n3\r\n", " 69\r\n2\r\n", 1);
|
||||
assert!(edited.contains(" 2\r\nFill\r\n 70\r\n64\r\n"), "flags edited");
|
||||
assert!(edited.contains(" 69\r\n2\r\n") && !edited.contains(" 69\r\n2\r\n 70\r\n3\r\n"), "DIMTFILLCLR removed");
|
||||
let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();
|
||||
let st = back.dim_style("Fill").unwrap();
|
||||
assert_eq!((st.text_fill, st.text_fill_color), (2, Color::ByBlock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_dimension_keeps_its_background_mask() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() });
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap();
|
||||
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
|
||||
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Dimension(dm) if dm.block.is_some())), "read with its block");
|
||||
let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default());
|
||||
assert_eq!(list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Mask).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_color_fill_is_drawn_once() {
|
||||
let mut d = drawing();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap();
|
||||
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
|
||||
let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default());
|
||||
// The block's SOLID in DIMTFILLCLR (color 3, green) is the background; no second one is added.
|
||||
let green = list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Tris && p.color == cadcraft_color::Rgb(0, 255, 0)).count();
|
||||
assert_eq!(green, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_inspection_frame_stays_over_the_background() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() });
|
||||
let mut dm = Dimension { style: "Fill".into(), ..linear() };
|
||||
dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: "A".into(), rate: String::new() });
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
|
||||
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
|
||||
let dm = back
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => Some(dm.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(dm.block.is_some(), "read with its block");
|
||||
let corners = cadcraft_render::dimension_in(&back, &dm).text_fill;
|
||||
let (lo, hi) = corners.iter().fold((corners[0], corners[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
|
||||
let inside = |q: &cadcraft_geom::Vec2| q.x >= lo.x - 1e-6 && q.x <= hi.x + 1e-6 && q.y >= lo.y - 1e-6 && q.y <= hi.y + 1e-6;
|
||||
let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default());
|
||||
let mask = list.prims.iter().position(|p| p.kind == cadcraft_render::Kind::Mask).unwrap();
|
||||
// The separator after the label, the frame's top and bottom: lines inside the background.
|
||||
let marks: Vec<usize> = list
|
||||
.prims
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.kind == cadcraft_render::Kind::Polyline && list.points(p).len() == 2 && list.points(p).iter().all(inside))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert!(marks.len() >= 3 && marks.iter().all(|i| *i > mask), "{marks:?} after the mask at {mask}");
|
||||
}
|
||||
|
||||
/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5.
|
||||
fn linear() -> Dimension {
|
||||
Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(0.0, 5.0, 0.0),
|
||||
text_mid: Vec3::ZERO,
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(10.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: None,
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_block_holds_the_text_background() {
|
||||
let mut d = drawing();
|
||||
// An inspection frame (top, bottom and two angular ends: six segments) goes over the background.
|
||||
let mut dm = Dimension { style: "Fill".into(), ..linear() };
|
||||
dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() });
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
|
||||
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
|
||||
let name = back
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::Dimension(dm) => dm.block.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ents: Vec<_> = back.block(&name).unwrap().entities.iter().cloned().collect();
|
||||
let solid = ents.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == Color::Index(3));
|
||||
let mtext = ents.iter().position(|e| matches!(e.kind, EntityKind::MText(_)));
|
||||
assert!(solid.is_some() && solid < mtext, "the background before the text: {solid:?}, {mtext:?}");
|
||||
let lines_after = ents.iter().skip(solid.unwrap_or(0)).filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!(lines_after, 6, "the frame after the background, the other lines before it");
|
||||
}
|
||||
@@ -44,9 +44,9 @@ fn a_millimetre_layout_plots_in_millimetres_whatever_measurement_says() {
|
||||
|
||||
// The frame lands 10 mm (28.35 pt) inside the page, not 10 inches.
|
||||
let bytes = pdf(&d, &Space::Paper("SHEET".into()), &PdfOptions { compress: false, ..PdfOptions::default() }).unwrap();
|
||||
let doc = crate::pdf_read::read(&bytes);
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let xs: Vec<f64> =
|
||||
crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect();
|
||||
text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect();
|
||||
assert!(!xs.is_empty(), "no path in the PDF");
|
||||
let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(*x), b.max(*x)));
|
||||
assert!((lo - 28.35).abs() < 0.1 && (hi - 813.54).abs() < 0.1, "frame from {lo} to {hi} pt");
|
||||
|
||||
@@ -13,7 +13,6 @@ mod dim_block_resave_roundtrip;
|
||||
mod dim_block_text;
|
||||
mod dim_marks_roundtrip;
|
||||
mod dimstyle_vars_roundtrip;
|
||||
mod dimtfill_roundtrip;
|
||||
mod drafting_spacing;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod dwg_header_vars;
|
||||
@@ -46,8 +45,6 @@ mod paperview_marker;
|
||||
mod passthrough_image_roundtrip;
|
||||
mod pdf_followups;
|
||||
mod pdf_layers;
|
||||
#[path = "../support/pdf_read.rs"]
|
||||
mod pdf_read;
|
||||
mod plot_layout_scale;
|
||||
mod plot_model_png_scale;
|
||||
mod plot_styles;
|
||||
|
||||
@@ -12,22 +12,18 @@ fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) {
|
||||
d.add(&Space::Model, Common::default(), k).unwrap();
|
||||
}
|
||||
|
||||
fn pdf_doc(d: &Drawing, opts: Value) -> crate::pdf_read::Doc {
|
||||
fn pdf_text(d: &Drawing, opts: Value) -> String {
|
||||
let mut o = json!({"compress": false});
|
||||
if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) {
|
||||
m.extend(e.clone());
|
||||
}
|
||||
crate::pdf_read::read(&plotter::plot(d, &Space::Model, &o).unwrap())
|
||||
String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// The plot area's clip rectangle (`x y w h`) of the first page.
|
||||
fn clip(doc: &crate::pdf_read::Doc) -> Vec<f64> {
|
||||
crate::pdf_read::clip_rect(doc.content()).map(|r| vec![r[0], r[1], r[2] - r[0], r[3] - r[1]]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The link targets of the first page.
|
||||
fn uris(doc: &crate::pdf_read::Doc) -> Vec<String> {
|
||||
doc.pages[0].links.iter().map(|l| l.uri.clone()).collect()
|
||||
/// The plot area's clip rectangle (`x y w h re W n`) of the first page.
|
||||
fn clip(pdf: &str) -> Vec<f64> {
|
||||
let line = pdf.lines().find(|l| l.ends_with(" re W n")).unwrap_or("");
|
||||
line.split_whitespace().take(4).filter_map(|t| t.parse().ok()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -36,17 +32,17 @@ fn model_space_pdf_uses_the_device_margins() {
|
||||
line(&mut d, (0.0, 0.0), (100.0, 50.0));
|
||||
let mm = 72.0 / 25.4;
|
||||
// No device: the page setup's printable area, as before.
|
||||
let plain = clip(&pdf_doc(&d, json!({})));
|
||||
let plain = clip(&pdf_text(&d, json!({})));
|
||||
assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}");
|
||||
// A device paper with 20 mm margins: the printable area starts 20 mm in.
|
||||
let r = clip(&pdf_doc(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
|
||||
let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
|
||||
assert_eq!(r.len(), 4, "{r:?}");
|
||||
assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}");
|
||||
let (w, h) = (r[2].min(r[3]), r[2].max(r[3]));
|
||||
assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}");
|
||||
// Hostile margins are ignored.
|
||||
for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] {
|
||||
assert_eq!(clip(&pdf_doc(&d, json!({"margins": bad}))), plain, "{bad}");
|
||||
assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +107,13 @@ fn pe_url_hyperlinks_become_link_annotations() {
|
||||
let text = with_pe_urls(&d, &["https://example.com/plan", "javascript:alert(1)", "data:text/html,x"]);
|
||||
assert_eq!(text.matches("PE_URL").count(), 3, "the test drawing carries the links");
|
||||
let read = read_dxf(text.as_bytes()).unwrap();
|
||||
let pdf = uris(&pdf_doc(&read, json!({})));
|
||||
assert_eq!(pdf, ["https://example.com/plan"], "only the web link");
|
||||
let pdf = pdf_text(&read, json!({}));
|
||||
assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "only the web link: {pdf}");
|
||||
assert!(pdf.contains("https://example.com/plan"));
|
||||
assert!(!pdf.contains("javascript") && !pdf.contains("data:text"));
|
||||
// Turned off, and given explicitly for the same object (the explicit one wins).
|
||||
assert!(uris(&pdf_doc(&read, json!({"hyperlinks": false}))).is_empty());
|
||||
assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0);
|
||||
let first = read.model.iter().next().unwrap().handle.hex();
|
||||
let own = uris(&pdf_doc(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]})));
|
||||
assert!(own.contains(&"https://example.org/own".to_string()) && !own.contains(&"https://example.com/plan".to_string()), "{own:?}");
|
||||
let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]}));
|
||||
assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}");
|
||||
}
|
||||
|
||||
+118
-131
@@ -1,6 +1,6 @@
|
||||
//! PDF export with layers, several sheets and hyperlinks (issue #560): drawing layers become
|
||||
//! optional content groups, `layouts` plots several sheets into one file with page labels, and
|
||||
//! linked entities get link annotations. The tests read the written file back.
|
||||
//! linked entities get link annotations. The tests parse the written objects.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Space, Viewport};
|
||||
@@ -9,19 +9,57 @@ use cadcraft_io::pdf::{MAX_PAGES, pdf_sheets};
|
||||
use cadcraft_io::{PdfOptions, plot};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::pdf_read::{self, Doc};
|
||||
|
||||
fn read(bytes: &[u8]) -> Doc {
|
||||
pdf_read::read(bytes)
|
||||
/// The objects of a PDF by number, after checking the cross-reference table points at them.
|
||||
fn objects(bytes: &[u8]) -> Vec<(usize, String)> {
|
||||
assert!(bytes.starts_with(b"%PDF-1."));
|
||||
// Byte offsets stay valid: non-ASCII bytes (the binary comment) become `?`.
|
||||
let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
|
||||
let sx = text.rfind("startxref\n").expect("startxref");
|
||||
let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap();
|
||||
assert!(text[xref..].starts_with("xref\n"));
|
||||
let mut lines = text[xref..].lines().skip(1);
|
||||
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
let entries: Vec<&str> = lines.take(count).collect();
|
||||
assert_eq!(entries.len(), count);
|
||||
let mut out = Vec::new();
|
||||
for (i, e) in entries.iter().enumerate().skip(1) {
|
||||
let off: usize = e[..10].parse().unwrap();
|
||||
let head = format!("{i} 0 obj\n");
|
||||
assert!(bytes[off..].starts_with(head.as_bytes()), "object {i} offset");
|
||||
let body = &text[off + head.len()..];
|
||||
let end = body.find("\nendobj\n").expect("endobj");
|
||||
out.push((i, body[..end].to_string()));
|
||||
}
|
||||
assert!(text.contains(&format!("/Size {count}")));
|
||||
out
|
||||
}
|
||||
|
||||
fn ocg_names(doc: &Doc) -> Vec<String> {
|
||||
doc.ocgs.iter().map(|g| g.name.clone()).collect()
|
||||
fn object(objs: &[(usize, String)], n: usize) -> &str {
|
||||
&objs.iter().find(|(i, _)| *i == n).unwrap().1
|
||||
}
|
||||
|
||||
/// The `BDC` marks of a content: the property resource each one names.
|
||||
fn marks(content: &str) -> Vec<String> {
|
||||
pdf_read::ops(content).into_iter().filter(|o| o.op == "BDC").filter_map(|o| o.names.get(1).cloned()).collect()
|
||||
/// The object a `/Key n 0 R` entry of `dict` refers to.
|
||||
fn reference(dict: &str, key: &str) -> usize {
|
||||
let i = dict.find(&format!("{key} ")).unwrap_or_else(|| panic!("{key} in {dict}")) + key.len() + 1;
|
||||
dict[i..].split_whitespace().next().unwrap().parse().unwrap()
|
||||
}
|
||||
|
||||
/// Uncompressed content streams in page order.
|
||||
fn contents(objs: &[(usize, String)]) -> Vec<String> {
|
||||
pages(objs).iter().map(|p| object(objs, reference(p, "/Contents")).to_string()).collect()
|
||||
}
|
||||
|
||||
fn pages(objs: &[(usize, String)]) -> Vec<String> {
|
||||
objs.iter().filter(|(_, b)| b.starts_with("<< /Type /Page ")).map(|(_, b)| b.clone()).collect()
|
||||
}
|
||||
|
||||
fn catalog(objs: &[(usize, String)]) -> String {
|
||||
objs.iter().find(|(_, b)| b.starts_with("<< /Type /Catalog")).unwrap().1.clone()
|
||||
}
|
||||
|
||||
/// The names of the optional content groups.
|
||||
fn ocg_names(objs: &[(usize, String)]) -> Vec<String> {
|
||||
objs.iter().filter_map(|(_, b)| b.strip_prefix("<< /Type /OCG /Name ").map(|r| r.trim_end_matches(" >>").to_string())).collect()
|
||||
}
|
||||
|
||||
fn add_line(d: &mut Drawing, space: &Space, layer: &str, a: (f64, f64), b: (f64, f64)) -> Handle {
|
||||
@@ -71,66 +109,45 @@ fn raw(extra: serde_json::Value) -> serde_json::Value {
|
||||
#[test]
|
||||
fn drawing_layers_become_pdf_layers() {
|
||||
let d = layered();
|
||||
let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
|
||||
let objs = objects(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
|
||||
let names = ocg_names(&objs);
|
||||
// Plotted layers only, in drawing order of first use; block contents on layer 0 are on
|
||||
// the block reference's layer. None starts hidden.
|
||||
assert_eq!(ocg_names(&doc), ["0", "Walls", "Doors"]);
|
||||
assert!(doc.ocgs.iter().all(|g| !g.off));
|
||||
// One sheet: the viewer opens with its layer panel.
|
||||
assert_eq!(doc.page_mode.as_deref(), Some("UseOC"));
|
||||
// the block reference's layer.
|
||||
assert_eq!(names, ["(0)", "(Walls)", "(Doors)"]);
|
||||
let cat = catalog(&objs);
|
||||
assert!(cat.contains("/Version /1.5") && cat.contains("/OCProperties") && cat.contains("/OFF []"), "{cat}");
|
||||
// Every OCG is listed and switchable.
|
||||
let ocgs: Vec<usize> = objs.iter().filter(|(_, b)| b.starts_with("<< /Type /OCG")).map(|(i, _)| *i).collect();
|
||||
for id in &ocgs {
|
||||
assert!(cat.contains(&format!("{id} 0 R")));
|
||||
}
|
||||
// The page maps its marks to the groups, and the marked sections are balanced.
|
||||
let page = &doc.pages[0];
|
||||
let groups: Vec<&str> = page.properties.iter().map(|(_, g)| g.as_str()).collect();
|
||||
for n in ["0", "Walls", "Doors"] {
|
||||
assert!(groups.contains(&n), "{n} in {groups:?}");
|
||||
// Marks are named after the layer's place in the layer table (0, Defpoints, Walls, …).
|
||||
let page = &pages(&objs)[0];
|
||||
for i in [0, 2, 5] {
|
||||
let id = reference(page, &format!("/L{i}"));
|
||||
assert!(ocgs.contains(&id));
|
||||
}
|
||||
let c = &page.content;
|
||||
assert_eq!(pdf_read::count(c, "BDC"), pdf_read::count(c, "EMC"));
|
||||
for m in marks(c) {
|
||||
assert!(page.properties.iter().any(|(k, _)| *k == m), "mark {m} is a page property");
|
||||
}
|
||||
// The red wall line is on Walls.
|
||||
let walls = page.properties.iter().find(|(_, g)| g == "Walls").map(|(k, _)| k.clone()).unwrap();
|
||||
let ops = pdf_read::ops(c);
|
||||
let at = ops.iter().position(|o| o.op == "BDC" && o.names.get(1) == Some(&walls)).unwrap();
|
||||
let next = ops[at..].iter().find(|o| o.op == "RG").unwrap();
|
||||
assert_eq!(next.nums, [1.0, 0.0, 0.0], "{c}");
|
||||
// Marked content inside the clip's q/Q.
|
||||
let first_bdc = ops.iter().position(|o| o.op == "BDC").unwrap();
|
||||
let last_emc = ops.iter().rposition(|o| o.op == "EMC").unwrap();
|
||||
let clip = ops.iter().position(|o| o.op == "W").unwrap();
|
||||
let last_q = ops.iter().rposition(|o| o.op == "Q").unwrap();
|
||||
assert!(clip < first_bdc && last_emc < last_q, "marked content inside the clip's q/Q");
|
||||
let c = &contents(&objs)[0];
|
||||
assert_eq!(c.matches(" BDC\n").count(), c.matches("EMC\n").count());
|
||||
assert!(c.contains("/OC /L2 BDC\n1 0 0 RG"), "the red wall line is on Walls: {c}");
|
||||
let (q, bdc, emc) = (c.find("re W n").unwrap(), c.find("BDC").unwrap(), c.rfind("EMC").unwrap());
|
||||
assert!(q < bdc && emc < c.rfind("Q\n").unwrap(), "marked content inside the clip's q/Q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_that_are_off_are_included_hidden_on_request() {
|
||||
let d = layered();
|
||||
let doc = read(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap());
|
||||
let names = ocg_names(&doc);
|
||||
assert!(names.contains(&"Notes".to_string()) && !names.contains(&"Hidden".to_string()), "{names:?}");
|
||||
let off: Vec<&str> = doc.ocgs.iter().filter(|g| g.off).map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(off, ["Notes"]);
|
||||
// Without layers, no optional content at all.
|
||||
let flat = read(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap());
|
||||
assert!(flat.ocgs.is_empty() && flat.page_mode.is_none());
|
||||
assert_eq!(pdf_read::count(flat.content(), "BDC"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_that_are_off_are_plotted_only_on_request() {
|
||||
let d = layered();
|
||||
let strokes = |o: serde_json::Value| {
|
||||
let doc = read(&plot(&d, &Space::Model, &raw(o)).unwrap());
|
||||
pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m").count()
|
||||
};
|
||||
// By default Notes (off) and Hidden (frozen) aren't drawn: the four lines less those two,
|
||||
// plus the door.
|
||||
let plain = strokes(json!({}));
|
||||
assert_eq!(plain, 3, "lines on 0 and Walls, and the door leaf");
|
||||
// `offLayers` adds Notes (on a hidden PDF layer), never the frozen layer; it needs PDF layers.
|
||||
assert_eq!(strokes(json!({"offLayers": true})), 4);
|
||||
assert_eq!(strokes(json!({"layers": false, "offLayers": true})), plain);
|
||||
let objs = objects(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap());
|
||||
let names = ocg_names(&objs);
|
||||
assert!(names.contains(&"(Notes)".to_string()) && !names.contains(&"(Hidden)".to_string()), "{names:?}");
|
||||
let notes = objs.iter().find(|(_, b)| b.contains("/Name (Notes)")).unwrap().0;
|
||||
let cat = catalog(&objs);
|
||||
assert!(cat.contains(&format!("/OFF [{notes} 0 R]")), "{cat}");
|
||||
// Off: neither the layer nor its objects; without layers, no optional content at all.
|
||||
let flat = objects(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap());
|
||||
assert!(ocg_names(&flat).is_empty() && !catalog(&flat).contains("/OCProperties"));
|
||||
assert!(!contents(&flat)[0].contains("BDC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -152,50 +169,43 @@ fn viewport_contents_keep_their_layers() {
|
||||
};
|
||||
d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();
|
||||
add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0));
|
||||
let names = ocg_names(&read(&plot(&d, &paper, &raw(json!({}))).unwrap()));
|
||||
let objs = objects(&plot(&d, &paper, &raw(json!({}))).unwrap());
|
||||
let names = ocg_names(&objs);
|
||||
// The viewport's frozen layer is left out; the model layers seen through it are kept.
|
||||
for n in ["Viewports", "0", "Doors", "Title"] {
|
||||
for n in ["(Viewports)", "(0)", "(Doors)", "(Title)"] {
|
||||
assert!(names.contains(&n.to_string()), "{n} in {names:?}");
|
||||
}
|
||||
assert!(!names.contains(&"Walls".to_string()));
|
||||
assert!(!names.contains(&"(Walls)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_sheets_in_one_file_with_page_labels() {
|
||||
let mut d = layered();
|
||||
add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0));
|
||||
let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
|
||||
let bytes = plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap();
|
||||
// "all": model (it has objects), then every layout in tab order.
|
||||
let all = read(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap());
|
||||
assert_eq!(all.labels, ["Model", "Layout1", "Layout2"]);
|
||||
assert_eq!(doc.pages.len(), 2);
|
||||
assert_eq!(doc.labels, ["Model", "Layout1"]);
|
||||
// Each page has its own sheet: model on A4 landscape, the layout on its page setup.
|
||||
for p in &doc.pages {
|
||||
assert!(pdf_read::ops(&p.content).iter().any(|o| o.op == "RG" && o.nums == [1.0, 0.0, 0.0]), "the red wall line: {}", p.content);
|
||||
let all = objects(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap());
|
||||
assert!(catalog(&all).contains("[0 << /P (Model) >> 1 << /P (Layout1) >> 2 << /P (Layout2) >>]"), "{}", catalog(&all));
|
||||
let objs = objects(&bytes);
|
||||
let tree = objs.iter().find(|(_, b)| b.starts_with("<< /Type /Pages")).unwrap().1.clone();
|
||||
assert!(tree.contains("/Count 2"), "{tree}");
|
||||
assert_eq!(pages(&objs).len(), 2);
|
||||
let cat = catalog(&objs);
|
||||
assert!(cat.contains("/PageLabels << /Nums [0 << /P (Model) >> 1 << /P (Layout1) >>] >>"), "{cat}");
|
||||
// One OCG per layer for the whole file, shared by the pages.
|
||||
let names = ocg_names(&objs);
|
||||
assert_eq!(names.iter().filter(|n| *n == "(Walls)").count(), 1);
|
||||
let walls = objs.iter().find(|(_, b)| b.contains("/Name (Walls)")).unwrap().0;
|
||||
for p in pages(&objs) {
|
||||
assert!(p.contains(&format!(" {walls} 0 R")), "{p}");
|
||||
}
|
||||
let size = |p: &pdf_read::Page| (p.media[2] - p.media[0], p.media[3] - p.media[1]);
|
||||
assert!((size(&doc.pages[0]).0 - 841.89).abs() < 0.01 && (size(&doc.pages[0]).1 - 595.276).abs() < 0.01);
|
||||
// Each page has its own sheet: model on A4 landscape, the layout on its page setup.
|
||||
let c = contents(&objs);
|
||||
assert!(c[0].contains("1 0 0 RG") && c[1].contains("1 0 0 RG"));
|
||||
// The library call does the same.
|
||||
let o = PdfOptions { compress: false, ..PdfOptions::default() };
|
||||
let doc2 = read(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
|
||||
assert_eq!(doc2.labels, ["Layout1", "Model"]);
|
||||
// Title: the first page's label unless given.
|
||||
assert_eq!(doc2.title.as_deref(), Some("Layout1"));
|
||||
assert_eq!((doc2.creator.as_deref(), doc2.producer.as_deref()), (Some("CADCraft"), Some("CADCraft")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_sheets_share_one_pdf_layer_per_drawing_layer() {
|
||||
let mut d = layered();
|
||||
add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0));
|
||||
let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
|
||||
assert_eq!(ocg_names(&doc).iter().filter(|n| *n == "Walls").count(), 1);
|
||||
for p in &doc.pages {
|
||||
assert!(p.properties.iter().any(|(_, g)| g == "Walls"));
|
||||
}
|
||||
// Several sheets: no layer panel forced open.
|
||||
assert!(doc.page_mode.is_none());
|
||||
let objs2 = objects(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
|
||||
assert!(catalog(&objs2).contains("0 << /P (Layout1) >> 1 << /P (Model) >>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,51 +230,28 @@ fn hyperlinks_become_link_annotations() {
|
||||
{"handle": h.hex(), "url": "https://example.com/ü"},
|
||||
{"url": "https://example.com/no-handle"},
|
||||
]);
|
||||
let doc = read(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap());
|
||||
let annots = &doc.pages[0].links;
|
||||
assert_eq!(annots.len(), 2);
|
||||
assert_eq!(annots[0].uri, "https://example.com/a(1)");
|
||||
assert_eq!(annots[1].uri, "https://example.com/%C3%BC");
|
||||
let objs = objects(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap());
|
||||
let annots: Vec<&String> = objs.iter().map(|(_, b)| b).filter(|b| b.contains("/Subtype /Link")).collect();
|
||||
assert_eq!(annots.len(), 2, "{annots:?}");
|
||||
assert!(annots[0].contains("/A << /S /URI /URI (https://example.com/a\\(1\\)) >>"), "{}", annots[0]);
|
||||
assert!(annots[1].contains("(https://example.com/%C3%BC)"), "{}", annots[1]);
|
||||
let page = &pages(&objs)[0];
|
||||
assert!(page.contains("/Annots ["));
|
||||
// The rectangle is inside the A4 landscape sheet and wide like the line.
|
||||
let v = annots[0].rect;
|
||||
let r = annots[0].split("/Rect [").nth(1).unwrap().split(']').next().unwrap();
|
||||
let v: Vec<f64> = r.split_whitespace().map(|x| x.parse().unwrap()).collect();
|
||||
assert!(v[0] >= 0.0 && v[2] <= 841.9 && v[1] >= 0.0 && v[3] <= 595.3 && v[2] - v[0] > 100.0, "{v:?}");
|
||||
}
|
||||
|
||||
fn unusual_names() -> Drawing {
|
||||
#[test]
|
||||
fn unusual_layer_names_and_titles_are_escaped() {
|
||||
let mut d = Drawing::new_metric();
|
||||
for name in ["a(b)\\c", "Wände", "日本"] {
|
||||
d.layers.push(Layer { name: name.into(), ..Layer::default() });
|
||||
add_line(&mut d, &Space::Model, name, (0.0, 0.0), (10.0, 10.0));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusual_layer_names_are_kept() {
|
||||
let doc = read(&plot(&unusual_names(), &Space::Model, &raw(json!({}))).unwrap());
|
||||
assert_eq!(ocg_names(&doc), ["a(b)\\c", "Wände", "日本"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusual_titles_are_kept() {
|
||||
let doc = read(&plot(&unusual_names(), &Space::Model, &raw(json!({"title": "Plan ü (1)\\"}))).unwrap());
|
||||
assert_eq!(doc.title.as_deref(), Some("Plan ü (1)\\"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_past_the_cap_print_without_a_pdf_layer() {
|
||||
// One more drawing layer than a file has PDF layers (4096).
|
||||
let mut d = Drawing::new_metric();
|
||||
for i in 0..4097 {
|
||||
let name = format!("L{i}");
|
||||
d.layers.push(Layer { name: name.clone(), ..Layer::default() });
|
||||
add_line(&mut d, &Space::Model, &name, (0.0, f64::from(i)), (10.0, f64::from(i)));
|
||||
}
|
||||
let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
|
||||
assert_eq!(doc.ocgs.len(), 4096);
|
||||
assert!(!ocg_names(&doc).contains(&"L4096".to_string()));
|
||||
// Every line is still drawn; only the last is outside marked content.
|
||||
let c = doc.content();
|
||||
assert_eq!(pdf_read::drawn(c).iter().filter(|o| o.op == "m").count(), 4097);
|
||||
assert_eq!((pdf_read::count(c, "BDC"), pdf_read::count(c, "EMC")), (4096, 4096));
|
||||
let objs = objects(&plot(&d, &Space::Model, &raw(json!({"title": "Plan ü"}))).unwrap());
|
||||
let names = ocg_names(&objs);
|
||||
assert_eq!(names, ["(a\\(b\\)\\\\c)", "<FEFF005700E4006E00640065>", "<FEFF65E5672C>"]);
|
||||
assert!(objs.iter().any(|(_, b)| b.contains("/Title <FEFF0050006C0061006E002000FC>")));
|
||||
}
|
||||
|
||||
@@ -14,10 +14,9 @@ fn plotted_mm(opts: Value) -> f64 {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
let bytes = cadcraft_io::plot(&d, &Space::Paper("Layout1".into()), &o).unwrap();
|
||||
let doc = crate::pdf_read::read(&bytes);
|
||||
let xs: Vec<f64> =
|
||||
crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect();
|
||||
assert_eq!(xs.len(), 2, "{}", doc.content());
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let xs: Vec<f64> = text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split(' ').next()?.parse().ok()).collect();
|
||||
assert_eq!(xs.len(), 2, "{text}");
|
||||
(xs[1] - xs[0]).abs() * 25.4 / 72.0
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ fn layout_with_red_line() -> Drawing {
|
||||
d
|
||||
}
|
||||
|
||||
/// The page content of Layout1's PDF with extra plot options.
|
||||
/// The uncompressed PDF of Layout1 with extra plot options.
|
||||
fn pdf_text(d: &Drawing, extra: serde_json::Value) -> String {
|
||||
let mut opts = json!({"compress": false});
|
||||
if let (Some(o), Some(e)) = (opts.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
let bytes = cadcraft_io::plot(d, &Space::Paper("Layout1".into()), &opts).unwrap();
|
||||
crate::pdf_read::read(&bytes).content().to_string()
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+77
-91
@@ -10,11 +10,6 @@ use cadcraft_geom::{Vec2, Vec3};
|
||||
use cadcraft_io::plot;
|
||||
use serde_json::json;
|
||||
|
||||
#[path = "support/pdf_read.rs"]
|
||||
mod pdf_read;
|
||||
|
||||
use pdf_read::Doc;
|
||||
|
||||
// ---------- a minimal TrueType font: .notdef, "A" (triangle), "B" (square) ----------
|
||||
|
||||
fn be16(v: &mut Vec<u8>, x: u16) {
|
||||
@@ -51,9 +46,8 @@ fn simple_glyph(pts: &[(i16, i16)]) -> Vec<u8> {
|
||||
g
|
||||
}
|
||||
|
||||
/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`. `hmtx` holds
|
||||
/// `metrics` of the three metrics `hhea` declares.
|
||||
fn test_font(fs_type: u16, metrics: usize) -> Vec<u8> {
|
||||
/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`.
|
||||
fn test_font(fs_type: u16) -> Vec<u8> {
|
||||
let glyphs = [Vec::new(), simple_glyph(&[(50, 0), (550, 0), (300, 700)]), simple_glyph(&[(50, 0), (550, 0), (550, 700), (50, 700)])];
|
||||
let mut glyf = Vec::new();
|
||||
let mut loca = Vec::new();
|
||||
@@ -88,7 +82,7 @@ fn test_font(fs_type: u16, metrics: usize) -> Vec<u8> {
|
||||
be16(&mut maxp, v);
|
||||
}
|
||||
let mut hmtx = Vec::new();
|
||||
for _ in 0..metrics {
|
||||
for _ in 0..3 {
|
||||
be16(&mut hmtx, 600);
|
||||
be16(&mut hmtx, 50);
|
||||
}
|
||||
@@ -148,14 +142,12 @@ fn test_font(fs_type: u16, metrics: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Register the test fonts once: an embeddable one, one with a restricted licence, and one
|
||||
/// the PDF writer accepts but can't subset (its `hmtx` is short).
|
||||
/// Register the test fonts once: an embeddable one and one with a restricted licence.
|
||||
fn fonts() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0, 3));
|
||||
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002, 3));
|
||||
cadcraft_fonts::ttf::register("cadcraft-pdf-short-hmtx.ttf", test_font(0, 1));
|
||||
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0));
|
||||
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,72 +178,77 @@ fn drawing() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
style(&mut d, "Real", "cadcraft-pdf-test.ttf");
|
||||
style(&mut d, "Locked", "cadcraft-pdf-restricted.ttf");
|
||||
style(&mut d, "Short", "cadcraft-pdf-short-hmtx.ttf");
|
||||
d
|
||||
}
|
||||
|
||||
/// The uncompressed PDF, read back.
|
||||
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> Doc {
|
||||
/// The uncompressed PDF as text (offsets kept: non-ASCII bytes become `?`).
|
||||
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> String {
|
||||
let mut o = json!({"compress": false});
|
||||
if let (Some(m), Some(e)) = (o.as_object_mut(), extra.as_object()) {
|
||||
m.extend(e.clone());
|
||||
}
|
||||
pdf_read::read(&plot(d, space, &o).unwrap())
|
||||
let bytes = plot(d, space, &o).unwrap();
|
||||
check_xref(&bytes);
|
||||
bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect()
|
||||
}
|
||||
|
||||
/// The glyph matrices (`a b c d e f`: em axes and origin on the page) of the text, in order.
|
||||
fn matrices(doc: &Doc) -> Vec<[f64; 6]> {
|
||||
pdf_read::text_matrices(doc.content())
|
||||
/// The cross-reference table points at every object.
|
||||
fn check_xref(bytes: &[u8]) {
|
||||
let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
|
||||
let sx = text.rfind("startxref\n").unwrap();
|
||||
let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap();
|
||||
let mut lines = text[xref..].lines().skip(1);
|
||||
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
for (i, e) in lines.take(count).enumerate().skip(1) {
|
||||
let off: usize = e[..10].parse().unwrap();
|
||||
assert!(text[off..].starts_with(&format!("{i} 0 obj\n")), "object {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph codes shown, in order (two bytes each).
|
||||
fn codes(doc: &Doc) -> Vec<u32> {
|
||||
pdf_read::shown(&doc.pages[0].content_bytes)
|
||||
.iter()
|
||||
.flat_map(|s| s.chunks(2).map(|c| u32::from(c[0]) << 8 | u32::from(*c.get(1).unwrap_or(&0))).collect::<Vec<_>>())
|
||||
/// The text matrices (`a b c d e f Tm`) of the content, in order.
|
||||
fn matrices(pdf: &str) -> Vec<[f64; 6]> {
|
||||
pdf.lines()
|
||||
.filter(|l| l.ends_with(" Tj") && l.contains(" Tm "))
|
||||
.map(|l| {
|
||||
let v: Vec<f64> = l.split(" Tm ").next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
|
||||
[v[0], v[1], v[2], v[3], v[4], v[5]]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Filled paths drawn (glyph outlines are fills).
|
||||
fn fills(doc: &Doc) -> usize {
|
||||
pdf_read::count(doc.content(), "f")
|
||||
}
|
||||
|
||||
/// Embedded font files.
|
||||
fn font_files(doc: &Doc) -> usize {
|
||||
doc.pages.iter().flat_map(|p| &p.fonts).filter(|f| f.font_file.is_some()).count()
|
||||
/// The embedded font file (FontFile2 stream data).
|
||||
fn font_file(pdf: &str, bytes_of: &[u8]) -> Vec<u8> {
|
||||
let i = pdf.find("/Length1 ").expect("a FontFile2 stream");
|
||||
let head = &pdf[..i];
|
||||
let len_at = head.rfind("/Length ").unwrap() + 8;
|
||||
let len: usize = pdf[len_at..].split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
|
||||
let start = pdf[i..].find("stream\n").unwrap() + i + 7;
|
||||
bytes_of[start..start + len].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truetype_text_is_embedded_as_text() {
|
||||
let mut d = drawing();
|
||||
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", 0.0, 1.0, 0.0);
|
||||
let doc = pdf(&d, &Space::Model, json!({}));
|
||||
let page = &doc.pages[0];
|
||||
let p = pdf(&d, &Space::Model, json!({}));
|
||||
// A Type0 font over a CIDFontType2 subset with a ToUnicode map.
|
||||
assert_eq!(page.fonts.len(), 1);
|
||||
let f = &page.fonts[0];
|
||||
assert_eq!((f.subtype.as_str(), f.encoding.as_deref(), f.descendant.as_deref()), ("Type0", Some("Identity-H"), Some("CIDFontType2")));
|
||||
assert!(f.cid_to_gid.as_deref() == Some("Identity") && f.font_file.is_some() && f.to_unicode.is_some());
|
||||
let base = f.base_font.as_bytes();
|
||||
assert!(base.len() > 7 && base[6] == b'+' && base[..6].iter().all(u8::is_ascii_uppercase), "{}", f.base_font);
|
||||
// Four glyphs written as text: A B B A.
|
||||
let c = codes(&doc);
|
||||
assert_eq!(c.len(), 4, "{c:?}");
|
||||
assert!(c[0] == c[3] && c[1] == c[2] && c[0] != c[1], "{c:?}");
|
||||
let map = pdf_read::to_unicode_map(f.to_unicode.as_deref().unwrap());
|
||||
assert!(map.contains(&(c[0], "A".into())) && map.contains(&(c[1], "B".into())), "ToUnicode maps the glyphs to A and B: {map:?}");
|
||||
for code in [c[0], c[1]] {
|
||||
assert!(f.widths.contains(&(code, 600.0)), "width of {code}: {:?}", f.widths);
|
||||
}
|
||||
// Text objects are balanced, at font size 1.
|
||||
assert_eq!(pdf_read::count(&page.content, "BT"), pdf_read::count(&page.content, "ET"));
|
||||
assert!(pdf_read::ops(&page.content).iter().filter(|o| o.op == "Tf").all(|o| o.nums == [1.0]));
|
||||
assert!(p.contains("/Subtype /Type0") && p.contains("/Encoding /Identity-H") && p.contains("/Subtype /CIDFontType2"), "{p}");
|
||||
assert!(p.contains("/CIDToGIDMap /Identity") && p.contains("/FontFile2 ") && p.contains("/ToUnicode "));
|
||||
let base = p.split("/BaseFont /").nth(1).unwrap().split_whitespace().next().unwrap();
|
||||
assert!(base.len() == 11 && base.as_bytes()[6] == b'+' && base[..6].chars().all(|c| c.is_ascii_uppercase()), "{base}");
|
||||
assert!(p.contains("<0001> <0041>") && p.contains("<0002> <0042>"), "ToUnicode maps glyphs to A and B");
|
||||
assert!(p.contains("/W [1 [600] 2 [600]]"), "widths");
|
||||
// The page uses the font; four glyphs written as text, in a text object.
|
||||
assert!(p.contains("/Font << /F0 "));
|
||||
let tj: Vec<&str> = p.lines().filter(|l| l.ends_with(" Tj")).map(|l| l.rsplit(' ').nth(1).unwrap()).collect();
|
||||
assert_eq!(tj, ["<0001>", "<0002>", "<0002>", "<0001>"]);
|
||||
assert!(p.contains("BT\n/F0 1 Tf\n") && p.contains("ET\n"));
|
||||
assert_eq!(p.matches("BT\n").count(), p.matches("ET\n").count());
|
||||
// No filled outlines left for them.
|
||||
assert_eq!(fills(&doc), 0, "glyph outlines are not drawn: {}", page.content);
|
||||
assert!(!p.contains("h\nf\n"), "glyph outlines are not drawn: {p}");
|
||||
// The glyphs advance along the baseline by 600/1000 em, scaled like the outline would be.
|
||||
let m = matrices(&doc);
|
||||
assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0), "{m:?}");
|
||||
let m = matrices(&p);
|
||||
assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0));
|
||||
let step = m[1][4] - m[0][4];
|
||||
assert!((step - 0.6 * m[0][0]).abs() < 0.01, "advance {step} for em {}", m[0][0]);
|
||||
assert!(m.windows(2).all(|w| (w[0][5] - w[1][5]).abs() < 1e-3), "one baseline");
|
||||
@@ -261,12 +258,21 @@ fn truetype_text_is_embedded_as_text() {
|
||||
fn the_subset_is_a_valid_truetype_font() {
|
||||
let mut d = drawing();
|
||||
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", 0.0, 1.0, 0.0);
|
||||
let doc = pdf(&d, &Space::Model, json!({}));
|
||||
let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file");
|
||||
let bytes = plot(&d, &Space::Model, &json!({"compress": false})).unwrap();
|
||||
let p: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
|
||||
let file = font_file(&p, &bytes);
|
||||
assert!(p.contains(&format!("/Length1 {}", file.len())));
|
||||
let face = cadcraft_fonts::ttf::Face::new(std::sync::Arc::new(file.clone()), 0).expect("the subset parses");
|
||||
assert_eq!(cadcraft_fonts::subset::embedding(&face), cadcraft_fonts::subset::Embedding::Allowed);
|
||||
// Glyph A kept, B (unused) left out: the subset is smaller than the font.
|
||||
assert!(file.len() < test_font(0, 3).len());
|
||||
// Glyph A kept, B (unused) emptied: the subset is smaller than the font.
|
||||
assert!(file.len() < test_font(0).len());
|
||||
// Whole-file checksum adjusted.
|
||||
let sum = file.chunks(4).fold(0u32, |s, c| {
|
||||
let mut w = [0u8; 4];
|
||||
w[..c.len()].copy_from_slice(c);
|
||||
s.wrapping_add(u32::from_be_bytes(w))
|
||||
});
|
||||
assert_eq!(sum, 0xB1B0_AFBA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -277,7 +283,7 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() {
|
||||
text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", 0.0, 2.0, 0.0);
|
||||
text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", 0.0, 1.0, 15f64.to_radians());
|
||||
let m = matrices(&pdf(&d, &Space::Model, json!({})));
|
||||
assert_eq!(m.len(), 3, "{m:?}");
|
||||
assert_eq!(m.len(), 3);
|
||||
// Rotated a quarter turn: the baseline points up, "up" points left.
|
||||
let [a, b, c, dd, _, _] = m[0];
|
||||
assert!(a.abs() < 1e-3 && b > 0.0 && c < 0.0 && dd.abs() < 1e-3, "{:?}", m[0]);
|
||||
@@ -291,29 +297,21 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() {
|
||||
assert!(em_mm > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_the_writer_cannot_subset_plots_as_outlines() {
|
||||
let mut d = drawing();
|
||||
text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
|
||||
let p = pdf(&d, &Space::Model, json!({}));
|
||||
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "outlines: {}", p.content());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_that_forbid_embedding_and_the_geometry_option_draw_outlines() {
|
||||
let mut d = drawing();
|
||||
text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
|
||||
let p = pdf(&d, &Space::Model, json!({}));
|
||||
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "restricted licence: outlines");
|
||||
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "restricted licence: outlines");
|
||||
let mut d = drawing();
|
||||
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
|
||||
let p = pdf(&d, &Space::Model, json!({"text": "geometry"}));
|
||||
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "text as geometry");
|
||||
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "text as geometry");
|
||||
// The stroke font is always geometry.
|
||||
let mut d = drawing();
|
||||
text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
|
||||
let p = pdf(&d, &Space::Model, json!({}));
|
||||
assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0);
|
||||
assert!(!p.contains("BT\n") && p.contains(" S\n") || p.contains("\nS\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -339,26 +337,14 @@ fn text_seen_through_a_viewport_and_on_layers() {
|
||||
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
text(&mut d, &paper, "Real", (20.0, 20.0), "B", 0.0, 1.0, 0.0);
|
||||
let p = pdf(&d, &paper, json!({}));
|
||||
let tj = p.lines().filter(|l| l.ends_with(" Tj")).count();
|
||||
// "AB" inside, the paper-space "B", and the glyph of "BA" left of the edge: real text. The
|
||||
// glyph the edge cuts stays a (clipped) outline.
|
||||
assert_eq!(pdf_read::count(p.content(), "Tj"), 4, "{}", p.content());
|
||||
assert!(fills(&p) > 0, "the cut glyph is an outline");
|
||||
// Text seen through the viewport sits in its layer's marked content.
|
||||
let ops = pdf_read::ops(p.content());
|
||||
let first_bt = ops.iter().position(|o| o.op == "BT").unwrap();
|
||||
let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap();
|
||||
assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_sits_in_its_layers_marked_content() {
|
||||
let mut d = drawing();
|
||||
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
|
||||
let p = pdf(&d, &Space::Model, json!({}));
|
||||
let ops = pdf_read::ops(p.content());
|
||||
let first_bt = ops.iter().position(|o| o.op == "BT").unwrap();
|
||||
let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap();
|
||||
assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0)));
|
||||
assert_eq!(tj, 4, "{p}");
|
||||
assert!(p.contains("h\nf\n"), "the cut glyph is an outline");
|
||||
// Text sits in its layer's marked content.
|
||||
let first_bt = p.find("BT\n").unwrap();
|
||||
assert!(p[..first_bt].contains("/OC /L0 BDC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,518 +0,0 @@
|
||||
//! Reads a plotted PDF back for tests, with craft-pdf's reader: pages, media boxes, decoded
|
||||
//! content, transparency graphics states, link annotations, fonts, page labels and document
|
||||
//! information.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfString};
|
||||
|
||||
/// What a reader sees in a PDF.
|
||||
pub struct Doc {
|
||||
pub pages: Vec<Page>,
|
||||
/// Page labels, one per page (empty when the file has none).
|
||||
pub labels: Vec<String>,
|
||||
pub title: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub producer: Option<String>,
|
||||
/// Optional content groups (PDF layers) in `/OCProperties` order.
|
||||
pub ocgs: Vec<Ocg>,
|
||||
/// The catalog's `/PageMode`.
|
||||
pub page_mode: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Ocg {
|
||||
pub name: String,
|
||||
/// Listed in the default configuration's `/OFF` array (starts hidden).
|
||||
pub off: bool,
|
||||
}
|
||||
|
||||
pub struct Page {
|
||||
/// `[x0 y0 x1 y1]` in points.
|
||||
pub media: [f64; 4],
|
||||
/// The decoded content stream(s), as text (binary string bytes replaced).
|
||||
pub content: String,
|
||||
/// The decoded content stream(s) as bytes.
|
||||
pub content_bytes: Vec<u8>,
|
||||
/// Whether a content stream is compressed (has a filter).
|
||||
pub compressed: bool,
|
||||
/// Stroke and fill alpha (`/CA`, `/ca`) of each graphics state in the page's resources.
|
||||
pub alphas: Vec<(f64, f64)>,
|
||||
pub links: Vec<Link>,
|
||||
pub fonts: Vec<Font>,
|
||||
/// The page's marked-content properties: resource name and the name of the optional
|
||||
/// content group it refers to.
|
||||
pub properties: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct Link {
|
||||
/// `[x0 y0 x1 y1]` in points.
|
||||
pub rect: [f64; 4],
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
pub struct Font {
|
||||
pub subtype: String,
|
||||
pub base_font: String,
|
||||
pub encoding: Option<String>,
|
||||
/// The descendant font's subtype (Type0 fonts).
|
||||
pub descendant: Option<String>,
|
||||
/// The descendant font's `/CIDToGIDMap` name.
|
||||
pub cid_to_gid: Option<String>,
|
||||
/// Glyph widths (`/W` of the descendant font): code and width.
|
||||
pub widths: Vec<(u32, f64)>,
|
||||
/// The decoded `ToUnicode` CMap.
|
||||
pub to_unicode: Option<String>,
|
||||
/// The embedded TrueType file (`FontFile2`).
|
||||
pub font_file: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A PDF text string: UTF-16BE with a byte order mark, else one byte per character.
|
||||
pub fn text_string(b: &[u8]) -> String {
|
||||
match b.strip_prefix(&[0xFE, 0xFF]) {
|
||||
Some(u) => String::from_utf16_lossy(&u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect::<Vec<_>>()),
|
||||
None => b.iter().map(|c| char::from(*c)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn name(d: &Dict<'_>, key: &[u8]) -> Option<String> {
|
||||
d.get::<Name<'_>>(key).map(|n| n.as_str().to_string())
|
||||
}
|
||||
|
||||
fn rect(a: &Array<'_>) -> [f64; 4] {
|
||||
let v: Vec<f64> = a.iter::<f64>().collect();
|
||||
assert_eq!(v.len(), 4, "a rectangle: {v:?}");
|
||||
[v[0], v[1], v[2], v[3]]
|
||||
}
|
||||
|
||||
fn decoded(s: &Stream<'_>) -> Vec<u8> {
|
||||
s.decoded().expect("a stream that decodes").to_vec()
|
||||
}
|
||||
|
||||
fn font(d: &Dict<'_>) -> Font {
|
||||
let descendant = d.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next());
|
||||
let font_file =
|
||||
descendant.as_ref().unwrap_or(d).get::<Dict<'_>>(b"FontDescriptor").and_then(|fd| fd.get::<Stream<'_>>(b"FontFile2")).map(|s| decoded(&s));
|
||||
let mut widths = Vec::new();
|
||||
if let Some(w) = descendant.as_ref().and_then(|c| c.get::<Array<'_>>(b"W")) {
|
||||
let mut it = w.flex_iter();
|
||||
while let Some(first) = it.next::<u32>() {
|
||||
if let Some(list) = it.next::<Array<'_>>() {
|
||||
widths.extend(list.iter::<f64>().enumerate().map(|(i, v)| (first + i as u32, v)));
|
||||
} else {
|
||||
let (last, v) = (it.next::<u32>().expect("a /W range end"), it.next::<f64>().expect("a /W range width"));
|
||||
widths.extend((first..=last).map(|c| (c, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Font {
|
||||
encoding: name(d, b"Encoding"),
|
||||
cid_to_gid: descendant.as_ref().and_then(|c| name(c, b"CIDToGIDMap")),
|
||||
widths,
|
||||
subtype: name(d, b"Subtype").unwrap_or_default(),
|
||||
base_font: name(d, b"BaseFont").unwrap_or_default(),
|
||||
descendant: descendant.as_ref().and_then(|c| name(c, b"Subtype")),
|
||||
to_unicode: d.get::<Stream<'_>>(b"ToUnicode").map(|s| String::from_utf8_lossy(&decoded(&s)).into_owned()),
|
||||
font_file,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a PDF; panics when it doesn't parse.
|
||||
pub fn read(bytes: &[u8]) -> Doc {
|
||||
assert!(bytes.starts_with(b"%PDF-"), "a PDF header");
|
||||
let pdf = craft_pdf::open_slice(bytes, None).expect("the PDF opens");
|
||||
let mut pages = Vec::new();
|
||||
for page in pdf.pages().iter() {
|
||||
let m = page.media_box();
|
||||
let raw = page.raw();
|
||||
let resources = raw.get::<Dict<'_>>(b"Resources");
|
||||
let dicts = |key: &[u8]| -> Vec<Dict<'_>> {
|
||||
let Some(d) = resources.as_ref().and_then(|r| r.get::<Dict<'_>>(key)) else { return Vec::new() };
|
||||
let keys: Vec<Vec<u8>> = d.keys().map(|k| k.deref().to_vec()).collect();
|
||||
keys.iter().filter_map(|k| d.get::<Dict<'_>>(k.as_slice())).collect()
|
||||
};
|
||||
let alphas = dicts(b"ExtGState").iter().map(|g| (g.get::<f64>(b"CA").unwrap_or(1.0), g.get::<f64>(b"ca").unwrap_or(1.0))).collect();
|
||||
let fonts = dicts(b"Font").iter().map(font).collect();
|
||||
let properties = match resources.as_ref().and_then(|r| r.get::<Dict<'_>>(b"Properties")) {
|
||||
Some(d) => d
|
||||
.keys()
|
||||
.filter_map(|k| {
|
||||
let ocg = d.get::<Dict<'_>>(k.deref())?;
|
||||
Some((k.as_str().to_string(), ocg.get::<PdfString<'_>>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default()))
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let links = raw
|
||||
.get::<Array<'_>>(b"Annots")
|
||||
.map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|a| name(a, b"Subtype").as_deref() == Some("Link"))
|
||||
.map(|a| Link {
|
||||
rect: rect(&a.get::<Array<'_>>(b"Rect").expect("a link rectangle")),
|
||||
uri: a
|
||||
.get::<Dict<'_>>(b"A")
|
||||
.and_then(|act| act.get::<PdfString<'_>>(b"URI"))
|
||||
.map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
let bytes = page.page_stream().expect("a decodable page content stream");
|
||||
// The writer flips its y-down surface back, so the plot area is drawn in PDF points.
|
||||
let content = String::from_utf8_lossy(bytes).into_owned();
|
||||
if let Some(ctm) = clip_ctm(&content) {
|
||||
assert!(ctm.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6), "the plot area is drawn under {ctm:?}, not in page space");
|
||||
}
|
||||
pages.push(Page {
|
||||
media: [m.x0, m.y0, m.x1, m.y1],
|
||||
content,
|
||||
content_bytes: bytes.to_vec(),
|
||||
compressed: raw
|
||||
.get::<Stream<'_>>(b"Contents")
|
||||
.map(|c| vec![c])
|
||||
.or_else(|| raw.get::<Array<'_>>(b"Contents").map(|a| a.iter::<Stream<'_>>().collect()))
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|c| c.dict().contains_key(b"Filter")),
|
||||
alphas,
|
||||
links,
|
||||
fonts,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).expect("a catalog");
|
||||
let mut starts: Vec<(usize, String)> = Vec::new();
|
||||
if let Some(nums) = catalog.get::<Dict<'_>>(b"PageLabels").and_then(|l| l.get::<Array<'_>>(b"Nums")) {
|
||||
let mut it = nums.flex_iter();
|
||||
while let Some(i) = it.next::<i64>() {
|
||||
let d = it.next::<Dict<'_>>().expect("a page label dictionary");
|
||||
starts.push((i as usize, d.get::<PdfString<'_>>(b"P").map(|s| text_string(s.as_bytes())).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
let labels = if starts.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
(0..pages.len()).map(|i| starts.iter().rev().find(|(s, _)| *s <= i).map(|(_, p)| p.clone()).unwrap_or_default()).collect()
|
||||
};
|
||||
let mut ocgs = Vec::new();
|
||||
if let Some(oc) = catalog.get::<Dict<'_>>(b"OCProperties") {
|
||||
let off: Vec<_> = oc
|
||||
.get::<Dict<'_>>(b"D")
|
||||
.and_then(|d| d.get::<Array<'_>>(b"OFF"))
|
||||
.map(|a| a.iter::<Dict<'_>>().filter_map(|g| g.obj_id()).collect())
|
||||
.unwrap_or_default();
|
||||
for g in oc.get::<Array<'_>>(b"OCGs").map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>()).unwrap_or_default() {
|
||||
ocgs.push(Ocg {
|
||||
name: g.get::<PdfString<'_>>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default(),
|
||||
off: g.obj_id().is_some_and(|id| off.contains(&id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
let page_mode = name(&catalog, b"PageMode");
|
||||
let md = pdf.metadata();
|
||||
let s = |v: &Option<Vec<u8>>| v.as_deref().map(text_string);
|
||||
Doc { labels, title: s(&md.title), creator: s(&md.creator), producer: s(&md.producer), pages, ocgs, page_mode }
|
||||
}
|
||||
|
||||
impl Doc {
|
||||
/// The first page's content.
|
||||
pub fn content(&self) -> &str {
|
||||
&self.pages.first().expect("a page").content
|
||||
}
|
||||
pub fn media(&self) -> (f64, f64) {
|
||||
let m = self.pages.first().expect("a page").media;
|
||||
(m[2] - m[0], m[3] - m[1])
|
||||
}
|
||||
}
|
||||
|
||||
/// One content operator with its numeric operands.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Op {
|
||||
pub op: String,
|
||||
pub nums: Vec<f64>,
|
||||
/// Name operands, without the slash.
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
/// The operators of content text in order, each with its numeric operands (other operands
|
||||
/// are skipped).
|
||||
pub fn ops(content: &str) -> Vec<Op> {
|
||||
let b: Vec<char> = content.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut nums = Vec::new();
|
||||
let mut names = Vec::new();
|
||||
let mut i = 0;
|
||||
let delim = |c: char| c.is_whitespace() || "()<>[]{}/%".contains(c);
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
if c.is_whitespace() || c == '[' || c == ']' {
|
||||
i += 1;
|
||||
} else if c == '%' {
|
||||
while i < b.len() && b[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '(' {
|
||||
let mut depth = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
'\\' => i += 1,
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
} else if c == '<' && b.get(i + 1) == Some(&'<') {
|
||||
// An inline dictionary (marked-content properties): skip to its end.
|
||||
let mut depth = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == '<' && b.get(i + 1) == Some(&'<') {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
} else if b[i] == '>' && b.get(i + 1) == Some(&'>') {
|
||||
depth -= 1;
|
||||
i += 1;
|
||||
if depth == 0 {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '<' {
|
||||
while i < b.len() && b[i] != '>' {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
} else if c == '/' {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < b.len() && !delim(b[i]) {
|
||||
i += 1;
|
||||
}
|
||||
names.push(b[start..i].iter().collect());
|
||||
} else {
|
||||
let start = i;
|
||||
while i < b.len() && !delim(b[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = b[start..i.max(start + 1)].iter().collect();
|
||||
i = i.max(start + 1);
|
||||
match word.parse::<f64>() {
|
||||
Ok(v) => nums.push(v),
|
||||
Err(_) => out.push(Op { op: word, nums: std::mem::take(&mut nums), names: std::mem::take(&mut names) }),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How many times operator `op` occurs.
|
||||
pub fn count(content: &str, op: &str) -> usize {
|
||||
ops(content).iter().filter(|o| o.op == op).count()
|
||||
}
|
||||
|
||||
const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
|
||||
/// Apply matrix `m` to the point `(x, y)`.
|
||||
fn apply(m: [f64; 6], x: f64, y: f64) -> (f64, f64) {
|
||||
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
|
||||
}
|
||||
|
||||
/// The operators of content text with path coordinates (`m`, `l`, `c`, `v`, `y`, `re`) mapped
|
||||
/// through the current transformation matrix, so they are in page space. A `re` becomes the
|
||||
/// rectangle between its mapped corners, which holds for the axis-aligned matrices plots use.
|
||||
pub fn page_ops(content: &str) -> Vec<Op> {
|
||||
let mut stack = vec![IDENTITY];
|
||||
let mut out = ops(content);
|
||||
for o in &mut out {
|
||||
let ctm = *stack.last().expect("a graphics state");
|
||||
match o.op.as_str() {
|
||||
"q" => stack.push(ctm),
|
||||
"Q" => {
|
||||
stack.pop();
|
||||
assert!(!stack.is_empty(), "unbalanced Q");
|
||||
}
|
||||
"cm" if o.nums.len() == 6 => {
|
||||
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
|
||||
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
|
||||
}
|
||||
"m" | "l" | "c" | "v" | "y" => {
|
||||
for [x, y] in o.nums.as_chunks_mut::<2>().0 {
|
||||
(*x, *y) = apply(ctm, *x, *y);
|
||||
}
|
||||
}
|
||||
"re" if o.nums.len() == 4 => {
|
||||
let (x0, y0) = apply(ctm, o.nums[0], o.nums[1]);
|
||||
let (x1, y1) = apply(ctm, o.nums[0] + o.nums[2], o.nums[1] + o.nums[3]);
|
||||
o.nums = vec![x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The current transformation matrix at the first clip (`W`): what the plot area is drawn under.
|
||||
pub fn clip_ctm(content: &str) -> Option<[f64; 6]> {
|
||||
let mut stack = vec![IDENTITY];
|
||||
for o in ops(content) {
|
||||
let ctm = *stack.last().expect("a graphics state");
|
||||
match o.op.as_str() {
|
||||
"W" => return Some(ctm),
|
||||
"q" => stack.push(ctm),
|
||||
"Q" => {
|
||||
stack.pop();
|
||||
}
|
||||
"cm" if o.nums.len() == 6 => {
|
||||
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
|
||||
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The clip rectangle in page space: the bounds of the path closed by the first `W n`.
|
||||
pub fn clip_rect(content: &str) -> Option<[f64; 4]> {
|
||||
let all = page_ops(content);
|
||||
let end = all.iter().position(|o| o.op == "W")?;
|
||||
let start = all[..end].iter().rposition(|o| !matches!(o.op.as_str(), "m" | "l" | "h" | "re")).map_or(0, |i| i + 1);
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for o in &all[start..end] {
|
||||
let pts: Vec<(f64, f64)> = match o.op.as_str() {
|
||||
"re" => vec![(o.nums[0], o.nums[1]), (o.nums[0] + o.nums[2], o.nums[1] + o.nums[3])],
|
||||
"m" | "l" => vec![(o.nums[0], o.nums[1])],
|
||||
_ => vec![],
|
||||
};
|
||||
for (x, y) in pts {
|
||||
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
|
||||
}
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
|
||||
/// The operators after the clip (the marks drawn inside the plot area), with path coordinates
|
||||
/// in page space.
|
||||
pub fn drawn(content: &str) -> Vec<Op> {
|
||||
let all = page_ops(content);
|
||||
let after = all.iter().position(|o| o.op == "W").map_or(0, |i| i + 1);
|
||||
all.into_iter().skip(after).collect()
|
||||
}
|
||||
|
||||
/// `a × b` for PDF matrices `[a b c d e f]` (apply `a`, then `b`).
|
||||
pub fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
|
||||
[
|
||||
a[0] * b[0] + a[1] * b[2],
|
||||
a[0] * b[1] + a[1] * b[3],
|
||||
a[2] * b[0] + a[3] * b[2],
|
||||
a[2] * b[1] + a[3] * b[3],
|
||||
a[4] * b[0] + a[5] * b[2] + b[4],
|
||||
a[4] * b[1] + a[5] * b[3] + b[5],
|
||||
]
|
||||
}
|
||||
|
||||
/// The glyph-space-to-page matrix of each text-showing operator (`Tj`/`TJ`), in order: the text
|
||||
/// matrix scaled by the font size, under the current transformation matrix.
|
||||
pub fn text_matrices(content: &str) -> Vec<[f64; 6]> {
|
||||
let mut stack = vec![IDENTITY];
|
||||
let (mut tm, mut size) = (IDENTITY, 1.0);
|
||||
let mut out = Vec::new();
|
||||
for o in ops(content) {
|
||||
let ctm = *stack.last().expect("a graphics state");
|
||||
match o.op.as_str() {
|
||||
"q" => stack.push(ctm),
|
||||
"Q" => {
|
||||
stack.pop();
|
||||
}
|
||||
"cm" if o.nums.len() == 6 => {
|
||||
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
|
||||
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
|
||||
}
|
||||
"BT" => tm = IDENTITY,
|
||||
"Tm" if o.nums.len() == 6 => tm = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]],
|
||||
"Tf" => size = o.nums.first().copied().unwrap_or(1.0),
|
||||
"Tj" | "TJ" => out.push(mul(mul([size, 0.0, 0.0, size, 0.0, 0.0], tm), ctm)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The string operands of text-showing operators (`Tj`), as bytes, in order.
|
||||
pub fn shown(content: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < content.len() {
|
||||
if content[i] == b'(' {
|
||||
let mut s = Vec::new();
|
||||
let mut depth = 1;
|
||||
i += 1;
|
||||
while i < content.len() {
|
||||
match content[i] {
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
let c = content.get(i).copied().unwrap_or(b'\\');
|
||||
if c.is_ascii_digit() {
|
||||
let digits: Vec<u8> = content[i..].iter().take(3).take_while(|d| (b'0'..=b'7').contains(d)).copied().collect();
|
||||
s.push(u8::from_str_radix(std::str::from_utf8(&digits).unwrap_or("0"), 8).unwrap_or(0));
|
||||
i += digits.len() - 1;
|
||||
} else {
|
||||
s.push(match c {
|
||||
b'n' => b'\n',
|
||||
b'r' => b'\r',
|
||||
b't' => b'\t',
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
}
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
s.push(b'(');
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
s.push(b')');
|
||||
}
|
||||
c => s.push(c),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let rest = &content[(i + 1).min(content.len())..];
|
||||
let next: Vec<u8> = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take(2).copied().collect();
|
||||
if next == b"Tj" {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `bfchar` entries of a `ToUnicode` CMap: code and text.
|
||||
pub fn to_unicode_map(cmap: &str) -> Vec<(u32, String)> {
|
||||
let mut out = Vec::new();
|
||||
for block in cmap.split("beginbfchar").skip(1) {
|
||||
let body = block.split("endbfchar").next().unwrap_or("");
|
||||
let hex: Vec<&str> = body.split(['<', '>']).map(str::trim).filter(|t| !t.is_empty()).collect();
|
||||
for pair in hex.chunks(2) {
|
||||
let [code, text] = pair else { continue };
|
||||
let units: Vec<u16> = (0..text.len() / 4).filter_map(|i| u16::from_str_radix(&text[i * 4..i * 4 + 4], 16).ok()).collect();
|
||||
if let Ok(c) = u32::from_str_radix(code, 16) {
|
||||
out.push((c, String::from_utf16_lossy(&units)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! fixed-length extension lines (DIMFXL/DIMFXLON), text placement (DIMTAD, DIMTVP, DIMJUST,
|
||||
//! DIMTIH/DIMTOH, DIMGAP, DIMTMOVE) and text formatting (DIMLUNIT, DIMDEC, DIMZIN, DIMRND,
|
||||
//! DIMPOST, DIMTOL/DIMLIM with DIMTZIN and DIMTOLJ, DIMALT with DIMALTU/DIMALTZ/DIMALTRND,
|
||||
//! DIMAZIN, DIMARCSYM) with its background (DIMTFILL).
|
||||
//! DIMAZIN, DIMARCSYM).
|
||||
|
||||
use cadcraft_doc::{DimKind, DimStyle, Dimension};
|
||||
use cadcraft_fonts::{MTextParams, TextFont};
|
||||
@@ -32,12 +32,6 @@ pub struct DimGeometry {
|
||||
pub line_roles: Vec<LineRole>,
|
||||
/// Filled triangles (three points each) in the dimension line colour.
|
||||
pub fills: Vec<Vec<Vec2>>,
|
||||
/// DIMTFILL 1 or 2: the corners of the text's background, the text box widened by DIMGAP
|
||||
/// on every side (empty without a fill or text).
|
||||
pub text_fill: Vec<Vec2>,
|
||||
/// Indices into `lines` of the marks drawn with the text, over `text_fill`: the arc length
|
||||
/// symbol above the text (DIMARCSYM 1) and the DIMINSPECT frame.
|
||||
pub text_marks: Vec<usize>,
|
||||
/// Text strokes (stroke font, fraction bars, underlines).
|
||||
pub text: Vec<Vec<Vec2>>,
|
||||
/// Text glyph outlines (TrueType), grouped per glyph.
|
||||
@@ -81,13 +75,6 @@ impl DimGeometry {
|
||||
self.line_roles.push(LineRole::Dim);
|
||||
}
|
||||
}
|
||||
/// A dimension line that marks the text (see `text_marks`).
|
||||
fn text_mark(&mut self, l: Vec<Vec2>) {
|
||||
if l.len() >= 2 {
|
||||
self.text_marks.push(self.lines.len());
|
||||
self.dim(l);
|
||||
}
|
||||
}
|
||||
fn ext(&mut self, l: Vec<Vec2>) {
|
||||
if l.len() >= 2 {
|
||||
self.lines.push(l);
|
||||
@@ -101,21 +88,18 @@ impl DimGeometry {
|
||||
}
|
||||
let mut lines = Vec::with_capacity(self.lines.len());
|
||||
let mut roles = Vec::with_capacity(self.lines.len());
|
||||
let (mut ext2, mut marks) = (Vec::new(), Vec::new());
|
||||
let mut ext2 = Vec::new();
|
||||
for (i, (l, rl)) in self.lines.iter().zip(&self.line_roles).enumerate() {
|
||||
let pieces = if role.is_none_or(|x| x == *rl) { cut_polyline(l, c, r) } else { vec![l.clone()] };
|
||||
for p in pieces {
|
||||
if self.ext2_lines.contains(&i) {
|
||||
ext2.push(lines.len());
|
||||
}
|
||||
if self.text_marks.contains(&i) {
|
||||
marks.push(lines.len());
|
||||
}
|
||||
lines.push(p);
|
||||
roles.push(*rl);
|
||||
}
|
||||
}
|
||||
(self.lines, self.line_roles, self.ext2_lines, self.text_marks) = (lines, roles, ext2, marks);
|
||||
(self.lines, self.line_roles, self.ext2_lines) = (lines, roles, ext2);
|
||||
}
|
||||
/// Extension line 1 or 2 (`second`).
|
||||
fn ext_n(&mut self, l: Option<Vec<Vec2>>, second: bool) {
|
||||
@@ -1003,7 +987,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
|
||||
base + x * (t * half) + y * ((1.0 - t * t).max(0.0).sqrt() * th * 0.35)
|
||||
})
|
||||
.collect();
|
||||
g.text_mark(sym);
|
||||
g.dim(sym);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1033,31 +1017,24 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
|
||||
let mut left = -w / 2.0;
|
||||
if !ins.label.is_empty() {
|
||||
left += wl;
|
||||
g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
|
||||
g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
|
||||
left += gap2;
|
||||
}
|
||||
if !ins.rate.is_empty() {
|
||||
left += wv;
|
||||
g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
|
||||
g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
|
||||
}
|
||||
g.text_mark(vec![at(-hw, hh), at(hw, hh)]);
|
||||
g.text_mark(vec![at(-hw, -hh), at(hw, -hh)]);
|
||||
g.dim(vec![at(-hw, hh), at(hw, hh)]);
|
||||
g.dim(vec![at(-hw, -hh), at(hw, -hh)]);
|
||||
for side in [-1.0, 1.0] {
|
||||
let ends: Vec<Vec2> = if ins.shape == 0 {
|
||||
(0..=12).map(|i| f64::from(i) * std::f64::consts::PI / 12.0).map(|a| at(side * (hw + hh * a.sin()), hh * a.cos())).collect()
|
||||
} else {
|
||||
vec![at(side * hw, hh), at(side * (hw + hh), 0.0), at(side * hw, -hh)]
|
||||
};
|
||||
g.text_mark(ends);
|
||||
g.dim(ends);
|
||||
}
|
||||
}
|
||||
// DIMTFILL: a background behind the text, the text box plus DIMGAP on every side.
|
||||
if st.text_fill != 0 && !g.mtext.is_empty() {
|
||||
let (w, h) = measure(&g.mtext);
|
||||
let (x, y) = (Vec2::from_angle(text_angle), Vec2::from_angle(text_angle).perp());
|
||||
let (hw, hh) = (w / 2.0 + gap, h / 2.0 + gap);
|
||||
g.text_fill = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].into_iter().map(|(px, py)| text_mid + x * px + y * py).collect();
|
||||
}
|
||||
g.value = decode_value(&cadcraft_fonts::plain_mtext(&g.mtext));
|
||||
if !g.mtext.is_empty() {
|
||||
let l = cadcraft_fonts::layout_mtext_with(&g.mtext, ¶ms(text_mid, text_angle));
|
||||
|
||||
+6
-138
@@ -1125,39 +1125,10 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
|
||||
&& ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH
|
||||
{
|
||||
let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::IDENTITY) };
|
||||
// DIMTFILL: the block's MTEXT carries no background mask here, so a background
|
||||
// around each of the block's texts is drawn before the block's first text or first
|
||||
// line of the text's marks, except for a text that a SOLID of the block covers.
|
||||
let style = ctx.d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
let g = if style.text_fill != 0 { dimension_in(ctx.d, dm) } else { DimGeometry::default() };
|
||||
let gap = style.text_gap.abs() * style.effective_scale(ctx.d.header.f64("DIMSCALE", 1.0));
|
||||
let boxes: Vec<Vec<Vec2>> = if g.text_fill.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let solids: Vec<&Entity> =
|
||||
blk.entities.iter().map(|be| &**be).filter(|be| matches!(be.kind, EntityKind::Solid(_))).take(MAX_FILL_SOLIDS).collect();
|
||||
// A text's own box (no gap): a SOLID holding all its corners is its background.
|
||||
let covered =
|
||||
|be: &Entity| block_text_box(ctx.d, be, 0.0).is_some_and(|t| solids.iter().any(|s| t.iter().all(|p| solid_covers(s, *p))));
|
||||
blk.entities
|
||||
.iter()
|
||||
.filter(|be| matches!(be.kind, EntityKind::MText(_) | EntityKind::Text(_)))
|
||||
.take(MAX_FILL_TEXTS)
|
||||
.filter(|be| !covered(be))
|
||||
.filter_map(|be| block_text_box(ctx.d, be, gap))
|
||||
.collect()
|
||||
};
|
||||
let mut fill_pending = !boxes.is_empty();
|
||||
for be in blk.entities.iter() {
|
||||
if !b.expand() {
|
||||
break;
|
||||
}
|
||||
if fill_pending && starts_text_marks(be, &g) {
|
||||
for corners in &boxes {
|
||||
dim_text_fill(b, ctx, e, &style, corners);
|
||||
}
|
||||
fill_pending = false;
|
||||
}
|
||||
entity(b, &sub, be);
|
||||
}
|
||||
} else {
|
||||
@@ -1484,126 +1455,23 @@ fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimensio
|
||||
};
|
||||
let (dim_lw, ext_lw) = (weight(style.dim_lineweight), weight(style.ext_lineweight));
|
||||
let (dim_lt, ext1_lt, ext2_lt) = (ltype(&style.dim_linetype), ltype(&style.ext_linetype1), ltype(&style.ext_linetype2));
|
||||
let line_style = |i: usize| match g.line_roles.get(i).unwrap_or(&LineRole::Dim) {
|
||||
LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()),
|
||||
LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()),
|
||||
LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()),
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) come last, over the text background.
|
||||
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) {
|
||||
let (rgb, w, t) = line_style(i);
|
||||
for (i, (l, role)) in g.lines.iter().zip(g.line_roles.iter().chain(std::iter::repeat(&LineRole::Dim))).enumerate() {
|
||||
let (rgb, w, t) = match role {
|
||||
LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()),
|
||||
LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()),
|
||||
LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()),
|
||||
};
|
||||
stroke_with(b, ctx, rgb, w, t, ltscale, l);
|
||||
}
|
||||
for t in &g.fills {
|
||||
b.tris(ctx, dim_rgb, t);
|
||||
}
|
||||
dim_text_fill(b, ctx, e, &style, &g.text_fill);
|
||||
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) {
|
||||
let (rgb, w, t) = line_style(i);
|
||||
stroke_with(b, ctx, rgb, w, t, ltscale, l);
|
||||
}
|
||||
if b.opts.text {
|
||||
let sh = cadcraft_fonts::Shaped { strokes: g.text.clone(), glyphs: g.text_glyphs.clone(), width: 0.0, text: g.text_runs.clone() };
|
||||
b.shaped(ctx, txt_rgb, lw, &sh);
|
||||
}
|
||||
}
|
||||
|
||||
/// DIMTFILL: a dimension's text background (`corners` from [`DimGeometry::text_fill`]) hides
|
||||
/// what is drawn before it, in the drawing's background color (1) or DIMTFILLCLR (2).
|
||||
fn dim_text_fill(b: &mut Builder, ctx: &Ctx, e: &Entity, style: &cadcraft_doc::DimStyle, corners: &[Vec2]) {
|
||||
let [p0, p1, p2, p3] = corners else { return };
|
||||
if !b.opts.fill || style.text_fill == 0 {
|
||||
return;
|
||||
}
|
||||
// DIMTFILLCLR ByBlock = the dimension's own color, as DIMCLRT.
|
||||
let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
|
||||
let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7));
|
||||
let own = match e.common.color {
|
||||
Color::ByLayer => layer_color,
|
||||
Color::ByBlock => ctx.block_color,
|
||||
c => c,
|
||||
};
|
||||
let n = b.list.prims.len();
|
||||
b.tris(ctx, ink(style.text_fill_color, layer_color, own), &[*p0, *p1, *p2, *p0, *p2, *p3]);
|
||||
if style.text_fill == 1
|
||||
&& let Some(p) = b.list.prims.get_mut(n)
|
||||
{
|
||||
p.kind = Kind::Mask;
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimension block's text, or a line that is a segment of one of the text's marks in `g`
|
||||
/// (as the `*D` block writer splits them): where the background goes.
|
||||
fn starts_text_marks(be: &Entity, g: &DimGeometry) -> bool {
|
||||
match &be.kind {
|
||||
EntityKind::MText(_) | EntityKind::Text(_) => true,
|
||||
EntityKind::Line(l) => {
|
||||
let (a, b) = (l.a.xy(), l.b.xy());
|
||||
g.text_marks
|
||||
.iter()
|
||||
.filter_map(|i| g.lines.get(*i))
|
||||
.any(|m| m.windows(2).any(|w| matches!(w, [p, q] if p.dist(a) < 1e-6 && q.dist(b) < 1e-6)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The background of a dimension block's MTEXT or TEXT: its box widened by `gap` on every side,
|
||||
/// turned with the text (corners in order); `None` for other entities.
|
||||
fn block_text_box(d: &Drawing, be: &Entity, gap: f64) -> Option<Vec<Vec2>> {
|
||||
let t = match &be.kind {
|
||||
EntityKind::MText(t) => t,
|
||||
EntityKind::Text(t) => return text_box(d, t, gap),
|
||||
_ => return None,
|
||||
};
|
||||
let (bounds, at, rot) = (layout_mtext_entity(d, &cadcraft_doc::MText { rotation: 0.0, ..t.clone() }).bounds, t.insert.xy(), t.rotation);
|
||||
if !(bounds.width().is_finite() && bounds.height().is_finite()) || bounds.width() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (lo, hi) = (bounds.min - Vec2::new(gap, gap), bounds.max + Vec2::new(gap, gap));
|
||||
let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp());
|
||||
let turn = |p: Vec2| at + x * (p.x - at.x) + y * (p.y - at.y);
|
||||
Some([lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)].into_iter().map(turn).collect())
|
||||
}
|
||||
|
||||
/// The background of a TEXT as placed: the box of its strokes and glyph outlines along the text's
|
||||
/// direction (the baseline from the insertion to the alignment point for Aligned and Fit),
|
||||
/// widened by `gap` on every side.
|
||||
fn text_box(d: &Drawing, t: &cadcraft_doc::Text, gap: f64) -> Option<Vec<Vec2>> {
|
||||
let (sh, _) = place_text_entity(d, t, &t.value);
|
||||
let rot = match (t.halign, t.align_pt) {
|
||||
(cadcraft_doc::HAlign::Aligned | cadcraft_doc::HAlign::Fit, Some(p)) if p.xy().dist(t.insert.xy()) > 1e-12 => t.insert.xy().angle_to(p.xy()),
|
||||
_ => t.rotation,
|
||||
};
|
||||
let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp());
|
||||
let pts: Vec<Vec2> = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).copied().collect();
|
||||
let first = pts.first()?;
|
||||
let start = (first.dot(x), first.dot(y), first.dot(x), first.dot(y));
|
||||
let (s0, u0, s1, u1) = pts.iter().fold(start, |(s0, u0, s1, u1), p| (s0.min(p.dot(x)), u0.min(p.dot(y)), s1.max(p.dot(x)), u1.max(p.dot(y))));
|
||||
if !(s1 - s0 > 0.0 && (s0 + s1 + u0 + u1).is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let at = |s: f64, u: f64| x * s + y * u;
|
||||
Some(vec![at(s0 - gap, u0 - gap), at(s1 + gap, u0 - gap), at(s1 + gap, u1 + gap), at(s0 - gap, u1 + gap)])
|
||||
}
|
||||
|
||||
/// Most texts of one dimension block that get a DIMTFILL background, and most of its SOLIDs
|
||||
/// checked for covering one.
|
||||
const MAX_FILL_TEXTS: usize = 64;
|
||||
const MAX_FILL_SOLIDS: usize = 256;
|
||||
|
||||
/// Whether `be` is a SOLID that covers `p` (a text background the block holds already, as the
|
||||
/// `*D` block of DIMTFILL 2 does).
|
||||
fn solid_covers(be: &Entity, p: Vec2) -> bool {
|
||||
let EntityKind::Solid(s) = &be.kind else { return false };
|
||||
// SOLID corners go 1, 2, 4, 3 around the outline.
|
||||
let [c1, c2, c3, c4] = s.corners.map(|c| c.xy());
|
||||
let ring = [c1, c2, c4, c3];
|
||||
let next = [c2, c4, c3, c1];
|
||||
let sides: Vec<f64> = ring.iter().zip(next.iter()).map(|(a, b)| (*b - *a).cross(p - *a)).filter(|c| c.abs() > 1e-12).collect();
|
||||
!sides.is_empty() && (sides.iter().all(|c| *c > 0.0) || sides.iter().all(|c| *c < 0.0))
|
||||
}
|
||||
|
||||
mod table;
|
||||
pub use table::{MAX_LAYOUT_CELLS, TableFill, TableGeom, TableLine, TableText, table_covered, table_geometry, table_style_of};
|
||||
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
//! DIMTFILL and DIMTFILLCLR: a dimension's text background hides what is drawn under the text,
|
||||
//! in the drawing's background color (1) or in DIMTFILLCLR (2).
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, Circle, Common, DimKind, DimStyle, Dimension, Drawing, Entity, EntityKind, Handle, Line, MText, Space};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use cadcraft_render::raster::{RasterOptions, View, render};
|
||||
use cadcraft_render::{DisplayList, Kind, Options, build, dimension_geometry};
|
||||
|
||||
/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5, in style "Fill".
|
||||
fn dimension() -> Dimension {
|
||||
Dimension {
|
||||
kind: DimKind::Linear { rotation: 0.0 },
|
||||
defpt: Vec3::new(0.0, 5.0, 0.0),
|
||||
text_mid: Vec3::ZERO,
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(10.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Fill".into(),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: None,
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// DIMGAP 0.5 leaves room above the text: a line at y = 5.4 passes under the background but
|
||||
/// clear of the text.
|
||||
fn style(fill: u8, color: Color) -> DimStyle {
|
||||
DimStyle { name: "Fill".into(), text_gap: 0.5, text_fill: fill, text_fill_color: color, ..DimStyle::default() }
|
||||
}
|
||||
|
||||
/// A line at y = 5.4 drawn first, then the dimension.
|
||||
fn drawing(fill: u8, color: Color) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(style(fill, color));
|
||||
let line = EntityKind::Line(Line { a: Vec3::new(-2.0, 5.4, 0.0), b: Vec3::new(12.0, 5.4, 0.0) });
|
||||
d.add(&Space::Model, Common::default(), line).unwrap();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dimension())).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Dark pixels on the row of the line at y = 5.4, near the text (x 4.6 to 5.4) and far from it
|
||||
/// (x -1.5 to -0.5), on white paper.
|
||||
fn dark_on_line(list: &DisplayList) -> (usize, usize) {
|
||||
let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 };
|
||||
let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() };
|
||||
let pm = render(list, &view, &o).unwrap();
|
||||
let (mut near, mut far) = (0, 0);
|
||||
// World y = 5.4 is screen row 100 - 0.4 * 20 = 92; world x = 5 + (px - 200) / 20.
|
||||
for y in 91..=93u32 {
|
||||
for x in 0..400u32 {
|
||||
let wx = 5.0 + (f64::from(x) + 0.5 - 200.0) / 20.0;
|
||||
if pm.pixel(x, y).unwrap().red() > 128 {
|
||||
continue;
|
||||
}
|
||||
if (4.6..5.4).contains(&wx) {
|
||||
near += 1;
|
||||
} else if (-1.5..-0.5).contains(&wx) {
|
||||
far += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(near, far)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fill_gives_the_text_a_background_box() {
|
||||
let d = dimension();
|
||||
assert!(dimension_geometry(&d, &style(0, Color::ByBlock), 1.0).text_fill.is_empty(), "DIMTFILL 0: no background");
|
||||
let g = dimension_geometry(&d, &style(1, Color::ByBlock), 1.0);
|
||||
let [a, b, c, e] = g.text_fill.as_slice() else { panic!("four corners: {:?}", g.text_fill) };
|
||||
// The box is centered on the text and reaches DIMGAP (0.5) past the text height (0.18).
|
||||
let mid = (*a + *b + *c + *e) * 0.25;
|
||||
assert!(mid.dist(g.text_pos) < 1e-9, "{mid:?} vs {:?}", g.text_pos);
|
||||
assert!(((c.y - a.y) - (0.18 + 1.0)).abs() < 0.05, "height {}", c.y - a.y);
|
||||
assert!(b.x - a.x > 1.0, "width {}", b.x - a.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fill_hides_what_is_drawn_under_the_text() {
|
||||
let opts = Options::default();
|
||||
let (near, far) = dark_on_line(&build(&drawing(0, Color::ByBlock), &Space::Model, &opts));
|
||||
assert!(near > 0 && far > 0, "DIMTFILL 0: the line shows under the text ({near}, {far})");
|
||||
let list = build(&drawing(1, Color::ByBlock), &Space::Model, &opts);
|
||||
assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 1);
|
||||
let (near, far) = dark_on_line(&list);
|
||||
assert_eq!(near, 0, "DIMTFILL 1: the background hides the line under the text");
|
||||
assert!(far > 0, "the line shows away from the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_frame_is_drawn_over_the_background() {
|
||||
// Polylines drawn after the background, for the dimension with and without an inspection
|
||||
// frame (the text strokes come after it either way).
|
||||
let after_mask = |dm: Dimension| {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(style(1, Color::ByBlock));
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
|
||||
let list = build(&d, &Space::Model, &Options::default());
|
||||
let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).unwrap();
|
||||
list.prims[mask..].iter().filter(|p| p.kind == Kind::Polyline).count()
|
||||
};
|
||||
let mut dm = dimension();
|
||||
// No label or rate: the same text, and a frame of top, bottom and two ends.
|
||||
dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() });
|
||||
let frame = dimension_geometry(&dm, &style(1, Color::ByBlock), 1.0).text_marks.len();
|
||||
assert_eq!(frame, 4);
|
||||
let (plain, inspected) = (after_mask(dimension()), after_mask(dm));
|
||||
assert_eq!(inspected, plain + frame, "the frame lines come after the background");
|
||||
// An arc length dimension's symbol over the text (DIMARCSYM 1) is a mark of the text too.
|
||||
let arc = Dimension {
|
||||
kind: DimKind::ArcLength,
|
||||
defpt: Vec3::new(0.0, 6.0, 0.0),
|
||||
p13: Vec3::new(5.0, 0.0, 0.0),
|
||||
p14: Vec3::new(-5.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
..dimension()
|
||||
};
|
||||
let st = DimStyle { arc_symbol: 1, ..style(1, Color::ByBlock) };
|
||||
let g = dimension_geometry(&arc, &st, 1.0);
|
||||
let [sym] = g.text_marks.as_slice() else { panic!("one mark: {:?}", g.text_marks) };
|
||||
assert!(g.lines[*sym].len() == 13, "the 13-point arc over the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dimension_read_with_its_block_keeps_the_block_and_gets_the_background() {
|
||||
// The block (as read from a file) holds a circle the dimension's own geometry does not have,
|
||||
// then the text, placed away from where CADCraft would put it.
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(style(1, Color::ByBlock));
|
||||
let mut blk = Block::new("*D1");
|
||||
let circle = EntityKind::Circle(Circle { center: Vec3::new(5.0, 5.0, 0.0), radius: 3.0 });
|
||||
blk.entities.push(Entity::new(Handle(1), circle));
|
||||
let text = MText {
|
||||
insert: Vec3::new(20.0, 20.0, 0.0),
|
||||
height: 0.18,
|
||||
width: 0.0,
|
||||
attach: 5,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: "10.0000".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
};
|
||||
blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text)));
|
||||
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
|
||||
let list = build(&d, &Space::Model, &Options::default());
|
||||
let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background");
|
||||
let circle = list.prims.iter().position(|p| list.points(p).iter().any(|q| q.dist(Vec2::new(8.0, 5.0)) < 0.05)).expect("the block's circle");
|
||||
assert!(circle < mask, "the circle before the background, the text after it");
|
||||
// The background is around the block's text.
|
||||
let pts = list.points(&list.prims[mask]);
|
||||
let mid = pts.iter().fold(Vec2::ZERO, |s, p| s + *p) * (1.0 / pts.len() as f64);
|
||||
assert!(mid.dist(Vec2::new(20.0, 20.0)) < 0.05, "{mid:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_text_of_a_block_gets_a_background() {
|
||||
// A measurement and a tolerance as two MTEXTs of the block.
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(style(1, Color::ByBlock));
|
||||
let mut blk = Block::new("*D1");
|
||||
for (i, (y, s)) in [(20.0, "10.0000"), (22.0, "+0.1")].into_iter().enumerate() {
|
||||
let text = MText {
|
||||
insert: Vec3::new(20.0, y, 0.0),
|
||||
height: 0.18,
|
||||
width: 0.0,
|
||||
attach: 5,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: s.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
};
|
||||
blk.entities.push(Entity::new(Handle(i as u64 + 1), EntityKind::MText(text)));
|
||||
}
|
||||
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
|
||||
let list = build(&d, &Space::Model, &Options::default());
|
||||
assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_that_holds_its_background_gets_no_second_one() {
|
||||
// A block with a SOLID, then its text at (20, 20), away from where CADCraft would put it: the
|
||||
// number of masks drawn.
|
||||
let masks = |solid: [(f64, f64); 4]| {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(style(1, Color::ByBlock));
|
||||
let mut blk = Block::new("*D1");
|
||||
let solid = cadcraft_doc::Solid { corners: solid.map(|(x, y)| Vec3::new(x, y, 0.0)) };
|
||||
blk.entities.push(Entity::new(Handle(1), EntityKind::Solid(solid)));
|
||||
let text = MText {
|
||||
insert: Vec3::new(20.0, 20.0, 0.0),
|
||||
height: 0.18,
|
||||
width: 0.0,
|
||||
attach: 5,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: "10.0000".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
};
|
||||
blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text)));
|
||||
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
|
||||
let list = build(&d, &Space::Model, &Options::default());
|
||||
list.prims.iter().filter(|p| p.kind == Kind::Mask).count()
|
||||
};
|
||||
assert_eq!(masks([(19.0, 19.5), (21.0, 19.5), (19.0, 20.5), (21.0, 20.5)]), 0, "the SOLID covers the text");
|
||||
// A small SOLID (an arrowhead) over the middle of the text only: the background is drawn.
|
||||
assert_eq!(masks([(19.9, 19.95), (20.1, 20.0), (19.9, 20.05), (19.9, 20.05)]), 1, "the SOLID covers part of the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_with_turned_text_gets_a_turned_background() {
|
||||
// A block whose text is a TEXT turned 90 degrees, at (20, 20): the background is taller than
|
||||
// wide and holds every point of the text.
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.dim_styles.push(style(1, Color::ByBlock));
|
||||
let mut blk = Block::new("*D1");
|
||||
let text = cadcraft_doc::Text {
|
||||
insert: Vec3::new(20.0, 20.0, 0.0),
|
||||
align_pt: None,
|
||||
height: 0.5,
|
||||
value: "10.00".into(),
|
||||
rotation: std::f64::consts::FRAC_PI_2,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
};
|
||||
blk.entities.push(Entity::new(Handle(1), EntityKind::Text(text)));
|
||||
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
|
||||
let list = build(&d, &Space::Model, &Options::default());
|
||||
let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background");
|
||||
let quad = list.points(&list.prims[mask]);
|
||||
let (lo, hi) = quad.iter().fold((quad[0], quad[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
|
||||
assert!(hi.y - lo.y > 1.5 * (hi.x - lo.x), "turned: {lo:?} {hi:?}");
|
||||
let text: Vec<Vec2> = list.prims[mask + 1..].iter().flat_map(|p| list.points(p).to_vec()).collect();
|
||||
assert!(!text.is_empty() && text.iter().all(|p| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y), "the text inside {lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fill_2_paints_dimtfillclr() {
|
||||
let list = build(&drawing(2, Color::Index(1)), &Space::Model, &Options::default());
|
||||
assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 0);
|
||||
let red: Vec<_> = list.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == Rgb(255, 0, 0)).collect();
|
||||
assert_eq!(red.len(), 1, "one red background");
|
||||
// Over the line, next to the text: the background is drawn after the line.
|
||||
let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 };
|
||||
let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() };
|
||||
let pm = render(&list, &view, &o).unwrap();
|
||||
let c = pm.pixel(194, 92).unwrap();
|
||||
assert_eq!((c.red(), c.green(), c.blue()), (255, 0, 0));
|
||||
}
|
||||
@@ -2,7 +2,6 @@ mod aci7_display;
|
||||
mod annotative_viewport;
|
||||
mod dim_angular_centre;
|
||||
mod dim_post_tolerance;
|
||||
mod dim_text_fill;
|
||||
mod dim_text_format;
|
||||
mod hatch_fill;
|
||||
mod linetype_text;
|
||||
|
||||
@@ -85,9 +85,6 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf
|
||||
if ui.button("▶").on_hover_text(crate::tl!("Next Change")).clicked() {
|
||||
action = Some("compare.next");
|
||||
}
|
||||
if ui.button(crate::tl!("Export")).on_hover_text(crate::tl!("Export Comparison")).clicked() {
|
||||
action = Some("compareexport");
|
||||
}
|
||||
if ui.button("×").on_hover_text(crate::tl!("Close Comparison")).clicked() {
|
||||
action = Some("compareclose");
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
|
||||
}
|
||||
match serde_json::from_value::<crate::UiState>(cur) {
|
||||
Ok(u) => {
|
||||
if let Err(error) = crate::docking::validate(&u.docking) {
|
||||
if let Err(error) = u.docking.validate() {
|
||||
return err(error);
|
||||
}
|
||||
app.ui = u;
|
||||
|
||||
+224
-125
@@ -1,10 +1,7 @@
|
||||
//! Application panel identities and persistence around the shared docking renderer.
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{
|
||||
DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock,
|
||||
Zone,
|
||||
};
|
||||
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -43,13 +40,25 @@ impl Panel {
|
||||
Self::ExternalRefs => "External References",
|
||||
}
|
||||
}
|
||||
fn default_side(self) -> Zone {
|
||||
match self {
|
||||
Self::Canvas => Zone::Right,
|
||||
Self::ToolSets => Zone::Left,
|
||||
Self::QuickCalc => Zone::Right,
|
||||
Self::Layers => Zone::Right,
|
||||
Self::Properties => Zone::Right,
|
||||
Self::ExternalRefs => Zone::Right,
|
||||
}
|
||||
}
|
||||
/// Palettes outside the default arrangement, opened on request (QUICKCALC).
|
||||
pub const ON_DEMAND: &[Self] = &[Self::QuickCalc];
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().chain(Self::ON_DEMAND).copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The drawing view never moves, floats, closes or takes tabs; palettes dock beside it.
|
||||
pub fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
@@ -60,137 +69,230 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
/// The saved panel arrangement: craft-ui's workspace over CADCraft's panels.
|
||||
pub type Workspace = craft_ui::docking::Workspace<Panel>;
|
||||
|
||||
/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right.
|
||||
pub fn default_workspace() -> Workspace {
|
||||
Workspace::new(Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(220.0),
|
||||
tabs(Panel::ToolSets),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(300.0),
|
||||
tabs(Panel::Canvas),
|
||||
Node::Stack {
|
||||
entries: vec![
|
||||
StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) },
|
||||
StackEntry { panel: Panel::Properties, open: true, height: None },
|
||||
],
|
||||
},
|
||||
),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
})
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
}
|
||||
|
||||
/// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float,
|
||||
/// group or collapse it, or restore a palette over it.
|
||||
pub fn validate(workspace: &Workspace) -> Result<(), String> {
|
||||
workspace.validate().map_err(|error| error.to_string())?;
|
||||
if !workspace.layout.contains(&Panel::Canvas) {
|
||||
return Err("The panel layout must contain its document view".into());
|
||||
}
|
||||
if workspace.layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = workspace.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => {
|
||||
return Err("The document view cannot be in a dock".into());
|
||||
}
|
||||
_ => {}
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(220.0),
|
||||
tabs(Panel::ToolSets),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(300.0),
|
||||
tabs(Panel::Canvas),
|
||||
Node::Stack {
|
||||
entries: vec![
|
||||
craft_ui::docking::StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) },
|
||||
craft_ui::docking::StackEntry { panel: Panel::Properties, open: true, height: None },
|
||||
],
|
||||
},
|
||||
),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
}
|
||||
}
|
||||
let over_canvas = |location: &Location<Panel>| {
|
||||
location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center))
|
||||
};
|
||||
if workspace.hidden.iter().any(|(_, location)| over_canvas(location)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place
|
||||
/// for), keeping the drawing view's rules.
|
||||
pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> {
|
||||
let mut next = workspace.clone();
|
||||
next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?;
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
impl Workspace {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.layout.validate().map_err(|error| error.to_string())?;
|
||||
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() + Panel::ON_DEMAND.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
// Saved settings cannot turn the protected document view into a floating or closable tab.
|
||||
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (panel, location) in &self.hidden {
|
||||
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
let mut probe = self.layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != craft_ui::docking::DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show or hide a panel for the Tool Sets and palette switches. Showing a panel whose recorded
|
||||
/// anchor is shown restores it there without changing which tab is selected or which accordion
|
||||
/// entries are open. Otherwise it goes through [`reveal_panel`], which regroups it with hidden
|
||||
/// panels it was beside, and selects it.
|
||||
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if workspace.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
if !visible {
|
||||
return workspace.set_visible(panel, false, None, permissions).map_err(|error| error.to_string());
|
||||
|
||||
/// Explicit opens select the requested tab and expand a collapsed stack entry.
|
||||
pub fn reveal_panel(&mut self, panel: Panel) -> Result<(), String> {
|
||||
let mut next = self.clone();
|
||||
next.set_visible(panel, true)?;
|
||||
next.apply(Action::Activate { panel })?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
let mut next = workspace.clone();
|
||||
let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
// A direct restore against a hidden anchor would open a window of its own.
|
||||
let direct = location.filter(|location| location.anchor.is_none_or(|anchor| next.layout.contains(&anchor)));
|
||||
if direct.is_some_and(|location| next.layout.restore(panel, &location).is_ok()) {
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
// Recreate the last surviving member first. Restoring a member also restores any
|
||||
// hidden anchor it needs, so either close order retains the original tab order.
|
||||
// Remove this member before recursion to bound malformed/cyclic saved anchors.
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
return Ok(());
|
||||
if let Some(anchor) = location.as_ref().and_then(|saved| saved.anchor)
|
||||
&& !next.layout.contains(&anchor)
|
||||
&& next.hidden.iter().any(|(id, _)| *id == anchor)
|
||||
{
|
||||
next.set_visible(anchor, true)?;
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
reveal_panel(workspace, panel)
|
||||
}
|
||||
|
||||
/// `ui.dock` params: `{action}` or `{operation, …}` in craft-ui's shared grammar.
|
||||
fn apply_command(workspace: &mut Workspace, params: &Value) -> Result<(), String> {
|
||||
validate(workspace)?;
|
||||
let operation = if let Some(action) = params.get("action") {
|
||||
UiDock::Action { action: serde_json::from_value(action.clone()).map_err(|error| error.to_string())? }
|
||||
} else {
|
||||
UiDock::Operation(serde_json::from_value(params.clone()).map_err(|error| error.to_string())?)
|
||||
};
|
||||
let mut next = workspace.clone();
|
||||
match operation {
|
||||
UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel }) if !next.layout.contains(&panel) => {
|
||||
reveal_panel(&mut next, panel)?;
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
operation => {
|
||||
// Every ui.dock operation carries its own rect, so the float space is never consulted.
|
||||
let space = FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)));
|
||||
DockCommand::UiDock(operation).apply(&mut next, false, &space, permissions).map_err(|error| error.to_string())?;
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.reveal_panel(panel),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
}
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> {
|
||||
set_visible(workspace, Panel::ToolSets, app.ui.show_toolsets)?;
|
||||
workspace.set_visible(Panel::ToolSets, app.ui.show_toolsets)?;
|
||||
if !app.ui.show_palettes {
|
||||
set_visible(workspace, Panel::Layers, false)?;
|
||||
set_visible(workspace, Panel::Properties, false)?;
|
||||
workspace.set_visible(Panel::Layers, false)?;
|
||||
workspace.set_visible(Panel::Properties, false)?;
|
||||
} else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) {
|
||||
// Reopen the last surviving accordion entry before restoring its earlier sibling.
|
||||
set_visible(workspace, Panel::Properties, true)?;
|
||||
set_visible(workspace, Panel::Layers, true)?;
|
||||
workspace.set_visible(Panel::Properties, true)?;
|
||||
workspace.set_visible(Panel::Layers, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -203,10 +305,10 @@ fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) {
|
||||
pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<Value, String> {
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
if reset {
|
||||
workspace = default_workspace();
|
||||
workspace = Workspace::default();
|
||||
} else {
|
||||
sync(&mut workspace, app)?;
|
||||
apply_command(&mut workspace, params)?;
|
||||
workspace.command(params)?;
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.ui.docking = workspace;
|
||||
@@ -216,9 +318,9 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<V
|
||||
pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) {
|
||||
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
|
||||
app.set_status(format!("Panel layout could not be restored: {error}"));
|
||||
workspace = default_workspace();
|
||||
workspace = Workspace::default();
|
||||
let _ = sync(&mut workspace, app);
|
||||
}
|
||||
// Bodies can dispatch commands (including the drawing's typed command line).
|
||||
@@ -234,9 +336,6 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
style.border = egui::Stroke::new(1.0, t.border);
|
||||
style.accent = t.accent;
|
||||
style.font = crate::theme::body();
|
||||
// Panels arrange in a split tree around the drawing; floating title bars only move windows.
|
||||
style.new_dock_zones = false;
|
||||
style.title_bar_docks = false;
|
||||
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
@@ -271,7 +370,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
let rendered = workspace;
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
let _ = sync(&mut workspace, app);
|
||||
let stale = workspace != rendered;
|
||||
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
|
||||
if stale {
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("cadcraft-docking"));
|
||||
}
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
Close All Other Drawings إغلاق كل الرسومات الأخرى
|
||||
Close Array Editing إغلاق تحرير المصفوفة
|
||||
Close Comparison إغلاق المقارنة
|
||||
Export Comparison تصدير المقارنة
|
||||
Closed مغلق
|
||||
Coincident تطابق
|
||||
Collapse Palettes طي اللوحات
|
||||
@@ -204,7 +203,6 @@
|
||||
Current selection التحديد الحالي
|
||||
Current: {color} الحالي: {color}
|
||||
Cut قص
|
||||
Cut with Base Point قص من نقطة أساس
|
||||
Cyan سماوي
|
||||
Cylinder أسطوانة
|
||||
DWG DWG
|
||||
@@ -616,7 +614,6 @@
|
||||
No supported 3D mouse found لم يُعثر على فأرة ثلاثية الأبعاد مدعومة
|
||||
Node عقدة
|
||||
None لا شيء
|
||||
Normal عادي
|
||||
Not Found غير موجود
|
||||
Not available غير متاح
|
||||
Not available yet غير متاح بعد
|
||||
@@ -788,7 +785,6 @@
|
||||
Rename Text Style إعادة تسمية نمط النص
|
||||
Rename... إعادة تسمية...
|
||||
Repeat {command} تكرار {command}
|
||||
Replace Block... استبدال الكتلة...
|
||||
Requires a point (x,y, @dx,dy or @d<a). يتطلب نقطة (x,y أو @dx,dy أو @d<a).
|
||||
Reset Palettes إعادة تعيين اللوحات
|
||||
Reset Panel Layout إعادة تعيين تخطيط اللوحات
|
||||
|
||||
|
@@ -141,7 +141,6 @@
|
||||
Close All Other Drawings Zavřít všechny ostatní výkresy
|
||||
Closed Uzavřeno
|
||||
Close Comparison Zavřít porovnání
|
||||
Export Comparison Exportovat porovnání
|
||||
Coincident Totožnost
|
||||
Collapse Tool Sets Sbalit sady nástrojů
|
||||
Collinear Kolinearita
|
||||
@@ -187,7 +186,6 @@
|
||||
Current selection Aktuální výběr
|
||||
Current: {color} Aktuální: {color}
|
||||
Cut Vyjmout
|
||||
Cut with Base Point Vyjmout se základním bodem
|
||||
Cyan Azurová
|
||||
Cylinder Válec
|
||||
DWG DWG
|
||||
@@ -560,7 +558,6 @@
|
||||
No selection Žádný výběr
|
||||
Node Bod
|
||||
None Žádné
|
||||
Normal Normální
|
||||
Not available Není k dispozici
|
||||
Not available yet Zatím není k dispozici
|
||||
Numbered list Číslovaný seznam
|
||||
@@ -733,7 +730,6 @@
|
||||
Rename Text Style Přejmenovat styl textu
|
||||
Rename... Přejmenovat...
|
||||
Repeat {command} Opakovat {command}
|
||||
Replace Block... Nahradit blok...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Je vyžadován bod (x,y, @dx,dy nebo @d<a).
|
||||
Reset Palettes Obnovit palety
|
||||
Restore Obnovit
|
||||
|
||||
|
@@ -145,7 +145,6 @@
|
||||
Current selection Aktuelle Auswahl
|
||||
Current: {color} Aktuell: {color}
|
||||
Cut Ausschneiden
|
||||
Cut with Base Point Mit Basispunkt ausschneiden
|
||||
Cyan Cyan
|
||||
Cylinder Zylinder
|
||||
DWG DWG
|
||||
@@ -477,7 +476,6 @@
|
||||
No selection Keine Auswahl
|
||||
Node Knoten
|
||||
None Keine
|
||||
Normal Normal
|
||||
Not available Nicht verfügbar
|
||||
Not available yet Noch nicht verfügbar
|
||||
OK OK
|
||||
@@ -624,7 +622,6 @@
|
||||
Rename Text Style Textstil umbenennen
|
||||
Rename... Umbenennen...
|
||||
Repeat {command} {command} wiederholen
|
||||
Replace Block... Block ersetzen...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Erfordert einen Punkt (x,y, @dx,dy oder @d<a).
|
||||
Reset Palettes Paletten zurücksetzen
|
||||
Restore Wiederherstellen
|
||||
@@ -970,7 +967,6 @@
|
||||
Viewport Properties Ansichtsfenster-Eigenschaften
|
||||
Viewport {id} Ansichtsfenster {id}
|
||||
Viewports Ansichtsfenster
|
||||
Volume Volumen
|
||||
W W
|
||||
WCS WKS
|
||||
White Weiß
|
||||
|
||||
|
@@ -165,7 +165,6 @@
|
||||
Close Array Editing Κλείσιμο επεξεργασίας διάταξης
|
||||
Close Block Editor Κλείσιμο επεξεργαστή μπλοκ
|
||||
Close Comparison Κλείσιμο σύγκρισης
|
||||
Export Comparison Εξαγωγή σύγκρισης
|
||||
Closed Κλειστό
|
||||
Coincident Σύμπτωση (Coincident)
|
||||
Collapse Palettes Σύμπτυξη παλετών
|
||||
@@ -228,7 +227,6 @@
|
||||
Curved Καμπύλο
|
||||
Custom Προσαρμοσμένο
|
||||
Cut Αποκοπή
|
||||
Cut with Base Point Αποκοπή με σημείο βάσης
|
||||
Cyan Κυανό
|
||||
Cylinder Κύλινδρος
|
||||
Dark Σκούρο
|
||||
@@ -854,7 +852,6 @@
|
||||
Rename Text Style Μετονομασία στυλ κειμένου
|
||||
Rename... Μετονομασία...
|
||||
Repeat {command} Επανάληψη εντολής {command}
|
||||
Replace Block... Αντικατάσταση μπλοκ...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Απαιτείται σημείο (x,y, @dx,dy ή @d<a).
|
||||
Reset keeps the interface language. Η επαναφορά διατηρεί τη γλώσσα του περιβάλλοντος.
|
||||
Reset Palettes Επαναφορά παλετών
|
||||
|
||||
|
@@ -140,7 +140,6 @@
|
||||
Close All Other Drawings Cerrar todos los demás dibujos
|
||||
Close Array Editing Cerrar la edición de matriz
|
||||
Close Comparison Cerrar comparación
|
||||
Export Comparison Exportar comparación
|
||||
Closed Cerrada
|
||||
Coincident Coincidente
|
||||
Collapse Palettes Contraer paletas
|
||||
@@ -193,7 +192,6 @@
|
||||
Current selection Selección actual
|
||||
Current: {color} Actual: {color}
|
||||
Cut Cortar
|
||||
Cut with Base Point Cortar con punto base
|
||||
Cyan Cian
|
||||
Cylinder Cilindro
|
||||
DWG DWG
|
||||
@@ -591,7 +589,6 @@
|
||||
No selection Sin selección
|
||||
Node Nodo
|
||||
None Ninguno
|
||||
Normal Normal
|
||||
Not Found No encontrada
|
||||
Not available No disponible
|
||||
Not available yet Aún no disponible
|
||||
@@ -831,7 +828,6 @@
|
||||
Rename Text Style Renombrar estilo de texto
|
||||
Rename... Renombrar...
|
||||
Repeat {command} Repetir {command}
|
||||
Replace Block... Reemplazar bloque...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Se requiere un punto (x,y, @dx,dy o @d<a).
|
||||
Reset Palettes Restablecer paletas
|
||||
Restore Restaurar
|
||||
|
||||
|
@@ -144,7 +144,6 @@
|
||||
Current selection Sélection actuelle
|
||||
Current: {color} Actuel : {color}
|
||||
Cut Couper
|
||||
Cut with Base Point Couper avec point de base
|
||||
Cyan Cyan
|
||||
Cylinder Cylindre
|
||||
DWG DWG
|
||||
@@ -472,7 +471,6 @@
|
||||
No selection Aucune sélection
|
||||
Node Nœud
|
||||
None Aucun
|
||||
Normal Normal
|
||||
Not available Non disponible
|
||||
Not available yet Pas encore disponible
|
||||
OK OK
|
||||
@@ -618,7 +616,6 @@
|
||||
Rename Text Style Renommer le style de texte
|
||||
Rename... Renommer...
|
||||
Repeat {command} Répéter {command}
|
||||
Replace Block... Remplacer le bloc...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Nécessite un point (x,y, @dx,dy ou @d<a).
|
||||
Reset Palettes Réinitialiser les palettes
|
||||
Restore Restaurer
|
||||
@@ -964,7 +961,6 @@
|
||||
Viewport Properties Propriétés de la fenêtre
|
||||
Viewport {id} Fenêtre {id}
|
||||
Viewports Fenêtres
|
||||
Volume Volume
|
||||
W O
|
||||
WCS SCG
|
||||
White Blanc
|
||||
|
||||
|
@@ -178,7 +178,6 @@
|
||||
Close Array Editing Kiosztás szerkesztésének lezárása
|
||||
Close Block Editor Blokkszerkesztő bezárása
|
||||
Close Comparison Összehasonlítás bezárása
|
||||
Export Comparison Összehasonlítás exportálása
|
||||
Closed Zárt
|
||||
Coincident Egybeeső
|
||||
Collapse Palettes Paletták összecsukása
|
||||
@@ -240,7 +239,6 @@
|
||||
Curved Ívelt
|
||||
Custom Egyéni
|
||||
Cut Kivágás
|
||||
Cut with Base Point Kivágás bázisponttal
|
||||
Cyan Cián
|
||||
Cylinder Henger
|
||||
DWG DWG
|
||||
@@ -863,7 +861,6 @@
|
||||
Rename Text Style Szövegstílus átnevezése
|
||||
Rename... Átnevezés...
|
||||
Repeat {command} Ismétlés: {command}
|
||||
Replace Block... Blokk cseréje...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Pontot kell megadni (x,y, @dx,dy vagy @d<a).
|
||||
Reset Palettes Paletták visszaállítása
|
||||
Reset Panel Layout Panelelrendezés visszaállítása
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 16 and column 72.
|
@@ -147,7 +147,6 @@ menu Modify Edita
|
||||
Close All Other Drawings Chiudi tutti gli altri disegni
|
||||
Closed Chiusa
|
||||
Close Comparison Chiudi confronto
|
||||
Export Comparison Esporta confronto
|
||||
Coincident Coincidente
|
||||
Collapse Palettes Comprimi tavolozze
|
||||
Collapse Tool Sets Comprimi set di strumenti
|
||||
@@ -195,7 +194,6 @@ menu Modify Edita
|
||||
Current selection Selezione corrente
|
||||
Current: {color} Corrente: {color}
|
||||
Cut Taglia
|
||||
Cut with Base Point Taglia con punto base
|
||||
Cyan Ciano
|
||||
Cylinder Cilindro
|
||||
DWG DWG
|
||||
@@ -744,7 +742,6 @@ menu Modify Edita
|
||||
Rename Text Style Rinomina stile di testo
|
||||
Rename... Rinomina...
|
||||
Repeat {command} Ripeti {command}
|
||||
Replace Block... Sostituisci blocco...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Richiede un punto (x,y, @dx,dy o @d<a).
|
||||
Reset Palettes Reimposta tavolozze
|
||||
Restore Ripristina
|
||||
|
||||
|
@@ -141,7 +141,6 @@
|
||||
Close All Other Drawings 다른 도면 모두 닫기
|
||||
Closed 닫힘
|
||||
Close Comparison 비교 닫기
|
||||
Export Comparison 비교 내보내기
|
||||
Coincident 일치
|
||||
Collapse Palettes 팔레트 접기
|
||||
Collapse Tool Sets 도구 세트 접기
|
||||
@@ -188,7 +187,6 @@
|
||||
Current selection 현재 선택 항목
|
||||
Current: {color} 현재: {color}
|
||||
Cut 잘라내기
|
||||
Cut with Base Point 기준점을 지정하여 잘라내기
|
||||
Cyan 청록색
|
||||
Cylinder 원기둥
|
||||
DWG DWG
|
||||
@@ -562,7 +560,6 @@
|
||||
No selection 선택 항목 없음
|
||||
Node 노드
|
||||
None 없음
|
||||
Normal 보통
|
||||
Not available 사용할 수 없음
|
||||
Not available yet 아직 사용할 수 없음
|
||||
Numbered list 번호 매기기 목록
|
||||
@@ -735,7 +732,6 @@
|
||||
Rename Text Style 문자 스타일 이름 바꾸기
|
||||
Rename... 이름 바꾸기...
|
||||
Repeat {command} {command} 반복
|
||||
Replace Block... 블록 바꾸기...
|
||||
Requires a point (x,y, @dx,dy or @d<a). 점이 필요합니다(x,y, @dx,dy 또는 @d<a).
|
||||
Reset Palettes 팔레트 초기화
|
||||
Restore 복원
|
||||
|
||||
|
@@ -139,7 +139,6 @@
|
||||
Close All Other Drawings Fechar todos os outros desenhos
|
||||
Close Array Editing Fechar edição de matriz
|
||||
Close Comparison Fechar comparação
|
||||
Export Comparison Exportar comparação
|
||||
Closed Fechada
|
||||
Coincident Coincidente
|
||||
Collapse Palettes Recolher paletas
|
||||
@@ -192,7 +191,6 @@
|
||||
Current selection Seleção atual
|
||||
Current: {color} Atual: {color}
|
||||
Cut Recortar
|
||||
Cut with Base Point Recortar com ponto base
|
||||
Cyan Ciano
|
||||
Cylinder Cilindro
|
||||
DWG DWG
|
||||
@@ -589,7 +587,6 @@
|
||||
No selection Nenhuma seleção
|
||||
Node Nó
|
||||
None Nenhum
|
||||
Normal Normal
|
||||
Not Found Não encontrado
|
||||
Not available Não disponível
|
||||
Not available yet Ainda não disponível
|
||||
@@ -823,7 +820,6 @@
|
||||
Rename Text Style Renomear estilo de texto
|
||||
Rename... Renomear...
|
||||
Repeat {command} Repetir {command}
|
||||
Replace Block... Substituir bloco...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Requer um ponto (x,y, @dx,dy ou @d<a).
|
||||
Reset Palettes Redefinir paletas
|
||||
Restore Restaurar
|
||||
|
||||
|
@@ -144,7 +144,6 @@
|
||||
Current selection Текущее выделение
|
||||
Current: {color} Текущий: {color}
|
||||
Cut Вырезать
|
||||
Cut with Base Point Вырезать с базовой точкой
|
||||
Cyan Голубой
|
||||
Cylinder Цилиндр
|
||||
DWG DWG
|
||||
@@ -472,7 +471,6 @@
|
||||
No selection Ничего не выделено
|
||||
Node Узел
|
||||
None Нет
|
||||
Normal Обычный
|
||||
Not available Недоступно
|
||||
Not available yet Пока недоступно
|
||||
OK ОК
|
||||
@@ -618,7 +616,6 @@
|
||||
Rename Text Style Переименовать текстовый стиль
|
||||
Rename... Переименовать...
|
||||
Repeat {command} Повторить {command}
|
||||
Replace Block... Заменить блок...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Требуется точка (x,y, @dx,dy или @d<a).
|
||||
Reset Palettes Сбросить палитры
|
||||
Restore Восстановить
|
||||
@@ -964,7 +961,6 @@
|
||||
Viewport Properties Свойства видового экрана
|
||||
Viewport {id} Видовой экран {id}
|
||||
Viewports Видовые экраны
|
||||
Volume Объём
|
||||
W З
|
||||
WCS МСК
|
||||
White Белый
|
||||
|
||||
|
@@ -143,7 +143,6 @@
|
||||
Close All Other Drawings Stäng alla andra ritningar
|
||||
Close Array Editing Avsluta redigering av array
|
||||
Close Comparison Stäng jämförelse
|
||||
Export Comparison Exportera jämförelse
|
||||
Closed Sluten
|
||||
Coincident Sammanfallande
|
||||
Collapse Palettes Fäll ihop paletter
|
||||
@@ -196,7 +195,6 @@
|
||||
Current selection Aktuell markering
|
||||
Current: {color} Aktuell: {color}
|
||||
Cut Klipp ut
|
||||
Cut with Base Point Klipp ut med baspunkt
|
||||
Cyan Cyan
|
||||
Cylinder Cylinder
|
||||
DWG DWG
|
||||
@@ -826,7 +824,6 @@
|
||||
Rename Text Style Byt namn på textstil
|
||||
Rename... Byt namn...
|
||||
Repeat {command} Upprepa {command}
|
||||
Replace Block... Ersätt block...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Kräver en punkt (x,y, @dx,dy eller @d<a).
|
||||
Reset Palettes Återställ paletter
|
||||
Restore Återställ
|
||||
|
||||
|
@@ -179,7 +179,6 @@
|
||||
Close Array Editing Dizi Düzenlemeyi Kapat
|
||||
Close Block Editor Blok Düzenleyiciyi Kapat
|
||||
Close Comparison Karşılaştırmayı Kapat
|
||||
Export Comparison Karşılaştırmayı Dışa Aktar
|
||||
Closed Kapalı
|
||||
Coincident Çakışık
|
||||
Collapse Palettes Paletleri Daralt
|
||||
@@ -241,7 +240,6 @@
|
||||
Curved Eğri
|
||||
Custom Özel
|
||||
Cut Kes
|
||||
Cut with Base Point Taban Noktasıyla Kes
|
||||
Cyan Camgöbeği
|
||||
Cylinder Silindir
|
||||
DWG DWG
|
||||
@@ -864,7 +862,6 @@
|
||||
Rename Text Style Metin Stilini Yeniden Adlandır
|
||||
Rename... Yeniden Adlandır...
|
||||
Repeat {command} Yinele: {command}
|
||||
Replace Block... Bloğu Değiştir...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Bir nokta gerekir (x,y, @dx,dy veya @d<a).
|
||||
Reset Palettes Paletleri Sıfırla
|
||||
Reset Panel Layout Panel Düzenini Sıfırla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 13.
|
@@ -144,7 +144,6 @@
|
||||
Close All Other Drawings Закрити всі інші креслення
|
||||
Closed Замкнений
|
||||
Close Comparison Закрити порівняння
|
||||
Export Comparison Експортувати порівняння
|
||||
Coincident Збіг
|
||||
Collapse Palettes Згорнути палітри
|
||||
Collapse Tool Sets Згорнути набори інструментів
|
||||
@@ -192,7 +191,6 @@
|
||||
Current selection Поточний вибір
|
||||
Current: {color} Поточний: {color}
|
||||
Cut Вирізати
|
||||
Cut with Base Point Вирізати з базовою точкою
|
||||
Cyan Блакитний
|
||||
Cylinder Циліндр
|
||||
DWG DWG
|
||||
@@ -750,7 +748,6 @@
|
||||
Rename Text Style Перейменувати текстовий стиль
|
||||
Rename... Перейменувати...
|
||||
Repeat {command} Повторити {command}
|
||||
Replace Block... Замінити блок...
|
||||
Requires a point (x,y, @dx,dy or @d<a). Потрібна точка (x,y, @dx,dy або @d<a).
|
||||
Reset Palettes Скинути палітри
|
||||
Restore Відновити
|
||||
|
||||
|
@@ -145,7 +145,6 @@
|
||||
Current selection 当前选择
|
||||
Current: {color} 当前: {color}
|
||||
Cut 剪切
|
||||
Cut with Base Point 带基点剪切
|
||||
Cyan 青色
|
||||
Cylinder 圆柱体
|
||||
DWG DWG
|
||||
@@ -618,7 +617,6 @@
|
||||
Rename Text Style 重命名文字样式
|
||||
Rename... 重命名...
|
||||
Repeat {command} 重复 {command}
|
||||
Replace Block... 替换块...
|
||||
Requires a point (x,y, @dx,dy or @d<a). 需要一个点 (x,y, @dx,dy 或 @d<a)。
|
||||
Reset Palettes 重置选项板
|
||||
Restore 恢复
|
||||
@@ -1163,7 +1161,6 @@
|
||||
Over-constrained 过度约束
|
||||
Previous Change 上一个更改
|
||||
Close Comparison 关闭比较
|
||||
Export Comparison 导出比较
|
||||
Plotter Manager 打印机管理
|
||||
Clear Command History 清除命令历史记录
|
||||
Degrees of freedom: {n} 自由度: {n}
|
||||
|
||||
|
@@ -156,7 +156,7 @@ impl SaveFormat {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
docking: docking::default_workspace(),
|
||||
docking: docking::Workspace::default(),
|
||||
|
||||
interface_language: i18n::Preference::default(),
|
||||
show_toolsets: true,
|
||||
|
||||
@@ -52,19 +52,19 @@ fn real_panel_drag_float_close_reopen_and_full_workspace_reload() {
|
||||
assert_eq!(location.anchor, Some(Panel::ToolSets));
|
||||
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
|
||||
harness.run_steps(3);
|
||||
harness.get_by_label("Float Panel").click();
|
||||
harness.get_by_label("Float panel").click();
|
||||
harness.run_steps(5);
|
||||
let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect;
|
||||
let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect;
|
||||
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
|
||||
harness.run_steps(3);
|
||||
harness.get_by_label("Close Panel").click();
|
||||
harness.get_by_label("Close panel").click();
|
||||
harness.run_steps(4);
|
||||
assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers));
|
||||
harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap();
|
||||
harness.run_steps(4);
|
||||
assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect, rect);
|
||||
assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect);
|
||||
let saved = serde_json::to_string(&harness.state().ui).unwrap();
|
||||
let expected = serde_json::to_value(&harness.state().ui.docking).unwrap();
|
||||
let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0);
|
||||
@@ -88,13 +88,6 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() {
|
||||
json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}),
|
||||
json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}),
|
||||
json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}),
|
||||
json!({"operation":"float", "panel":"layers", "rect":[1e39,0,300,200]}),
|
||||
json!({"operation":"resizeStack", "panel":"layers", "height":1e39}),
|
||||
json!({"operation":"resizeSplit", "path":[true,true,true,true,true,true,true,true], "size":{"Ratio":0.5}}),
|
||||
json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}),
|
||||
json!({"operation":"spin", "panel":"layers"}),
|
||||
json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}),
|
||||
json!(null),
|
||||
] {
|
||||
assert!(harness.state_mut().run("ui.dock", params).is_err());
|
||||
assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before);
|
||||
@@ -178,47 +171,28 @@ fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing()
|
||||
|
||||
#[test]
|
||||
fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() {
|
||||
use cadcraft_ui_egui::docking::{default_workspace, permissions, reveal_panel};
|
||||
use craft_ui::docking::{Action, Placement};
|
||||
for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] {
|
||||
let mut workspace = default_workspace();
|
||||
let mut workspace = cadcraft_ui_egui::docking::Workspace::default();
|
||||
let rect = [50.0, 60.0, 320.0, 400.0];
|
||||
workspace.apply(Action::Float { panel: Panel::Layers, rect }, permissions).unwrap();
|
||||
workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap();
|
||||
workspace
|
||||
.apply(
|
||||
Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } },
|
||||
permissions,
|
||||
)
|
||||
.apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } })
|
||||
.unwrap();
|
||||
let expected: Vec<Panel> = workspace.layout.floating[0].panels().copied().collect();
|
||||
let expected = workspace.layout.floating[0].panels.clone();
|
||||
for panel in order {
|
||||
workspace.set_visible(panel, false, None, permissions).unwrap();
|
||||
workspace.set_visible(panel, false).unwrap();
|
||||
}
|
||||
let saved = serde_json::to_string(&workspace).unwrap();
|
||||
workspace = serde_json::from_str(&saved).unwrap();
|
||||
reveal_panel(&mut workspace, Panel::Properties).unwrap();
|
||||
reveal_panel(&mut workspace, Panel::Layers).unwrap();
|
||||
workspace.reveal_panel(Panel::Properties).unwrap();
|
||||
workspace.reveal_panel(Panel::Layers).unwrap();
|
||||
assert_eq!(workspace.layout.floating.len(), 1);
|
||||
assert_eq!(workspace.layout.floating[0].panels().copied().collect::<Vec<_>>(), expected);
|
||||
assert_eq!(workspace.layout.floating[0].panels, expected);
|
||||
assert_eq!(workspace.layout.floating[0].rect, rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palettes_off_and_on_reopen_a_floating_group_as_one_window() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
|
||||
h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let floating = &h.state().ui.docking.layout.floating;
|
||||
assert_eq!(floating.len(), 1, "{floating:?}");
|
||||
assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() {
|
||||
let mut harness = harness(None, 1000.0, 1.0);
|
||||
@@ -232,7 +206,8 @@ fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() {
|
||||
harness.run_steps(3);
|
||||
let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap();
|
||||
assert_eq!(location.anchor, Some(Panel::Layers));
|
||||
assert_eq!(harness.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front);
|
||||
let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
|
||||
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -291,8 +266,8 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac
|
||||
h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap();
|
||||
h.run_steps(3);
|
||||
type_command(&mut h, "PROPERTIES");
|
||||
assert!(h.state().ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Properties)));
|
||||
assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front);
|
||||
let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
|
||||
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
|
||||
@@ -303,7 +278,7 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() {
|
||||
h.run_steps(3);
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
type_command(&mut h, "ui.dock.reset");
|
||||
let expected = cadcraft_ui_egui::docking::default_workspace();
|
||||
let expected = cadcraft_ui_egui::docking::Workspace::default();
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout);
|
||||
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
@@ -344,7 +319,7 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() {
|
||||
h.event(Event::Text("ui.dock.reset".into()));
|
||||
h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
let expected = cadcraft_ui_egui::docking::default_workspace();
|
||||
let expected = cadcraft_ui_egui::docking::Workspace::default();
|
||||
assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command");
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree");
|
||||
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
|
||||
@@ -384,37 +359,3 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() {
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.show_toolsets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drag_bar_moves_the_whole_group_beside_the_drawing() {
|
||||
use craft_ui::docking::{DockArea, Hit, Node};
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap();
|
||||
h.run_steps(4);
|
||||
let bar = |h: &Harness<'_, CadApp>| {
|
||||
DockArea::<Panel>::hit_rects(&h.ctx, egui::Id::new("cadcraft-docking"))
|
||||
.into_iter()
|
||||
.find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers))
|
||||
.map(|(_, rect)| rect)
|
||||
};
|
||||
let from = bar(&h).expect("the Tool Sets group draws a drag bar").center();
|
||||
let drawing = h.state().canvas.rect.expect("the drawing view is drawn");
|
||||
let to = egui::pos2(drawing.right() - 10.0, drawing.center().y);
|
||||
let document = h.state().session.doc().unwrap().clone();
|
||||
drag(&mut h, from, to);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group");
|
||||
// The drawing's frame splits: the drawing on the left, the whole group on the right.
|
||||
let mut nodes: Vec<&Node<Panel>> = layout.root.iter().collect();
|
||||
let mut beside = false;
|
||||
while let Some(node) = nodes.pop() {
|
||||
if let Node::Split { first, second, .. } = node {
|
||||
beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. })
|
||||
if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]);
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
}
|
||||
assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root);
|
||||
assert_eq!(h.state().session.doc().unwrap(), &document);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() {
|
||||
h.key_press_modifiers(modifiers, Key::Num8);
|
||||
h.run_steps(5);
|
||||
assert!(h.state().ui.docking.layout.contains(&Panel::QuickCalc));
|
||||
let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect;
|
||||
let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect;
|
||||
expression(&h).click();
|
||||
h.run_steps(3);
|
||||
expression(&h).type_text("2+3");
|
||||
@@ -55,7 +55,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() {
|
||||
h.key_press_modifiers(modifiers, Key::Num8);
|
||||
h.run_steps(5);
|
||||
assert_eq!(h.get(by().role(Role::TextInput).value("2+3")).value().as_deref(), Some("2+3"));
|
||||
assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect, floating);
|
||||
assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect, floating);
|
||||
assert_eq!(h.state().session.running.as_ref().unwrap().id, "line");
|
||||
assert_eq!(h.state().cmd.buffer, "12,34");
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
@@ -131,7 +131,7 @@ fn explicit_opens_still_reveal_and_calculations_do_not_open_the_palette() {
|
||||
app.run("ui.dock", json!({"operation":"activate", "panel":"toolSets"})).unwrap();
|
||||
app.run("ui.panel.quickcalc", Value::Null).unwrap();
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
cadcraft_ui_egui::docking::reveal_panel(&mut workspace, Panel::QuickCalc).unwrap();
|
||||
workspace.reveal_panel(Panel::QuickCalc).unwrap();
|
||||
assert_eq!(app.ui.docking.layout, workspace.layout, "explicit open must activate an inactive tab");
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ Tool Sets, Layers, and Properties can be regrouped, split, floated within the ap
|
||||
|
||||
Panel IDs: `toolSets`, `layers`, `properties`, `externalReferences`. The protected center is `canvas`.
|
||||
|
||||
`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`, default `center`). Center and tab group with the target; side zones split beside it; above and below add a group in a floating window. `open` and `activate` show a hidden panel at its last place and select it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
|
||||
The QuickCalc palette (`quickCalc`) is not part of the default arrangement. Typed `QUICKCALC`/`QC`
|
||||
and `ui.panel.quickcalc` explicitly open and activate it: it floats the first time and later
|
||||
@@ -150,9 +150,9 @@ Advanced `ui.dock` operations use the same validated transaction as pointer gest
|
||||
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
|
||||
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
|
||||
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
|
||||
A center or tab `move` can include `before` (panel ID or null to append) for tab order.
|
||||
A center `move` can include `before` (panel ID or null to append) for tab order.
|
||||
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
|
||||
this is also the form emitted by the renderer, and it takes every action of craft-ui's docking, group, column and window moves included. Protected-canvas and app-specific panel
|
||||
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
|
||||
rules apply equally to both forms.
|
||||
|
||||
## Command-line replies
|
||||
|
||||
+3
-6
@@ -1,6 +1,6 @@
|
||||
# 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-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
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
|
||||
@@ -29,7 +29,7 @@ do them first.
|
||||
| F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 |
|
||||
| F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 |
|
||||
| F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 |
|
||||
| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 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 (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |
|
||||
| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |
|
||||
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
|
||||
| F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 |
|
||||
| F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 |
|
||||
@@ -43,7 +43,7 @@ do them first.
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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, and has no COMPAREEXPORT report; 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -150,9 +150,6 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 | 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-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed |
|
||||
| 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed |
|
||||
| 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed |
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
# Parity checklist
|
||||
|
||||
> **Generated:** 2026-10-25 by `cargo xtask parity`. Do not edit by hand.
|
||||
> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand.
|
||||
> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
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: 283 / 491 items (58%)** · 427 registered commands.
|
||||
|
||||
| Menu | Items | Covered | % |
|
||||
|---|---|---|---|
|
||||
| File | 22 | 13 | 59% |
|
||||
| Edit | 13 | 12 | 92% |
|
||||
| View | 75 | 36 | 48% |
|
||||
| Edit | 13 | 11 | 85% |
|
||||
| View | 75 | 31 | 41% |
|
||||
| Insert | 11 | 5 | 45% |
|
||||
| Format | 27 | 26 | 96% |
|
||||
| Tools | 85 | 66 | 78% |
|
||||
| Format | 27 | 25 | 93% |
|
||||
| Tools | 85 | 60 | 71% |
|
||||
| Draw | 87 | 55 | 63% |
|
||||
| Dimension | 28 | 28 | 100% |
|
||||
| Modify | 113 | 52 | 46% |
|
||||
@@ -32,6 +33,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
|
||||
- File > Markup Import...
|
||||
- File > Share...
|
||||
- Edit > Undo Group of commands
|
||||
- Edit > Cut with Base Point
|
||||
- View > Pan > Point
|
||||
- View > Orbit > Constrained Orbit
|
||||
- View > Orbit > Free Orbit
|
||||
@@ -66,9 +68,14 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
|
||||
- View > Render > Light > New Distant Light
|
||||
- View > Render > Light > Light Glyphs
|
||||
- View > Render > Materials Browser
|
||||
- View > UCS Icon > On
|
||||
- View > UCS Icon > Origin
|
||||
- View > UCS Icon > Properties...
|
||||
- View > ViewCube > On
|
||||
- View > ViewCube > Settings
|
||||
- View > Attribute Display > Normal
|
||||
- View > Attribute Display > On
|
||||
- View > Attribute Display > Off
|
||||
- View > Lights
|
||||
- View > Enter Full Screen
|
||||
- Insert > PDF Underlay...
|
||||
@@ -78,7 +85,9 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
|
||||
- Insert > ACIS File...
|
||||
- Insert > Drawing Exchange Binary...
|
||||
- Format > Transparency
|
||||
- Format > Thickness
|
||||
- Tools > Spelling
|
||||
- Tools > Inquiry > Volume
|
||||
- Tools > Update Fields
|
||||
- Tools > Edit Xref in Place
|
||||
- Tools > New UCS > World
|
||||
@@ -92,8 +101,14 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
|
||||
- Tools > New UCS > Z
|
||||
- Tools > Named UCS...
|
||||
- Tools > Customize > Interface (CUI)...
|
||||
- Tools > Customize > Edit Command Aliases (PGP)...
|
||||
- Tools > Import Settings
|
||||
- Tools > Export Settings
|
||||
- Tools > Express Tools > Convert Text to Mtext
|
||||
- Tools > Express Tools > Break-line Symbol
|
||||
- Tools > Express Tools > Move/Copy/Rotate
|
||||
- Tools > Express Tools > Explode Attributes to Text
|
||||
- Tools > Express Tools > Replace Block...
|
||||
- Tools > Express Tools > Super Hatch > Image...
|
||||
- Tools > Express Tools > Super Hatch > Xref...
|
||||
- Tools > Express Tools > Super Hatch > Select Existing Object
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: CADCraft vs AutoCAD 2027
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (RECTANG centre construction mode, #1127; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work
|
||||
remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall,
|
||||
@@ -156,7 +156,7 @@ presence: an option that prints "not available yet" doesn't count.
|
||||
| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) |
|
||||
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; no dynamic-block authoring, XCLIP, DesignCenter | [gaps](gaps.md#features) |
|
||||
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) |
|
||||
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563) and COMPAREEXPORT, MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) |
|
||||
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563), MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) |
|
||||
| Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) |
|
||||
| 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) |
|
||||
| Collaboration and cloud (Sheet Set Manager, Share, Traces, Markup Import, Activity Insights, DWG Compare) | 2% | 5% | 40–70 | none of these exist | [gaps](gaps.md#features) |
|
||||
@@ -223,7 +223,6 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured |
|
||||
| 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured |
|
||||
| 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured |
|
||||
| 2026-10-12 | minor | Blocks row evidence: Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); percentages and hours not re-measured |
|
||||
|
||||
Reference in New Issue
Block a user