M-EGUI037: port CADCraft and reconcile current main

This commit is contained in:
skylartaylor
2026-10-25 02:10:34 +02:00
115 changed files with 5539 additions and 1392 deletions
+5 -1
View File
@@ -23,7 +23,11 @@ Generated-in-code assets are original and have no file to list:
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
Generated
+234 -19
View File
@@ -430,6 +430,12 @@ 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"
@@ -670,6 +676,7 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -865,6 +872,12 @@ 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"
@@ -945,10 +958,20 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
dependencies = [
"egui",
"serde",
@@ -1084,7 +1107,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "ecolor"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1094,7 +1117,7 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1131,7 +1154,7 @@ dependencies = [
[[package]]
name = "egui"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1152,7 +1175,7 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1171,7 +1194,7 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
@@ -1198,7 +1221,7 @@ dependencies = [
[[package]]
name = "egui_glow"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1211,7 +1234,7 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"dify",
"eframe",
@@ -1237,7 +1260,7 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"serde",
@@ -1298,14 +1321,14 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
"ecolor",
"emath",
"epaint_default_fonts",
"font-types",
"font-types 0.12.7",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@@ -1325,7 +1348,7 @@ dependencies = [
[[package]]
name = "epaint_default_fonts"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equivalent"
@@ -1428,6 +1451,15 @@ 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"
@@ -1446,6 +1478,15 @@ 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"
@@ -1589,6 +1630,16 @@ 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"
@@ -1791,6 +1842,39 @@ dependencies = [
"foldhash 0.2.0",
]
[[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=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
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=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
[[package]]
name = "heapless"
version = "0.8.0"
@@ -1948,6 +2032,22 @@ 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"
@@ -2108,6 +2208,32 @@ dependencies = [
"accesskit_consumer",
]
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
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"
@@ -2862,6 +2988,18 @@ 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"
@@ -3199,6 +3337,16 @@ 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"
@@ -3206,7 +3354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.12.7",
"once_cell",
]
@@ -3218,7 +3366,7 @@ checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
"font-types",
"font-types 0.12.7",
"once_cell",
"yoke",
]
@@ -3513,6 +3661,16 @@ 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"
@@ -3656,6 +3814,29 @@ 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"
@@ -3810,6 +3991,12 @@ 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"
@@ -3957,6 +4144,15 @@ 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"
@@ -4685,7 +4881,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winit"
version = "0.30.13"
source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -4775,6 +4971,19 @@ dependencies = [
"wayland-protocols-wlr",
]
[[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"
@@ -4844,6 +5053,12 @@ 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.12.0"
@@ -5126,19 +5341,19 @@ dependencies = [
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+27 -18
View File
@@ -7,7 +7,7 @@ members = ["crates/*", "apps/*", "xtask"]
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies]
@@ -26,6 +26,9 @@ 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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# 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 = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false }
# foundation
thiserror = "2"
@@ -50,7 +53,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 = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
@@ -83,20 +86,26 @@ lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
# (Cargo ignores dependency-local patches).
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
# Keep the complete egui family and native event bridge on reviewed fork commits.
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+9 -1
View File
@@ -24,10 +24,18 @@ CADCraft is an independent project and is not affiliated with, sponsored by or e
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
(pinned through https://github.com/storytold/craft-libs/tree/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc).
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
Pinned licence text: https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/LICENSE
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
+1 -2
View File
@@ -123,8 +123,7 @@ numbers):
## Quick start
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
remains Rust 1.90.
CADCraft requires Rust 1.95 or later.
```sh
git clone https://github.com/storytold/cadcraft
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
@@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry |
|---|---|
| 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) |
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
@@ -135,6 +136,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
+4 -2
View File
@@ -96,9 +96,11 @@ mod web {
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
/// drop a `.lsp` file rather than asking for a name it cannot read.
fn services() -> Services {
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
}
pub fn start() {
+10 -1
View File
@@ -151,6 +151,13 @@ fn services() -> Services {
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
pick_lisp: Some(Box::new(|dir: &str| {
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
no_file_system: false,
}
}
@@ -368,7 +375,9 @@ fn main() -> eframe::Result {
app.open_path(f);
}
if app.session.docs.is_empty() {
app.session.new_drawing(false);
// In the units the profile asks for (MEASUREINIT, restored above).
let metric = app.session.settings.measureinit;
app.session.new_drawing(metric);
}
let ctx = cc.egui_ctx.clone();
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
+92 -13
View File
@@ -107,6 +107,23 @@ impl Filter {
*self = Self::default();
}
fn update_drift(&mut self, complete: bool, now: Instant, bias: &mut [f64; 6]) {
if !complete {
return;
}
if self.raw.iter().zip(*bias).all(|(raw, b)| (*raw - b).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (b, raw) in bias.iter_mut().zip(self.raw) {
*b += (raw - *b) * 0.01;
}
self.bias = Some(*bias);
}
} else {
self.idle_since = None;
}
}
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
for (index, seen) in self.seen.iter_mut().enumerate() {
if mask & (1 << index) != 0 {
@@ -128,19 +145,7 @@ impl Filter {
}
let Some(mut bias) = self.bias else { return Motion::default() };
// Follow small resting drift only after two seconds entirely inside the deadzone.
if complete {
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (bias, raw) in bias.iter_mut().zip(self.raw) {
*bias += (raw - *bias) * 0.01;
}
self.bias = Some(bias);
}
} else {
self.idle_since = None;
}
}
self.update_drift(complete, now, &mut bias);
let mut axes = [0.0; 6];
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
@@ -156,3 +161,77 @@ impl Filter {
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_reports() {
assert_eq!(decode(&[]), None);
assert_eq!(decode(&[3]), None);
assert_eq!(decode(&[3, 5]), Some(Report::Buttons(5)));
let values = [1_i16, -2, 3, 4, -5, 6];
let mut bytes = vec![1_u8];
for v in values {
bytes.extend_from_slice(&v.to_le_bytes());
}
assert_eq!(decode(&bytes), Some(Report::Axes { values, mask: 3 }));
bytes[0] = 2;
assert_eq!(decode(&bytes), Some(Report::Axes { values: [0, 0, 0, 1, -2, 3], mask: 2 }));
assert_eq!(decode(&[2, 1, 2, 3]), None, "too short for a rotation report");
}
/// Sixteen complete reports at rest, spread over more than CALIBRATION_TIME, all stamped
/// on a made-up point in time big enough that they sit in the past of later updates.
fn calibrated(t0: Instant) -> Filter {
let mut f = Filter::default();
for i in 0..17 {
let at = t0 + Duration::from_millis(50 * i);
assert_eq!(f.update([0; 6], 1, at), Motion::default());
assert_eq!(f.update([0; 6], 2, at), Motion::default());
}
assert!(f.calibrated());
f
}
#[test]
fn axes_deflect_and_expire() {
let t0 = Instant::now();
let mut f = calibrated(t0);
let at = t0 + Duration::from_secs(2);
let ms = Duration::from_millis;
let m = f.update([500, 500, 0, 0, 0, 0], 1, at);
assert!((m.pan[0] - 1.0).abs() < 1e-9);
assert!((m.pan[1] - 1.0).abs() < 1e-9);
assert_eq!(m.zoom, 0.0, "no rotation input");
// A fresh rotation report drives the zoom.
let m = f.update([0, 0, 0, 0, 0, 500], 2, at + ms(100));
assert!((m.zoom - 1.0).abs() < 1e-9);
// The rotation axes went quiet for over 250 ms while translations still arrive.
let m = f.update([500, 500, 0, 0, 0, 0], 1, at + ms(400));
assert_eq!(m.zoom, 0.0);
// A fresh rotation report puts the zoom back.
let m = f.update([500, 500, 0, 0, 0, 500], 2, at + ms(500));
assert!((m.zoom - 1.0).abs() < 1e-9);
}
#[test]
fn drift_tracks_rest_after_two_idle_seconds() {
// Five axes units of drift sit inside the deadzone (FULL_SCALE * DEADZONE = 20).
let mut f = Filter { raw: [5.0; 6], bias: Some([0.0; 6]), ..Filter::default() };
let t0 = Instant::now();
let mut b = [0.0; 6];
f.update_drift(true, t0, &mut b); // starts the idle window: no shift yet
assert_eq!(b, [0.0; 6]);
f.update_drift(true, t0 + Duration::from_secs(3), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "bias follows resting drift");
assert_eq!(f.bias, Some(b));
// Motion outside the deadzone resets the idle window.
f.raw = [100.0; 6];
f.update_drift(true, t0 + Duration::from_secs(4), &mut b);
assert_eq!(f.idle_since, None);
f.update_drift(true, t0 + Duration::from_secs(5), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "no shift before two seconds at rest");
}
}
+12 -1
View File
@@ -74,5 +74,16 @@ sync_github_name = false
[people.gurparvez]
real_name = "Gurparvez"
[people.ffishman]
[people.nicholasgower]
sync_github_name = true
[people.ffishman]
real_name = "Rodrigo Gressler"
display_name = "Rodrigo"
sync_github_name = false
[people.aussieboer61]
real_name = "Richard Sauer"
display_name = "Richard Sauer"
sync_github_name = false
+25 -4
View File
@@ -111,9 +111,9 @@ impl Color {
_ => {}
}
if let Some(rest) = t.strip_prefix("color ") {
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index);
return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
}
if let Ok(i) = t.parse::<u16>() {
if let Some(i) = parse_digits::<u16>(&t) {
return match i {
0 => Some(Color::ByBlock),
256 => Some(Color::ByLayer),
@@ -124,14 +124,22 @@ impl Color {
if t.starts_with('#') {
return Rgb::parse_hex(&t).map(Color::True);
}
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect();
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
if let [Some(r), Some(g), Some(b)] = parts.as_slice() {
return Some(Color::True(Rgb(*r, *g, *b)));
}
None
}
}
/// Parse an unsigned integer made of ASCII digits only (no sign, unlike `str::parse`).
fn parse_digits<T: std::str::FromStr>(s: &str) -> Option<T> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// The RGB of ACI `i` (0 and out-of-range map to white/7).
pub fn aci_rgb(i: u8) -> Rgb {
PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
@@ -234,6 +242,19 @@ mod tests {
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
}
#[test]
fn parse_rejects_plus_sign() {
assert_eq!(Color::parse("+5"), None);
assert_eq!(Color::parse(" +256"), None);
assert_eq!(Color::parse("color +5"), None);
assert_eq!(Color::parse("+1,+2,+3"), None);
assert_eq!(Color::parse("1,+2,3"), None);
assert_eq!(Color::parse("5"), Some(Color::Index(5)));
assert_eq!(Color::parse("256"), Some(Color::ByLayer));
assert_eq!(Color::parse("color 5"), Some(Color::Index(5)));
assert_eq!(Color::parse("1, 2, 3"), Some(Color::True(Rgb(1, 2, 3))));
}
#[test]
fn parse_hex_rejects_repeated_hash() {
assert_eq!(Rgb::parse_hex("##ff0000"), None);
+1 -1
View File
@@ -49,7 +49,7 @@ struct Parser<'a> {
impl Parser<'_> {
fn ws(&mut self) {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
self.i += 1;
}
}
+1 -1
View File
@@ -208,7 +208,7 @@ impl Default for ParametricSettings {
angle_tolerance: 1.0,
auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"]
.iter()
.map(|s| s.to_string())
.map(ToString::to_string)
.collect(),
bars_visible: true,
bar_exceptions: Vec::new(),
+5
View File
@@ -66,6 +66,10 @@ pub struct Foreign {
pub records: BTreeMap<ForeignKey, ForeignExtra>,
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
pub targets: BTreeMap<Handle, ForeignKey>,
/// The upper-case names of the file's classes in its CLASSES order, kept when the file has
/// proxy objects or entities: a proxy's class number (group 91) is 500 plus its class's place
/// here, and saving renumbers it to the written CLASSES (#1812).
pub class_order: Vec<String>,
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
@@ -81,6 +85,7 @@ impl Foreign {
&& self.records.is_empty()
&& self.classes.is_empty()
&& self.targets.is_empty()
&& self.class_order.is_empty()
&& self.data_store.is_empty()
}
+16
View File
@@ -98,6 +98,9 @@ pub struct Drawing {
pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
pub model: EntityStore,
pub layouts: Vec<Layout>,
/// The layout a file marks active (its block is `*Paper_Space`, the others `*Paper_SpaceN`),
/// written active again. `None`, or a layout that no longer exists: the first by tab order.
pub active_layout: Option<String>,
pub views: Vec<NamedView>,
/// Model space viewport configurations (VPORTS): the tiles shown and named ones.
pub vports: Vec<VportConfig>,
@@ -171,6 +174,7 @@ impl Drawing {
blocks: BTreeMap::new(),
model: EntityStore::new(),
layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
active_layout: None,
views: Vec::new(),
vports: Vec::new(),
ucss: Vec::new(),
@@ -298,6 +302,18 @@ impl Drawing {
true
}
/// The layouts in the order their paper space blocks are named in a file: the active one
/// (`*Paper_Space`) first, then the others by tab order (`*Paper_Space0`, `*Paper_Space1`, …).
pub fn paper_space_layouts(&self) -> Vec<&Layout> {
let mut ls: Vec<&Layout> = self.layouts.iter().collect();
ls.sort_by_key(|l| l.tab_order);
if let Some(i) = self.active_layout.as_ref().and_then(|a| ls.iter().position(|l| l.name.eq_ignore_ascii_case(a))) {
let active = ls.remove(i);
ls.insert(0, active);
}
ls
}
pub fn space(&self, space: &Space) -> Option<&EntityStore> {
match space {
Space::Model => Some(&self.model),
+1 -1
View File
@@ -857,7 +857,7 @@ pub struct VportConfig {
impl VportConfig {
/// The name of the configuration shown.
pub const ACTIVE: &'static str = "*Active";
pub const ACTIVE: &str = "*Active";
pub fn is_active(&self) -> bool {
self.name.eq_ignore_ascii_case(Self::ACTIVE)
}
+4 -4
View File
@@ -109,9 +109,9 @@ impl Default for CellStyle {
}
impl CellStyle {
pub const TITLE: &'static str = "Title";
pub const HEADER: &'static str = "Header";
pub const DATA: &'static str = "Data";
pub const TITLE: &str = "Title";
pub const HEADER: &str = "Header";
pub const DATA: &str = "Data";
/// Whether `name` is one of the three styles every table style has.
pub fn is_builtin(name: &str) -> bool {
@@ -190,7 +190,7 @@ impl TableStyle {
/// The names of its cell styles: Title, Header, Data, then the custom ones.
pub fn cell_style_names(&self) -> Vec<String> {
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(|s| s.to_string()).collect();
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(ToString::to_string).collect();
out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone()));
out
}
+68
View File
@@ -541,12 +541,80 @@ mod native {
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
}
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
/// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive).
const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary";
/// A style or scale name, or `default` when the drawing has none.
fn name_or_default(name: &str, default: &str) -> String {
let name = name.trim();
if name.is_empty() { default.to_string() } else { name.to_string() }
}
/// A drawing variable's value as text, or `default` when the header holds a value the
/// variable doesn't accept (a hostile file can put any integer in it).
fn variable_value(value: i16, valid: &[i16], default: i16) -> String {
if valid.contains(&value) { value.to_string() } else { default.to_string() }
}
/// Add the drawing variables dictionary AutoCAD always writes into the named object
/// dictionary, with the variables it keeps there, when the drawing has none (#1813).
/// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported
/// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such
/// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs
/// read there without it, so newer output is left as it was, and a dictionary the drawing
/// does have keeps its own entries and values.
fn add_variable_dictionary(doc: &mut acadrust::CadDocument) {
use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType};
if doc.version >= acadrust::DxfVersion::AC1032 {
return;
}
let root_handle = doc.header.named_objects_dict_handle;
match doc.objects.get(&root_handle) {
Some(ObjectType::Dictionary(root)) => {
if root.get(VARIABLE_DICTIONARY).is_some() {
return;
}
}
_ => return,
}
// The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation
// scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and
// LAYERNOTIFY have no DXF header variable.
let variables = [
("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")),
("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")),
("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")),
("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)),
("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)),
("LAYEREVAL", "0".to_string()),
("LAYERNOTIFY", "0".to_string()),
];
let dictionary_handle = doc.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = dictionary_handle;
dictionary.owner = root_handle;
for (name, value) in variables {
let handle = doc.allocate_handle();
let mut variable = DictionaryVariable::new(name, &value);
variable.handle = handle;
variable.owner_handle = dictionary_handle;
dictionary.add_entry(name, handle);
doc.objects.insert(handle, ObjectType::DictionaryVariable(variable));
}
doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) {
root.add_entry(VARIABLE_DICTIONARY, dictionary_handle);
}
}
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
+1 -1
View File
@@ -302,7 +302,7 @@ mod native {
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
pub fn discard(path: &str) {
let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some());
let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| parse_file_name(n).is_some());
if !ours {
log::warn!("autosave: not deleting {path}: not an autosave file");
return;
+1 -1
View File
@@ -2,7 +2,7 @@
//! Block Editor.
pub(crate) mod bedit;
mod purge;
pub(in crate::cmd) mod purge;
use std::sync::Arc;
+6 -4
View File
@@ -13,8 +13,9 @@ use super::super::qselect::wildcard;
use super::super::{bad, find_command, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// The named objects PURGE can remove. `USAGE` runs the same scan.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
pub(in crate::cmd) enum Kind {
Blocks,
Dimstyles,
Groups,
@@ -128,7 +129,7 @@ fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String>
/// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the
/// arrowhead blocks dimension styles and dimension overrides name.
fn used_blocks(d: &Drawing) -> HashSet<String> {
pub(in crate::cmd) fn used_blocks(d: &Drawing) -> HashSet<String> {
let mut used = HashSet::new();
let mut frontier: Vec<String> = Vec::new();
for st in &d.dim_styles {
@@ -177,8 +178,9 @@ fn is(name: &str, list: &[&str]) -> bool {
list.iter().any(|n| n.eq_ignore_ascii_case(name))
}
/// Names of the unused objects of a named kind, in table order.
fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
/// Names of the unused objects of a named kind, in table order. This is PURGE's scan; `USAGE`
/// reads it too, without purging anything.
pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>();
match kind {
Kind::Blocks => {
+3 -1
View File
@@ -1083,7 +1083,9 @@ 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::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()),
(CloudKind::Rect, _) => {
Prompt { corner: true, ..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()),
+3 -2
View File
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["File", "New Drawing..."])
.key("Cmd+N")
.alias(&["qnew"])
.params("{metric?: bool, template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
.params("{metric?: bool (default MEASUREINIT), template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
.enabled(always)
.noundo(),
CommandSpec::new("open", "Open...", run_open)
@@ -252,7 +252,8 @@ fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = template::run_new(s, p)? {
return Ok(r);
}
let i = s.new_drawing(bool_or(p, "metric", false));
let metric = s.settings.measureinit;
let i = s.new_drawing(bool_or(p, "metric", metric));
Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
}
+11 -2
View File
@@ -34,12 +34,17 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The tolerance curves are flattened with for boundary tracing, in a space with extents `ext`.
pub(super) fn outline_tol(ext: Bounds2) -> f64 {
(ext.width() + ext.height()).max(1e-9) / 20000.0
}
/// Tessellated outlines of every visible curve in the space (hatch boundary candidates), each
/// with the handle of the object it came from.
fn outlines(d: &Drawing, space: &Space, near: Option<Bounds2>) -> (Vec<Vec<Vec2>>, Vec<Handle>) {
let Some(store) = d.space(space) else { return (Vec::new(), Vec::new()) };
let ext = d.extents(space);
let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0;
let tol = outline_tol(ext);
let mut out = Vec::new();
let mut owners = Vec::new();
for e in store.iter() {
@@ -113,7 +118,8 @@ fn closed_loop(d: &Drawing, e: &Entity) -> Option<Vec<PolyVertex>> {
}
/// Open curves that can make up an island together (a square drawn as four LINEs, two ARCs
/// forming a circle). Closed objects are islands on their own (see `closed_loop`).
/// forming a circle, the inner lines of multiline walls around a room). Closed objects are
/// islands on their own (see `closed_loop`).
fn island_curve(d: &Drawing, e: &Entity) -> bool {
matches!(
e.kind,
@@ -123,6 +129,7 @@ fn island_curve(d: &Drawing, e: &Entity) -> bool {
| EntityKind::LwPolyline(_)
| EntityKind::Polyline3d(_)
| EntityKind::Spline(_)
| EntityKind::MLine(_)
) && closed_loop(d, e).is_none()
}
@@ -204,6 +211,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
let ext = d.extents(&space);
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
let (polys, owners) = outlines(d, &space, None);
// A line that ends on a flattened curve (an ellipse) can stop short of its chords.
let polys = cadcraft_geom::region::snap_ends(&polys, outline_tol(ext), tol);
let found = cadcraft_geom::region::boundary_at_gap(&polys, p, tol, gap_tolerance(d))
.ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
let outer = found.outer;
+3 -2
View File
@@ -86,9 +86,10 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill {
if nb.is_empty() {
return Refill::Open;
}
let tol = (nb.width() + nb.height()).max(1e-9) / 20000.0;
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
let tol = super::outline_tol(nb);
let snap = ((nb.width() + nb.height()) * 1e-9).max(1e-9);
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
let polys = region::snap_ends(&polys, tol, snap);
let gap = gap_tolerance(after);
let found = if l.outer {
// The face around a point inside the old loop, carried along with the objects.
+135
View File
@@ -1,5 +1,7 @@
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2, Vec3};
use serde_json::{Value, json};
@@ -11,7 +13,10 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod loops;
mod regions;
mod shapes;
mod usage;
/// The inquiry commands: distances, points, listing, area, time and status, the drawing and
/// entity queries an agent reads, SHAPES, USAGE, LOOPS, COUNT and the region commands.
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
CommandSpec::new("dist", "Distance", run_dist)
@@ -46,6 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
CommandSpec::new("usage", "Analyze Usage", usage::run).params(usage::PARAMS).noundo(),
CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(),
CommandSpec::new("texts", "Query Texts", run_texts)
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
@@ -66,15 +72,119 @@ pub fn specs() -> Vec<CommandSpec> {
v
}
/// Format a distance with the drawing's `LUNITS` and `LUPREC` (decimal units, four places by
/// default).
fn fmt_d(s: &Session, v: f64) -> String {
let h = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
format_distance(v, h.0, h.1)
}
/// Format an angle with the drawing's `AUNITS` and `AUPREC` (decimal degrees, whole degrees by
/// default).
fn fmt_a(s: &Session, v: f64) -> String {
let h = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
format_angle(v, h.0, h.1)
}
/// Schema of the analysis self-check block (#1440). Fields may be added within a version; a
/// rename or a changed meaning bumps it.
pub(super) const SELF_CHECK_SCHEMA: u64 = 1;
/// How far past its bound a value may be before it is implausible: a relative slack for the
/// rounding of two ways of measuring the same thing.
const IMPLAUSIBLE_TOLERANCE: f64 = 1e-9;
/// How many objects of one type an analysis skipped, and every distinct reason it gave.
#[derive(Default)]
struct SkipTally {
count: u64,
reasons: BTreeSet<String>,
}
/// The self-check every analysis result carries (#1440): how many objects it found, how many it
/// analysed, what it skipped and why, the caps it hit with their values, and values so
/// implausible that they point at a bug rather than at the drawing.
#[derive(Default)]
pub(super) struct SelfCheck {
found: u64,
analyzed: u64,
skipped: BTreeMap<String, SkipTally>,
caps: BTreeMap<String, u64>,
implausible: Vec<Value>,
}
impl SelfCheck {
/// An empty self-check: nothing found, analysed or skipped yet.
pub(super) fn new() -> Self {
Self::default()
}
/// Count objects the analysis found (matched its filters).
pub(super) fn found(&mut self, n: u64) {
self.found = self.found.saturating_add(n);
}
/// Count objects the analysis actually analysed.
pub(super) fn analyzed(&mut self, n: u64) {
self.analyzed = self.analyzed.saturating_add(n);
}
/// Count `n` objects of `ty` skipped, with the reason.
pub(super) fn skip(&mut self, ty: &str, reason: &str, n: u64) {
let skip = self.skipped.entry(ty.to_string()).or_default();
skip.count = skip.count.saturating_add(n);
skip.reasons.insert(reason.to_string());
}
/// Record a cap the analysis hit, with its value (e.g. `limit`, 200).
pub(super) fn cap(&mut self, name: &str, value: u64) {
self.caps.insert(name.to_string(), value);
}
/// Flag `value` as implausible when it is past `bound`, with the rule that makes it so. A
/// non-finite `bound` means no bound applies (an empty drawing has no extents). Values within
/// `IMPLAUSIBLE_TOLERANCE` of the bound are not flagged: they are the rounding of two ways
/// of measuring the same thing (a border rectangle's shoelace area against the extents'
/// `width * height`), not a bug.
pub(super) fn implausible(&mut self, handle: Handle, ty: &str, field: &str, value: f64, bound: f64, rule: &str) {
if value.is_finite() && bound.is_finite() && value > bound + bound.abs() * IMPLAUSIBLE_TOLERANCE {
self.implausible.push(json!({
"handle": handle.hex(),
"type": ty,
"field": field,
"value": value,
"bound": bound,
"reason": rule,
}));
}
}
/// The skip map in the shape analyses have always reported: type → {count, reason}.
pub(super) fn skipped_json(&self) -> Value {
Value::Object(
self.skipped
.iter()
.map(|(ty, skip)| {
let reason = skip.reasons.iter().map(String::as_str).collect::<Vec<_>>().join("; ");
(ty.clone(), json!({ "count": skip.count, "reason": reason }))
})
.collect(),
)
}
/// The self-check as the JSON object every analysis returns: schema, counts, skip map, caps
/// and implausible values.
pub(super) fn to_json(&self) -> Value {
json!({
"schema": SELF_CHECK_SCHEMA,
"found": self.found,
"analyzed": self.analyzed,
"skipped": self.skipped_json(),
"caps": self.caps,
"implausible": self.implausible,
})
}
}
/// The angle of `d` up from the XY plane.
fn angle_from_xy(d: Vec3) -> f64 {
d.z.atan2(d.xy().len())
@@ -898,3 +1008,28 @@ impl Interactive for AreaM {
vec![super::helpers::lwpoly(v, true)]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against
/// the extents' `width * height`) is rounding, not a bug; a value clearly past it still is.
#[test]
fn implausible_allows_rounding_but_not_a_real_excess() {
let bound: f64 = 100.0;
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Polyline", "area", bound.next_up(), bound, "past the bound");
assert!(check.implausible.is_empty(), "one ulp past the bound is rounding: {}", check.to_json());
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Polyline", "area", bound * (1.0 + 1e-6), bound, "past the bound");
assert_eq!(check.implausible.len(), 1, "a real excess is still flagged: {}", check.to_json());
// A bound that is not finite means no bound applies: the value is not flagged.
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Region", "area", 1e9, f64::INFINITY, "past the bound");
assert!(check.implausible.is_empty(), "an infinite bound applies to nothing: {}", check.to_json());
}
}
+114 -19
View File
@@ -10,6 +10,7 @@ use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::super::*;
use super::SelfCheck;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Merges done while expanding nested references; a hostile file stops here (and says so).
@@ -20,12 +21,15 @@ const MAX_HANDLES: usize = 100_000;
/// COUNT's JSON parameters.
pub(super) const PARAMS: &str = "{block?, object?: handle (count objects like this one), space?: current|model|all|<layout> (default current), \
handles?: [...] (count in these objects), nested?: bool (default true), highlight?: bool} → \
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message`";
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message` and \
`selfCheck` (found/analyzed/skipped counts with reasons, and the caps hit: work, handles, blockDepth)";
/// COUNT's interactive command-line prompt machine.
pub(super) fn machine() -> Box<dyn Interactive> {
Box::new(CountM)
}
/// COUNTTABLE's interactive command-line prompt machine (it asks for the insertion point).
pub(super) fn table_machine() -> Box<dyn Interactive> {
Box::new(CountTableM)
}
@@ -109,11 +113,14 @@ struct Expander<'a> {
memo: HashMap<(String, usize), Rc<BTreeMap<String, u64>>>,
work: u64,
truncated: bool,
/// A block at the end of the depth cap still held references.
depth_capped: bool,
}
impl<'a> Expander<'a> {
/// An expander that has not traversed any block yet.
fn new(d: &'a Drawing) -> Self {
Expander { d, memo: HashMap::new(), work: 0, truncated: false }
Expander { d, memo: HashMap::new(), work: 0, truncated: false, depth_capped: false }
}
/// The definition's own name for a referenced block (names are case-insensitive).
fn canonical(&self, name: &str) -> String {
@@ -125,10 +132,17 @@ impl<'a> Expander<'a> {
if let Some(m) = self.memo.get(&key) {
return m.clone();
}
if levels == 0 {
// More references than MAX_BLOCK_DEPTH covers: say so rather than silently stop.
if self.d.block(name).is_some_and(|b| b.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(_)))) {
self.depth_capped = true;
}
let out = Rc::new(BTreeMap::new());
self.memo.insert(key, out.clone());
return out;
}
let mut out: BTreeMap<String, u64> = BTreeMap::new();
if levels > 0
&& let Some(b) = self.d.block(name)
{
if let Some(b) = self.d.block(name) {
let b = b.clone();
for e in b.entities.iter() {
let EntityKind::Insert(i) = &e.kind else { continue };
@@ -173,6 +187,14 @@ struct Tally {
/// Top-level objects holding `want` (when one block is asked for).
handles: Vec<Handle>,
truncated: bool,
/// Top-level objects scanned, and the block references among them.
found: u64,
analyzed: u64,
/// Top-level objects that are not block references, by type.
skipped: BTreeMap<&'static str, u64>,
/// More top-level objects held the block than `MAX_HANDLES`.
handles_capped: bool,
depth_capped: bool,
}
/// Count block references in `scope`; with `want`, also collect the top-level objects holding it.
@@ -180,7 +202,13 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
let mut x = Expander::new(d);
let mut t = Tally::default();
for (space, e) in scope.objects(d) {
let EntityKind::Insert(i) = &e.kind else { continue };
t.found = t.found.saturating_add(1);
let EntityKind::Insert(i) = &e.kind else {
let n = t.skipped.entry(e.kind.type_name()).or_default();
*n = n.saturating_add(1);
continue;
};
t.analyzed = t.analyzed.saturating_add(1);
let name = x.canonical(&i.block);
let m = copies(i);
let mut holds = want.is_some_and(|w| w.eq_ignore_ascii_case(&name));
@@ -199,15 +227,41 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
holds |= want.is_some_and(|w| w.eq_ignore_ascii_case(k));
}
}
if holds && t.handles.len() < MAX_HANDLES {
t.handles.push(e.handle);
if holds {
if t.handles.len() < MAX_HANDLES {
t.handles.push(e.handle);
} else {
t.handles_capped = true;
}
}
}
t.spaces.retain(|_, m| !m.is_empty());
t.truncated = x.truncated;
t.depth_capped = x.depth_capped;
t
}
/// The self-check for a COUNT-style result: what was scanned, counted and skipped, and the caps
/// that stopped it.
fn self_check(t: &Tally) -> Value {
let mut check = SelfCheck::new();
check.found(t.found);
check.analyzed(t.analyzed);
for (ty, n) in &t.skipped {
check.skip(ty, "not a block reference", *n);
}
if t.truncated {
check.cap("work", MAX_WORK);
}
if t.handles_capped {
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
}
if t.depth_capped {
check.cap("blockDepth", u64::try_from(MAX_BLOCK_DEPTH).unwrap_or(u64::MAX));
}
check.to_json()
}
/// A translation-free signature of an object's geometry: objects with equal keys are copies of
/// each other (moved, not rotated or scaled). Coordinates are rounded to 1e-6.
fn shape_key(k: &EntityKind) -> Option<String> {
@@ -262,7 +316,15 @@ fn list(t: &Tally, scope: &Scope) -> Value {
m
};
let total = t.blocks.values().fold(0u64, |a, b| a.saturating_add(*b));
json!({ "blocks": t.blocks, "nested": t.nested, "spaces": t.spaces, "total": total, "truncated": t.truncated, "message": message })
json!({
"blocks": t.blocks,
"nested": t.nested,
"spaces": t.spaces,
"total": total,
"truncated": t.truncated,
"selfCheck": self_check(t),
"message": message,
})
}
/// Count block `b` (its references, nested ones included) in `scope`.
@@ -273,7 +335,15 @@ fn count_block(d: &Drawing, scope: &Scope, nested: bool, b: &str) -> Value {
let k = t.nested.get(&name).copied().unwrap_or(0);
let wh = scope.label();
let message = if k > 0 { format!("Block {name}: {n} in {wh} ({k} nested).") } else { format!("Block {name}: {n} in {wh}.") };
json!({ "block": name, "count": n, "nested": k, "handles": hex(&t.handles), "truncated": t.truncated, "message": message })
json!({
"block": name,
"count": n,
"nested": k,
"handles": hex(&t.handles),
"truncated": t.truncated,
"selfCheck": self_check(&t),
"message": message,
})
}
/// Count the objects in `scope` shaped like `h` (a block reference: references of its block).
@@ -284,18 +354,35 @@ fn count_like(d: &Drawing, scope: &Scope, nested: bool, h: Handle) -> Result<Val
}
let ty = e.kind.type_name();
let key = shape_key(&e.kind).ok_or_else(|| bad("count", format!("a {ty} can't be counted by shape")))?;
let hs: Vec<Handle> = scope
.objects(d)
.into_iter()
.filter(|(_, o)| o.kind.type_name() == ty && shape_key(&o.kind).as_deref() == Some(key.as_str()))
.map(|(_, o)| o.handle)
.take(MAX_HANDLES)
.collect();
let mut check = SelfCheck::new();
let mut hs: Vec<Handle> = Vec::new();
let mut capped = false;
for (_, o) in scope.objects(d) {
check.found(1);
if o.kind.type_name() != ty {
check.skip(o.kind.type_name(), "a different type", 1);
continue;
}
check.analyzed(1);
if shape_key(&o.kind).as_deref() == Some(key.as_str()) {
if hs.len() < MAX_HANDLES {
hs.push(o.handle);
} else {
capped = true;
}
}
}
if capped {
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
}
let n = hs.len();
let message = format!("{ty}: {n} like the selected one in {}.", scope.label());
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "message": message }))
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "selfCheck": check.to_json(), "message": message }))
}
/// The body of COUNT and COUNTLIST: list the blocks in scope, count one block, or count the
/// objects shaped like a picked one. `default_all` makes an omitted `space` mean every space
/// (COUNTLIST) instead of the current one (COUNT).
fn run(s: &mut Session, cmd: &str, p: &Value, default_all: bool) -> Result<Value> {
let scope = Scope::parse(s, cmd, p, default_all)?;
let nested = bool_or(p, "nested", true);
@@ -336,6 +423,8 @@ fn table_style(s: &Session) -> cadcraft_doc::TableStyle {
d.table_styles.iter().find(|t| t.name.eq_ignore_ascii_case(&cur)).or(d.table_styles.first()).cloned().unwrap_or_default()
}
/// COUNTTABLE: insert a table of the block counts in scope at `at`, and report what the tally
/// covered (and refused: no block references in scope is an error, not an empty table).
pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "counttable";
let at = point_req(CMD, p, "at")?;
@@ -348,9 +437,15 @@ pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
let header = Some(vec!["Name".to_string(), "Count".to_string()]);
let table = super::super::table::make_table(&table_style(s), at, cells.len(), 2, None, None, Some("Block Count"), header, &cells);
let h = s.add_entity(EntityKind::Table(table))?;
Ok(json!({ "handle": h.hex(), "rows": cells.len(), "message": format!("Table of {} block(s) inserted.", cells.len()) }))
Ok(json!({
"handle": h.hex(),
"rows": cells.len(),
"selfCheck": self_check(&t),
"message": format!("Table of {} block(s) inserted.", cells.len()),
}))
}
/// Echo a result's `message` at the command line, one line at a time.
fn echo_lines(s: &mut Session, r: &Value) {
if let Some(m) = r.get("message").and_then(Value::as_str) {
for l in m.lines() {
+27 -2
View File
@@ -12,8 +12,10 @@ use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::super::*;
use super::SelfCheck;
use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// The region commands: UNION, SUBTRACT and INTERSECT combine regions, MASSPROP measures them.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("union", "Union", run_union)
@@ -33,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(SelectRun::new("intersect", "INTERSECT")))),
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
.params("{handles?} (regions) → properties of each")
.params("{handles?} (regions) → properties of each, plus `selfCheck` (found/analyzed/skipped with reasons, caps, implausible values)")
.noundo()
.interactive(|_| Ok(Box::new(SelectRun::new("massprop", "MASSPROP")))),
]
@@ -211,13 +213,16 @@ impl Interactive for SubtractM {
fn name(&self) -> &'static str {
"SUBTRACT"
}
/// Tell the user what to select first: the regions to subtract from.
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo("Select regions to subtract from ..");
Ok(Step::Continue)
}
/// Prompt for the current selection phase (regions to subtract from, then regions to remove).
fn prompt(&self, _s: &Session) -> Prompt {
self.sel.prompt()
}
/// The first selection is the regions to subtract from; the second is subtracted from them.
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
@@ -243,18 +248,38 @@ impl Interactive for SubtractM {
}
}
/// MASSPROP: area, perimeter, bounding box, centroid, moments of inertia and principal directions
/// for each selected region, plus a self-check of what was measured, skipped and found
/// implausible.
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let rs = regions(s, &hs)?;
if rs.is_empty() {
return Err(other("Select one or more regions."));
}
let mut check = SelfCheck::new();
let d = s.doc()?;
let extents = d.extents(&s.space());
// No visible extents means no bound to compare an area against (`implausible` skips an
// infinite bound).
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
for h in &hs {
check.found(1);
match d.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Region(_)) => check.analyzed(1),
Some(kind) => check.skip(kind.type_name(), "not a region", 1),
None => check.skip("Missing", "no such object", 1),
}
}
let f = |v: f64| super::fmt_d(s, v);
let mut out = Vec::new();
let mut lines = Vec::new();
for (h, r) in &rs {
let m = r.props();
let c = m.centroid;
let diagonal = if m.bounds.is_empty() { 0.0 } else { m.bounds.min.dist(m.bounds.max) };
check.implausible(*h, "Region", "perimeter", m.perimeter, diagonal * 10.0, "perimeter is more than 10 x the bounding-box diagonal");
check.implausible(*h, "Region", "area", m.area, extents_area, "area is larger than the drawing extents");
// Moments about the centroid, and the principal moments and directions.
let [ixc, iyc, ixyc] = m.centroid_moments;
let mid = (ixc + iyc) / 2.0;
@@ -292,5 +317,5 @@ fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
ang.cos(),
));
}
Ok(json!({ "regions": out, "message": lines.join("\n") }))
Ok(json!({ "regions": out, "selfCheck": check.to_json(), "message": lines.join("\n") }))
}
+98 -27
View File
@@ -2,26 +2,37 @@
//! winding, the first self-intersection and coincident vertices — for agents that read a drawing.
//! Read-only.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{Entity, EntityKind};
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
use serde_json::{Value, json};
use super::super::*;
use super::{area_of, object_area};
use super::{SelfCheck, area_of, object_area};
use crate::{Result, Session};
const CMD: &str = "shapes";
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches), selfCheck: {schema, found, analyzed, skipped: {<type>: {count, reason}}, caps: {<name>: <value>}, implausible: [{handle, type, field, value, bound, reason}]} (what the analysis covered, the caps it hit, and values that point at a bug)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
/// The facts SHAPES reports for one closed shape, or why an entity is not one.
enum Facts {
Shape { perimeter: f64, area: f64, signed: f64, centroid: Option<Vec2>, cross: Option<Vec2>, dups: Vec<(usize, f64)> },
Shape {
perimeter: f64,
area: f64,
signed: f64,
centroid: Option<Vec2>,
cross: Option<Vec2>,
/// The shape had more edges than the self-intersection cap checks.
cross_capped: bool,
dups: Vec<(usize, f64)>,
bounds: Bounds2,
},
Skip(&'static str),
}
/// SHAPES: geometry facts for every closed shape that matches the filters in the current space,
/// paged by `offset`/`limit`, with the self-check of what was found, analysed, skipped and
/// flagged.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
@@ -52,15 +63,29 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let s: &Session = s;
let st = s.state()?;
let d = &st.doc;
let empty = || json!({ "schema": 1, "count": 0, "returned": 0, "offset": offset, "truncated": false, "shapes": [], "skipped": {} });
let empty = || {
json!({
"schema": 1,
"count": 0,
"returned": 0,
"offset": offset,
"truncated": false,
"shapes": [],
"skipped": {},
"selfCheck": SelfCheck::new().to_json(),
})
};
let Some(store) = d.space(&st.space) else { return Ok(empty()) };
let extents = d.extents(&st.space);
// No visible extents means no bound to compare an area against (`implausible` skips an
// infinite bound).
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
let tol = tolerance.unwrap_or_else(|| {
let ext = d.extents(&st.space);
let diag = if ext.is_empty() { 0.0 } else { ext.min.dist(ext.max) };
let diag = if extents.is_empty() { 0.0 } else { extents.min.dist(extents.max) };
(diag * 1e-9).max(1e-12)
});
let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
let mut skipped: BTreeMap<&str, (usize, BTreeSet<&str>)> = BTreeMap::new();
let mut check = SelfCheck::new();
let mut shapes: Vec<(&Entity, Facts)> = Vec::new();
for e in store
.iter()
@@ -68,31 +93,51 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, wtol)))
{
check.found(1);
match facts(s, e, tol)? {
Facts::Skip(why) => {
let entry = skipped.entry(e.kind.type_name()).or_default();
entry.0 += 1;
entry.1.insert(why);
Facts::Skip(why) => check.skip(e.kind.type_name(), why, 1),
f => {
check.analyzed(1);
if let Facts::Shape { perimeter, area, cross_capped, bounds, .. } = &f {
if *cross_capped {
check.cap("crossingSegments", MAX_CROSSING_SEGMENTS as u64);
}
let diagonal = if bounds.is_empty() { 0.0 } else { bounds.min.dist(bounds.max) };
check.implausible(
e.handle,
e.kind.type_name(),
"perimeter",
*perimeter,
diagonal * 10.0,
"perimeter is more than 10 x the bounding-box diagonal",
);
check.implausible(e.handle, e.kind.type_name(), "area", *area, extents_area, "area is larger than the drawing extents");
}
shapes.push((e, f));
}
f => shapes.push((e, f)),
}
}
let page: Vec<Value> = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect();
let skipped: BTreeMap<&str, Value> =
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
let truncated = page.len() < shapes.len().saturating_sub(offset);
if truncated {
check.cap("limit", u64::try_from(limit).unwrap_or(u64::MAX));
}
let skipped = check.skipped_json();
Ok(json!({
"schema": 1,
"count": shapes.len(),
"returned": page.len(),
"offset": offset,
"truncated": page.len() < shapes.len().saturating_sub(offset),
"truncated": truncated,
"shapes": page,
"skipped": skipped,
"selfCheck": check.to_json(),
}))
}
/// One analysed shape as the JSON object the command reports (null if `f` is not a shape).
fn shape_json(e: &Entity, f: &Facts) -> Value {
let Facts::Shape { perimeter, area, signed, centroid, cross, dups } = f else { return Value::Null };
let Facts::Shape { perimeter, area, signed, centroid, cross, dups, .. } = f else { return Value::Null };
json!({
"handle": e.handle.hex(),
"type": e.kind.type_name(),
@@ -106,11 +151,24 @@ fn shape_json(e: &Entity, f: &Facts) -> Value {
})
}
/// The facts SHAPES reports for one entity, or the reason it is not a closed shape: circles,
/// full ellipses, closed or self-closing polylines and splines are measured, arcs and everything
/// else are skipped.
fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
Ok(match &e.kind {
EntityKind::Circle(c) => {
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(c.center.xy()), cross: None, dups: Vec::new() }
let r = Vec2::new(c.radius, c.radius);
Facts::Shape {
perimeter,
area,
signed: area,
centroid: Some(c.center.xy()),
cross: None,
cross_capped: false,
dups: Vec::new(),
bounds: Bounds2::new(c.center.xy() - r, c.center.xy() + r),
}
}
EntityKind::Ellipse(el) => {
let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
@@ -118,7 +176,16 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
return Ok(Facts::Skip("open"));
}
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(geom.center), cross: None, dups: Vec::new() }
Facts::Shape {
perimeter,
area,
signed: area,
centroid: Some(geom.center),
cross: None,
cross_capped: false,
dups: Vec::new(),
bounds: geom.bounds(),
}
}
EntityKind::LwPolyline(pl) => {
let mut vertices = pl.vertices.clone();
@@ -145,7 +212,8 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
(dist <= tol).then_some((i, dist))
})
.collect();
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross: first_crossing(&g), dups }
let (cross, cross_capped) = first_crossing(&g);
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross, cross_capped, dups, bounds: g.bounds() }
}
EntityKind::Spline(sp) => {
let mut pts = sp.tessellate(1e-3);
@@ -162,9 +230,11 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
if pts.len() < 3 {
return Ok(Facts::Skip("degenerate"));
}
let bounds = Bounds2::from_points(pts.iter().copied());
let (area, perimeter) = area_of(&pts);
let g = Polyline::from_points(&pts, true);
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross: first_crossing(&g), dups: Vec::new() }
let (cross, cross_capped) = first_crossing(&g);
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross, cross_capped, dups: Vec::new(), bounds }
}
EntityKind::Arc(_) => Facts::Skip("open"),
_ => Facts::Skip("not a closed shape"),
@@ -174,13 +244,14 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
/// Shapes with more edges than this report no selfIntersection.
const MAX_CROSSING_SEGMENTS: usize = 4_000;
/// The first crossing between two non-adjacent edges, in edge order.
fn first_crossing(g: &Polyline) -> Option<Vec2> {
/// The first crossing between two non-adjacent edges, in edge order, and whether the shape was
/// too large to check (the cap applies even if a crossing lies among the first edges).
fn first_crossing(g: &Polyline) -> (Option<Vec2>, bool) {
let segs = g.segments();
let n = segs.len();
// The pairwise test is quadratic: past this many edges the answer is null, not a hang.
if n > MAX_CROSSING_SEGMENTS {
return None;
return (None, true);
}
for (i, a) in segs.iter().enumerate() {
for (j, b) in segs.iter().enumerate().skip(i + 2) {
@@ -188,9 +259,9 @@ fn first_crossing(g: &Polyline) -> Option<Vec2> {
continue;
}
if let Some(x) = intersect_segments(a, b).first() {
return Some(*x);
return (Some(*x), false);
}
}
}
None
(None, false)
}
+437
View File
@@ -0,0 +1,437 @@
//! USAGE: a read-only cross-reference of the named objects a drawing uses — for each layer,
//! block, text style and linetype, how many objects use it, whether they are only inside block
//! definitions, and which are empty or unused. The unused lists are PURGE's scan (see
//! `cmd::blocks::purge`); nothing here changes or deletes anything.
use std::collections::{HashMap, HashSet};
use cadcraft_doc::{Drawing, Entity, EntityKind};
use serde_json::{Value, json};
use super::super::blocks::purge;
use super::super::*;
use crate::{Result, Session};
const CMD: &str = "usage";
/// Names listed per kind in `unused`; the counts are complete unless the scan was cut short
/// (`incomplete`).
const MAX_NAMES: usize = 5_000;
/// The most objects one scan reads, across model space, the layouts and the block definitions. A
/// drawing with more is reported as `incomplete` instead of with counts that look complete. This
/// is USAGE's own limit; PURGE's scan limit is separate and unchanged.
const MAX_SCAN: usize = 5_000_000;
pub const PARAMS: &str = "{type?: layers|blocks|textstyles|linetypes|all (default all), limit? (default 200), offset?} → \
{schema: 1, count (all items of the type), returned, offset, truncated, incomplete (true when the drawing has more \
objects than one scan reads, so every count, status and summary is partial), incompleteReason? (why), \
items: [{type: layer|block|textStyle|linetype, name, entities (objects in model space and layouts that use it), \
blockEntities (objects in block definitions that use it), references (named objects that use it: a layer's linetype, \
a text style's dimension and multileader styles), used (for a block: reachable from model space, a layout or a dimension), \
insideBlocksOnly (used, but by block definitions only), empty (nothing refers to it), unused (PURGE would offer it), \
current? (the current layer, text style or linetype)}], \
summary: {<kind>: {total, used, insideBlocksOnly, empty, unused}}, \
unused: {<kind>: {count, returned, truncated, names}} (PURGE's scan, in table order)}";
/// The four kinds of named object USAGE reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Category {
Layer,
Block,
TextStyle,
Linetype,
}
impl Category {
const ALL: [Category; 4] = [Category::Layer, Category::Block, Category::TextStyle, Category::Linetype];
/// The singular JSON `type` value of this kind (`layer`, `block`, `textStyle`, `linetype`).
fn key(self) -> &'static str {
match self {
Category::Layer => "layer",
Category::Block => "block",
Category::TextStyle => "textStyle",
Category::Linetype => "linetype",
}
}
/// The JSON key under which this kind's objects are grouped (`layers`, `blocks`,
/// `textStyles`, `linetypes`).
fn plural(self) -> &'static str {
match self {
Category::Layer => "layers",
Category::Block => "blocks",
Category::TextStyle => "textStyles",
Category::Linetype => "linetypes",
}
}
/// The capitalised plural noun used in the human-readable message.
fn label(self) -> &'static str {
match self {
Category::Layer => "Layers",
Category::Block => "Blocks",
Category::TextStyle => "Text styles",
Category::Linetype => "Linetypes",
}
}
/// The PURGE kind whose scan answers whether this kind's objects are unused.
fn purge(self) -> purge::Kind {
match self {
Category::Layer => purge::Kind::Layers,
Category::Block => purge::Kind::Blocks,
Category::TextStyle => purge::Kind::TextStyles,
Category::Linetype => purge::Kind::Linetypes,
}
}
/// `None` for an unknown name, `Some(None)` for all kinds.
fn parse(s: &str) -> Option<Option<Category>> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "all" => Some(None),
"layer" | "layers" => Some(Some(Category::Layer)),
"block" | "blocks" => Some(Some(Category::Block)),
"style" | "styles" | "textstyle" | "textstyles" | "text_style" | "text_styles" => Some(Some(Category::TextStyle)),
"linetype" | "linetypes" | "ltype" | "ltypes" => Some(Some(Category::Linetype)),
_ => None,
}
}
}
/// Objects in model space and the layouts (`false`), and in block definitions (`true`), up to
/// `limit` — the USAGE scan limit, past which later objects are not read.
fn each_entity(d: &Drawing, limit: usize) -> impl Iterator<Item = (&Entity, bool)> {
let top = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).map(|e| (&**e, false));
let blocks = d.blocks.values().flat_map(|b| b.entities.iter()).map(|e| (&**e, true));
top.chain(blocks).take(limit)
}
/// Objects in model space, the layouts and the block definitions — everything one scan reads.
fn entity_total(d: &Drawing) -> usize {
d.blocks.values().fold(d.entity_count(), |n, b| n.saturating_add(b.entities.len()))
}
/// A scan that stopped at its limit: the drawing holds `total` objects but only the first
/// `scanned` were read, so objects after the limit are missing from the counts, verdicts and
/// summary. Present in the report as `incomplete`/`incompleteReason`, never silently.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Incomplete {
scanned: usize,
total: usize,
}
impl Incomplete {
/// Why the report is partial, naming both counts so a caller can judge it.
fn reason(self) -> String {
format!(
"USAGE read the first {} of {} objects (model space, layouts and block definitions); \
references after the limit are missing, so counts, statuses and summaries are partial",
self.scanned, self.total
)
}
}
/// Uses per lower-case name: (in model space and layouts, in block definitions).
type Uses = HashMap<String, (usize, usize)>;
/// Counts one use of a named object, separately for model space and the layouts (`in_block`
/// false) and for block definitions (`in_block` true); names compare case-insensitively.
fn bump(map: &mut Uses, name: &str, in_block: bool) {
let slot = map.entry(name.to_ascii_lowercase()).or_default();
if in_block {
slot.1 = slot.1.saturating_add(1);
} else {
slot.0 = slot.0.saturating_add(1);
}
}
/// Counts one named table entry that refers to `name`: a layer's linetype, or a dimension or
/// multileader style's text style. Names compare case-insensitively.
fn bump_reference(map: &mut HashMap<String, usize>, name: &str) {
let slot = map.entry(name.to_ascii_lowercase()).or_default();
*slot = slot.saturating_add(1);
}
/// `ByLayer` and `ByBlock` name a value, not the linetype table's entries.
fn linetype_value(name: &str) -> bool {
name.eq_ignore_ascii_case("ByLayer") || name.eq_ignore_ascii_case("ByBlock")
}
/// Every use of the drawing's layers, blocks, text styles and linetypes, in one pass over the
/// objects (plus the block reachability PURGE already computes).
#[derive(Default)]
struct Scan {
layers: Uses,
blocks: Uses,
styles: Uses,
linetypes: Uses,
/// Dimension and multileader styles that name a text style.
style_refs: HashMap<String, usize>,
/// Layers that name a linetype.
linetype_refs: HashMap<String, usize>,
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
used_blocks: HashSet<String>,
/// Set when the scan stopped at its limit, so every count and verdict is partial.
incomplete: Option<Incomplete>,
}
impl Scan {
/// Scans the drawing once, reading at most `limit` objects: counts each entity's layer,
/// explicit linetype, block, text style and attribute style, plus the table references, and
/// borrows block reachability from PURGE. Records whether the limit cut the drawing short.
fn new(d: &Drawing, limit: usize) -> Scan {
let total = entity_total(d);
let scanned = total.min(limit);
let mut sc = Scan { used_blocks: purge::used_blocks(d), ..Scan::default() };
sc.incomplete = (scanned < total).then_some(Incomplete { scanned, total });
for (e, in_block) in each_entity(d, scanned) {
bump(&mut sc.layers, &e.common.layer, in_block);
if !linetype_value(&e.common.linetype) {
bump(&mut sc.linetypes, &e.common.linetype, in_block);
}
match &e.kind {
EntityKind::Insert(i) => {
bump(&mut sc.blocks, &i.block, in_block);
for a in &i.attribs {
bump(&mut sc.styles, &a.text.style, in_block);
}
}
EntityKind::Dimension(dm) => {
if let Some(b) = &dm.block {
bump(&mut sc.blocks, b, in_block);
}
}
EntityKind::MLeader(m) => {
if let Some(b) = &m.block {
bump(&mut sc.blocks, &b.block, in_block);
}
if let Some(t) = &m.text {
bump(&mut sc.styles, &t.style, in_block);
}
}
EntityKind::Text(t) => bump(&mut sc.styles, &t.style, in_block),
EntityKind::MText(t) => bump(&mut sc.styles, &t.style, in_block),
EntityKind::AttDef(a) => bump(&mut sc.styles, &a.text.style, in_block),
_ => {}
}
}
for st in &d.dim_styles {
bump_reference(&mut sc.style_refs, &st.text_style);
}
for st in &d.mleader_styles {
bump_reference(&mut sc.style_refs, &st.text_style);
}
for l in &d.layers {
if !linetype_value(&l.linetype) {
bump_reference(&mut sc.linetype_refs, &l.linetype);
}
}
sc
}
}
/// PURGE's unused lists, kept once so each item can say whether PURGE would offer it.
struct Unused {
lists: Vec<(Category, Vec<String>, HashSet<String>)>,
}
impl Unused {
/// Runs PURGE's unused scan for all four kinds, keeping each list in table order plus a
/// lower-case set for fast membership checks.
fn new(d: &Drawing) -> Unused {
let lists = Category::ALL
.into_iter()
.map(|c| {
let names = purge::candidates(d, c.purge());
let lower = names.iter().map(|n| n.to_ascii_lowercase()).collect();
(c, names, lower)
})
.collect();
Unused { lists }
}
/// Whether PURGE would offer `name` of kind `c` as unused (case-insensitive).
fn has(&self, c: Category, name: &str) -> bool {
self.lists.iter().find(|(k, ..)| *k == c).is_some_and(|(_, _, lower)| lower.contains(&name.to_ascii_lowercase()))
}
/// How many objects of kind `c` PURGE would offer — the full count, even when the name list
/// in the JSON output is truncated.
fn count(&self, c: Category) -> usize {
self.lists.iter().find(|(k, ..)| *k == c).map_or(0, |(_, names, _)| names.len())
}
/// The `unused` member: per kind, the total count, the returned page of names capped at
/// `MAX_NAMES`, and whether that page is truncated.
fn json(&self) -> Value {
let mut out = serde_json::Map::new();
for (c, names, _) in &self.lists {
let page: Vec<&String> = names.iter().take(MAX_NAMES).collect();
out.insert(
c.plural().into(),
json!({
"count": names.len(),
"returned": page.len(),
"truncated": page.len() < names.len(),
"names": page,
}),
);
}
Value::Object(out)
}
}
/// One report item: the use counts, the caller's `used`/`empty` verdicts, `insideBlocksOnly`
/// derived from those counts and PURGE's `unused` answer. `current` is emitted only for the kinds
/// that have a current entry (layers, text styles and linetypes).
fn item_json(
c: Category,
name: &str,
entities: usize,
block_entities: usize,
references: usize,
used: bool,
empty: bool,
current: Option<bool>,
unused: &Unused,
) -> Value {
let mut v = json!({
"type": c.key(),
"name": name,
"entities": entities,
"blockEntities": block_entities,
"references": references,
"used": used,
"insideBlocksOnly": used && entities == 0 && block_entities > 0 && references == 0,
"empty": empty,
"unused": unused.has(c, name),
});
if let (Some(cur), Some(o)) = (current, v.as_object_mut()) {
o.insert("current".into(), json!(cur));
}
v
}
/// Builds one item per named object in table order — layers, blocks, text styles, linetypes —
/// reading the scanned use counts and adding the current-object flag where the drawing has one.
fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
let mut out = Vec::new();
let current_layer = d.header.str("CLAYER", "0");
for l in &d.layers {
let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default();
let uses = e.saturating_add(b);
let cur = l.name.eq_ignore_ascii_case(&current_layer);
out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused));
}
for b in d.blocks.values() {
let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default();
// A definition is used when a reference to it is reachable, as PURGE counts it.
let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase());
out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused));
}
let current_style = d.header.str("TEXTSTYLE", "Standard");
for t in &d.text_styles {
let (e, b) = scan.styles.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_style);
out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
}
let current_linetype = d.header.str("CELTYPE", "ByLayer");
for t in &d.linetypes {
let (e, b) = scan.linetypes.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_linetype);
out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
}
out
}
/// Totals per kind over every item — total, used, inside blocks only, empty — plus how many
/// objects of that kind PURGE would offer.
fn summary(items: &[Value], unused: &Unused) -> Value {
let mut out = serde_json::Map::new();
for c in Category::ALL {
let mut total = 0usize;
let mut used = 0usize;
let mut inside = 0usize;
let mut empty = 0usize;
for it in items.iter().filter(|i| i["type"] == c.key()) {
total = total.saturating_add(1);
used = used.saturating_add(usize::from(it["used"].as_bool() == Some(true)));
inside = inside.saturating_add(usize::from(it["insideBlocksOnly"].as_bool() == Some(true)));
empty = empty.saturating_add(usize::from(it["empty"].as_bool() == Some(true)));
}
out.insert(c.plural().into(), json!({ "total": total, "used": used, "insideBlocksOnly": inside, "empty": empty, "unused": unused.count(c) }));
}
Value::Object(out)
}
/// The human-readable summary, one line per kind, plus a note when the filter matched no named
/// objects at all and a note when the scan was cut short.
fn message(items: &[&Value], summary: &Value, incomplete: Option<Incomplete>) -> String {
let mut lines = Vec::new();
for c in Category::ALL {
let s = &summary[c.plural()];
lines.push(format!(
"{}: {} total, {} used, {} inside blocks only, {} empty, {} unused.",
c.label(),
s["total"].as_u64().unwrap_or(0),
s["used"].as_u64().unwrap_or(0),
s["insideBlocksOnly"].as_u64().unwrap_or(0),
s["empty"].as_u64().unwrap_or(0),
s["unused"].as_u64().unwrap_or(0),
));
}
if items.is_empty() {
lines.push("There are no named objects of this type.".into());
}
if let Some(inc) = incomplete {
lines.push(format!("Scan incomplete: read the first {} of {} objects; the counts above are partial.", inc.scanned, inc.total));
}
lines.join("\n")
}
/// Runs `USAGE`: validates `type`, `limit` and `offset` (rejecting bad input without touching the
/// drawing), scans read-only, and returns the paged items with the per-kind summary, PURGE's
/// unused lists and the message.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let ty = match given("type") {
None => None,
Some(Value::String(t)) => {
Category::parse(t).ok_or_else(|| bad(CMD, format!("`type` must be layers, blocks, textstyles, linetypes or all, got \"{t}\"")))?
}
Some(v) => return Err(bad(CMD, format!("`type` must be a string, got {v}"))),
};
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let d = &st.doc;
let scan = Scan::new(d, MAX_SCAN);
let unused = Unused::new(d);
let all = items(&scan, &unused, d);
let matched: Vec<&Value> = all.iter().filter(|i| ty.is_none_or(|c| i["type"] == c.key())).collect();
let total = matched.len();
let page: Vec<&Value> = matched.iter().skip(offset).take(limit).copied().collect();
let summary = summary(&all, &unused);
let mut out = json!({
"schema": 1,
"count": total,
"returned": page.len(),
"offset": offset,
"truncated": page.len() < total.saturating_sub(offset),
"incomplete": scan.incomplete.is_some(),
"items": page,
"summary": summary,
"unused": unused.json(),
"message": message(&matched, &summary, scan.incomplete),
});
if let (Some(inc), Some(o)) = (scan.incomplete, out.as_object_mut()) {
o.insert("incompleteReason".into(), json!(inc.reason()));
}
Ok(out)
}
#[cfg(test)]
#[path = "usage_tests.rs"]
mod tests;
@@ -0,0 +1,59 @@
//! Unit tests for USAGE's scan limit (#1439): a scan cut at its limit must say so. A drawing with
//! more than 5,000,000 objects is impractical to build in a test, so the limit is a parameter and
//! these tests cut a tiny drawing instead.
use std::sync::Arc;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Line};
use cadcraft_geom::Vec3;
use super::{Incomplete, Scan, entity_total};
/// A LINE on `layer`, one unit long.
fn line(handle: u64, layer: &str) -> Entity {
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }));
e.common = Common { layer: layer.into(), ..Common::default() };
e
}
/// A drawing with `top` lines on layer Used in model space and `held` lines on layer Held in the
/// block HELD, so the block's objects come last in the scan, exactly as they do in a real drawing.
fn drawing(top: usize, held: usize) -> Drawing {
let mut d = Drawing::new_metric();
for i in 0..top {
d.model.push(line(0x100 + i as u64, "Used"));
}
let mut b = Block::new("HELD");
for i in 0..held {
b.entities.push(line(0x200 + i as u64, "Held"));
}
d.blocks.insert("HELD".into(), Arc::new(b));
d
}
/// Guards the detector's boundary: a drawing holding exactly the limit is read whole and is not
/// reported as cut short, so `incomplete` never cries wolf on the last readable object.
#[test]
fn a_drawing_at_the_limit_is_complete() {
let d = drawing(2, 1);
assert_eq!(entity_total(&d), 3);
let sc = Scan::new(&d, 3);
assert_eq!(sc.incomplete, None);
assert_eq!(sc.layers.get("used").copied(), Some((2, 0)));
assert_eq!(sc.layers.get("held").copied(), Some((0, 1)));
}
/// Guards that a scan stopped one object early reports what it omitted and why: HELD's line is
/// past the limit, so its layer looks unused — the miscount the `incomplete` signal exists to
/// prevent a caller from trusting.
#[test]
fn a_cut_scan_reports_the_objects_it_omitted() {
let d = drawing(2, 1);
let sc = Scan::new(&d, 2);
let inc = sc.incomplete.expect("a cut scan must report it");
assert_eq!(inc, Incomplete { scanned: 2, total: 3 });
assert_eq!(sc.layers.get("held").copied(), None);
let reason = inc.reason();
assert!(reason.contains("first 2 of 3 objects"), "{reason}");
assert!(reason.contains("partial"), "{reason}");
}
+13 -1
View File
@@ -270,9 +270,13 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
let from = layout_name("layout.rename", s.doc()?, &from)?;
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
let to = check_name("layout.rename", s.doc()?, to, Some(&from))?;
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == from) {
let d = s.doc_mut()?;
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == from) {
l.name = to.clone();
}
if d.active_layout.as_ref().is_some_and(|a| a.eq_ignore_ascii_case(&from)) {
d.active_layout = Some(to.clone());
}
let (old, new) = (Space::Paper(from.clone()), Space::Paper(to.clone()));
let st = s.state_mut()?;
for (sp, _) in st.views.iter_mut() {
@@ -928,6 +932,14 @@ mod tests {
assert!(s.doc().unwrap().layout("Layout3").is_none());
}
#[test]
fn rename_keeps_the_active_layout_whatever_its_case() {
let mut s = session_with_model();
s.doc_mut().unwrap().active_layout = Some("layout1".into());
s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap();
assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A"));
}
#[test]
fn rename_copy_delete() {
let mut s = session_with_model();
+2 -2
View File
@@ -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).base(*a),
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*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).base(*a),
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
}
}
+20 -3
View File
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("appload", "Load Application...", run_appload)
.menu(&["Tools", "Load Application..."])
.alias(&["ap"])
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, commands: [commands the file defined], error?}")
.noundo()
.interactive(|_| Ok(Box::new(ApploadM))),
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
@@ -23,6 +23,11 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Whether `path` names an AutoLISP routine: APPLOAD loads `.lsp` files only, in any case.
fn is_lisp_path(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("lsp"))
}
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
fn source(p: &Value) -> Result<(String, String)> {
if let Some(text) = str_param(p, "text") {
@@ -32,7 +37,14 @@ fn source(p: &Value) -> Result<(String, String)> {
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
}
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
// The path is read as it is given (a JSON caller may name a file or folder with spaces);
// only a path of blanks alone is refused.
let path = str_param(p, "path")
.filter(|p| !p.trim().is_empty())
.ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
if !is_lisp_path(path) {
return Err(bad("appload", format!("{path}: not an AutoLISP file (APPLOAD loads .lsp files)")));
}
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
Ok((path.to_string(), text))
}
@@ -49,9 +61,14 @@ fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
let (name, text) = source(p)?;
let before = s.lisp_commands();
let from = s.log.len();
let r = s.lisp_load(&name, &text, true)?;
Ok(report(s, from, r))
let mut out = report(s, from, r);
// The commands the file defined, so a caller can run them next (a failed file may still
// have defined some before the error).
out["commands"] = json!(s.lisp_commands().into_iter().filter(|c| !before.contains(c)).collect::<Vec<_>>());
Ok(out)
}
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
+66 -50
View File
@@ -310,6 +310,67 @@ fn segment_dir(m: &MLeader) -> Option<Vec2> {
(v.len() > 1e-12).then(|| v.normalized())
}
fn align_parallel(items: &[(Handle, MLeader)], reference: Option<&MLeader>) -> Result<Vec<(Handle, Vec2)>> {
let rdir = reference.and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
let mut moves = Vec::new();
for (h, m) in items {
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
let len = (m.landing.xy() - arrow.xy()).len();
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
}
Ok(moves)
}
fn align_distribute(mut items: Vec<(Handle, MLeader)>, line: Option<(Vec2, Vec2)>) -> Result<Vec<(Handle, Vec2)>> {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the two points coincide"));
}
let u = d.normalized();
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
let n = items.len();
let mut moves = Vec::new();
for (i, (h, m)) in items.iter().enumerate() {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
moves.push((*h, a + d * f - m.landing.xy()));
}
Ok(moves)
}
fn align_directional(
items: &[(Handle, MLeader)],
reference: Option<&MLeader>,
line: Option<(Vec2, Vec2)>,
spacing: Spacing,
) -> Result<Vec<(Handle, Vec2)>> {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the direction is zero"));
}
let u = d.normalized();
let origin = reference.map(|r| r.landing.xy()).unwrap_or(a);
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
let mut moves = Vec::new();
if let Spacing::Value(sp) = spacing {
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
for (k, (h, m)) in pos.iter().enumerate() {
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
}
for (k, (h, m)) in neg.iter().enumerate() {
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
}
} else {
for (h, m) in items {
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
}
}
Ok(moves)
}
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
let mut items: Vec<(Handle, MLeader)> = Vec::new();
for h in hs {
@@ -331,56 +392,11 @@ fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, l
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
};
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
match spacing {
Spacing::Parallel => {
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
for (h, m) in &items {
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
let len = (m.landing.xy() - arrow.xy()).len();
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
}
}
Spacing::Distribute => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the two points coincide"));
}
let u = d.normalized();
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
let n = items.len();
for (i, (h, m)) in items.iter().enumerate() {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
moves.push((*h, a + d * f - m.landing.xy()));
}
}
Spacing::Current | Spacing::Value(_) => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the direction is zero"));
}
let u = d.normalized();
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
if let Spacing::Value(sp) = spacing {
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
for (k, (h, m)) in pos.iter().enumerate() {
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
}
for (k, (h, m)) in neg.iter().enumerate() {
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
}
} else {
for (h, m) in &items {
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
}
}
}
}
let moves = match spacing {
Spacing::Parallel => align_parallel(&items, reference.as_ref())?,
Spacing::Distribute => align_distribute(items, line)?,
Spacing::Current | Spacing::Value(_) => align_directional(&items, reference.as_ref(), line, spacing)?,
};
move_content(s, &moves)?;
Ok(moves.len())
}
@@ -120,3 +120,88 @@ fn hostile_mleader_edit_params_never_panic() {
}
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
}
mod units {
use super::super::{Spacing, align_directional, align_distribute, align_parallel};
use cadcraft_doc::{Handle, MLeader};
use cadcraft_geom::{Vec2, Vec3};
fn unit_ml(arrow: [f64; 2], land: [f64; 2]) -> MLeader {
MLeader {
leaders: vec![vec![Vec3::new(arrow[0], arrow[1], 0.0)]],
landing: Vec3::new(land[0], land[1], 0.0),
dogleg: 0.0,
text: None,
style: String::new(),
arrow_size: 1.0,
spline: false,
block: None,
}
}
fn no_leader(land: [f64; 2]) -> MLeader {
MLeader {
leaders: vec![],
landing: Vec3::new(land[0], land[1], 0.0),
dogleg: 0.0,
text: None,
style: String::new(),
arrow_size: 1.0,
spline: false,
block: None,
}
}
/// A five-unit-long leader whose direction differs from the reference's `+y`: moving the
/// arrow onto `y` plus `5` (a five-unit parallel push) and back along the landing gives
/// `(-3, 1)`.
#[test]
fn parallel_requires_a_reference_and_skips_leaders() {
assert!(align_parallel(&[(Handle(1), unit_ml([0.0, 0.0], [3.0, 4.0]))], None).is_err());
let d = align_parallel(
&[(Handle(2), no_leader([1.0, 1.0])), (Handle(3), unit_ml([0.0, 0.0], [3.0, 4.0]))],
Some(&unit_ml([0.0, 0.0], [0.0, 4.0])),
)
.unwrap();
assert_eq!(d.len(), 1, "a multileader without a leader line is skipped");
assert_eq!(d[0].0, Handle(3));
assert!((d[0].1.x + 3.0).abs() < 1e-9);
assert!((d[0].1.y - 1.0).abs() < 1e-9);
}
#[test]
fn distribute_orders_landings_along_the_line() {
let mk = |h: u64, land: [f64; 2]| (Handle(h), unit_ml([0.0, 0.0], land));
assert!(align_distribute(vec![mk(1, [2.0, 0.0])], None).is_err(), "needs two points");
assert!(align_distribute(Vec::new(), Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0)))).is_err(), "coinciding points give no direction");
let items = vec![mk(1, [2.0, 0.0]), mk(2, [0.0, 0.0])];
let d = align_distribute(items, Some((Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0)))).unwrap();
assert_eq!(d[0].0, Handle(2), "sorted by projection onto the line");
assert!((d[0].1.x - 0.0).abs() < 1e-9 && (d[0].1.y - 0.0).abs() < 1e-9, "first fraction is 0");
assert_eq!(d[1].0, Handle(1));
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 0.0).abs() < 1e-9, "last fraction is 1");
}
#[test]
fn directional_projects_landings_and_supports_spacing() {
let line = Some((Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)));
assert!(align_directional(&[], None, None, Spacing::Current).is_err(), "needs a direction");
assert!(
align_directional(&[], None, Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0))), Spacing::Current).is_err(),
"a zero direction is an error"
);
// Without a reference the origin is the first point: the landing is projected onto it.
let d = align_directional(&[(Handle(7), unit_ml([0.0, 0.0], [1.0, 5.0]))], None, line, Spacing::Current).unwrap();
assert_eq!(d.len(), 1);
assert!((d[0].1.x - 0.0).abs() < 1e-9);
assert!((d[0].1.y + 5.0).abs() < 1e-9);
// With a reference as origin, given spacing counts outward on both sides of it.
let items = vec![(Handle(1), unit_ml([0.0, 0.0], [4.0, 0.0])), (Handle(2), unit_ml([0.0, 0.0], [-4.0, 0.0]))];
let d = align_directional(&items, Some(&unit_ml([0.0, 0.0], [0.0, 4.0])), line, Spacing::Value(2.0)).unwrap();
assert_eq!(d.len(), 2);
assert_eq!(d[0].0, Handle(1));
assert!((d[0].1.x + 2.0).abs() < 1e-9 && (d[0].1.y - 4.0).abs() < 1e-9);
assert_eq!(d[1].0, Handle(2));
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 4.0).abs() < 1e-9);
}
}
+33 -4
View File
@@ -219,6 +219,22 @@ impl StretchArea {
}
}
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
/// selection tests the drawn element lines, which sit away from the control vertices by the
/// multiline's scale, so a small area around a vertex alone would miss them.
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
let Some(store) = d.space(space) else { return };
for e in store.iter() {
if let EntityKind::MLine(m) = &e.kind
&& d.is_visible(e)
&& !hs.contains(&e.handle)
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
{
hs.push(e.handle);
}
}
}
/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).
fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));
@@ -240,6 +256,7 @@ fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
}
}
}
EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)),
EntityKind::Arc(a) => {
// Arcs keep their shape: move when the centre is inside.
if inside(a.center.xy()) {
@@ -287,6 +304,7 @@ fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let bx = cadcraft_geom::Bounds2::new(*a, *b);
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
};
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
hs.retain(|h| !super::curves::is_locked(s, *h));
let doc = s.doc_mut()?;
let areas = [area];
@@ -353,7 +371,12 @@ impl StretchM {
/// of two) the mode is dropped.
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
let space = s.space();
let Some(hs) = m.select(s.doc()?, &space) else { return Ok(()) };
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
(SelectKind::Box, Some((a, b))) => b.x < a.x,
_ => false,
};
let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
(SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),
(SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {
@@ -363,6 +386,9 @@ impl StretchM {
_ => None,
};
let whole = area.is_none();
if let (Some(a), true, false) = (&area, crossing, self.removing) {
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
}
if let (Some(a), false) = (area, self.removing) {
self.windows.push(a);
}
@@ -400,8 +426,11 @@ impl StretchM {
Some(c) => {
let bx = cadcraft_geom::Bounds2::new(c, p);
let space = s.space();
let hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
if !self.removing {
if p.x < c.x {
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
}
self.windows.push(StretchArea::Box(bx));
}
self.add(s, hs, false);
@@ -488,7 +517,7 @@ impl Interactive for StretchM {
return m.prompt();
}
match (self.phase, self.corner) {
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(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"),
@@ -1403,7 +1432,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
attach: 5,
rotation: g.text_angle,
style: style.text_style.clone(),
contents: g.value.clone(),
contents: g.mtext_contents(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
+1
View File
@@ -52,6 +52,7 @@ pub const PROFILE_VARS: &[&str] = &[
"PICKADD",
"PICKDRAG",
"MAXARRAY",
"MEASUREINIT",
];
/// The most profile entries [`restore`] reads (a saved profile is untrusted input).
+1 -1
View File
@@ -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).base(a),
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
+1 -1
View File
@@ -73,7 +73,7 @@ struct Parser<'a> {
impl Parser<'_> {
fn peek(&mut self) -> Option<u8> {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
self.i += 1;
}
self.s.get(self.i).copied()
+1 -1
View File
@@ -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).base(a),
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(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" };
+1 -1
View File
@@ -2,7 +2,7 @@ use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Arc, Vec2};
use serde_json::{Value, json};
use super::*;
use super::{GripMode, stretch_grip};
use crate::Session;
fn h(r: &Value) -> Handle {
+1 -1
View File
@@ -23,7 +23,7 @@ pub(crate) struct Guarded {
}
pub(crate) fn message(p: &(dyn Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
impl Guarded {
+11 -4
View File
@@ -359,6 +359,9 @@ pub struct Settings {
pub gridmajor: u32,
/// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100).
pub zoomfactor: u32,
/// MEASUREINIT: the units of a new drawing made without a template (NEW, QNEW and the first
/// drawing at startup): `false` (0, the default) imperial, `true` (1) metric.
pub measureinit: bool,
/// -VIEW sEttings > Layer snapshot: named views save the layer settings with them.
pub view_layer_snapshot: bool,
pub dynmode: bool,
@@ -477,6 +480,7 @@ impl Default for Settings {
gridunit: Vec2::new(0.5, 0.5),
gridmajor: 5,
zoomfactor: 60,
measureinit: false,
view_layer_snapshot: true,
dynmode: true,
dyndigrip: 31,
@@ -837,7 +841,7 @@ impl Session {
}
/// The space commands act on (model space while working inside a layout viewport).
pub fn space(&self) -> Space {
self.state().map(|s| s.edit_space()).unwrap_or_default()
self.state().map(DocState::edit_space).unwrap_or_default()
}
/// The tab being displayed: Model or a layout, regardless of MSPACE.
pub fn layout_space(&self) -> Space {
@@ -899,7 +903,7 @@ impl Session {
/// World units per pixel at the current view.
pub fn pixel_size(&self) -> f64 {
let v = self.state().map(|s| s.view()).unwrap_or_default();
let v = self.state().map(DocState::view).unwrap_or_default();
v.height / self.viewport_px.1.max(1.0)
}
@@ -919,8 +923,11 @@ impl Session {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
let msg = p
.downcast_ref::<&str>()
.map(ToString::to_string)
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
// Restore the drawing as it was.
if let Some((doc, sel, uid)) = &before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == *uid)
+8 -6
View File
@@ -5,6 +5,8 @@
//! Errors use AutoLISP's wording (`bad argument type: numberp: nil`, `too few arguments`,
//! `divide by zero`), since routines and their `*error*` handlers match on it.
use std::cmp::Ordering;
use std::ffi::OsStr;
use std::sync::Arc;
use super::eval::{Ctx, Flow, Special};
@@ -335,16 +337,16 @@ fn num_ne(a: &[Value]) -> R {
eq_chain(a, false)
}
fn lt(a: &[Value]) -> R {
chain(a, |o| o.is_lt())
chain(a, Ordering::is_lt)
}
fn gt(a: &[Value]) -> R {
chain(a, |o| o.is_gt())
chain(a, Ordering::is_gt)
}
fn le(a: &[Value]) -> R {
chain(a, |o| o.is_le())
chain(a, Ordering::is_le)
}
fn ge(a: &[Value]) -> R {
chain(a, |o| o.is_ge())
chain(a, Ordering::is_ge)
}
fn zerop(a: &[Value]) -> R {
@@ -658,12 +660,12 @@ fn vl_list_to_string(a: &[Value]) -> R {
fn vl_filename_base(a: &[Value]) -> R {
arity(a, 1, 1)?;
let s = string(&a[0])?;
Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(|x| x.to_str()).unwrap_or("")))
Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(OsStr::to_str).unwrap_or("")))
}
fn vl_filename_extension(a: &[Value]) -> R {
arity(a, 1, 1)?;
let s = string(&a[0])?;
Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(|x| x.to_str()).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default())
Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(OsStr::to_str).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default())
}
fn vl_filename_directory(a: &[Value]) -> R {
arity(a, 1, 1)?;
+16 -2
View File
@@ -577,6 +577,20 @@ impl Session {
self.lisp.active
}
/// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted.
pub fn lisp_commands(&self) -> Vec<String> {
let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() };
let Some(env) = self.lisp.envs.get(&uid) else { return Vec::new() };
let mut names: Vec<String> = env
.globals
.iter()
.filter(|(name, f)| name.name().starts_with("C:") && matches!(f, Value::Func(_) | Value::Subr(_)))
.filter_map(|(name, _)| name.name().strip_prefix("C:").filter(|n| !n.is_empty()).map(str::to_string))
.collect();
names.sort();
names
}
/// A `c:` function for a command name typed at the command line.
pub(crate) fn lisp_command(&self, name: &str) -> Option<Value> {
if self.lisp.active {
@@ -610,7 +624,7 @@ impl Session {
self.lisp.files.insert(file_key(name), text.to_string());
let r = self.lisp_eval(text, false, blocking, Vec::new())?;
if !matches!(r, Some(Err(_))) {
let shown = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_string();
let shown = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name).to_string();
self.echo(format!("{shown} successfully loaded."));
}
Ok(r)
@@ -618,7 +632,7 @@ impl Session {
}
fn file_key(name: &str) -> String {
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_ascii_lowercase();
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name).to_ascii_lowercase();
base.strip_suffix(".lsp").map(str::to_string).unwrap_or(base)
}
+57
View File
@@ -542,6 +542,63 @@ fn appload_and_load() {
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
}
#[test]
fn appload_reads_a_lsp_path_and_reports_its_commands() {
let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tools.LSP");
std::fs::write(&path, "(defun c:hi () (princ \"hi\") (princ)) (defun c:bye () (princ))").unwrap();
let mut s = session();
let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap();
assert!(r.get("error").is_none(), "{r}");
assert_eq!(r["commands"], serde_json::json!(["BYE", "HI"]));
assert!(s.log.iter().any(|l| l == "Tools.LSP successfully loaded."));
s.cmdline("HI").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("hi"));
// Only `.lsp` files are APPLOADed, whatever the case of the extension.
let txt = dir.join("notes.txt");
std::fs::write(&txt, "(defun c:no () (princ))").unwrap();
let e = s.execute("appload", &serde_json::json!({"path": txt.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains(".lsp"), "{e}");
// A missing file and a file that fails to parse are ordinary errors.
let missing = dir.join("missing.lsp");
let e = s.execute("appload", &serde_json::json!({"path": missing.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("can't read"), "{e}");
let broken = dir.join("broken.lsp");
std::fs::write(&broken, "(defun c:oops (").unwrap();
let e = s.execute("appload", &serde_json::json!({"path": broken.to_string_lossy()})).unwrap_err().to_string();
assert!(!e.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn appload_keeps_the_whitespace_of_a_relative_path() {
/// A folder in the test's current directory, removed even when an assertion fails.
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// A JSON call's path is read as it is given: a relative path whose folder starts with a
// space names that folder, not the one its trimmed name would.
let dir = TempDir(std::path::PathBuf::from(format!(" cadcraft-appload-space-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&dir.0);
std::fs::create_dir_all(&dir.0).unwrap();
let path = dir.0.join("tools.lsp");
std::fs::write(&path, "(setq space-loaded 1)").unwrap();
let mut s = session();
let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap();
assert!(r.get("error").is_none(), "{r}");
assert_eq!(ok(&mut s, "space-loaded"), "1");
// A path of blanks only is still refused, not read as "".
for blank in ["", " ", "\t "] {
let e = s.execute("appload", &serde_json::json!({"path": blank})).unwrap_err().to_string();
assert!(e.contains("`path`"), "{blank:?}: {e}");
}
}
#[test]
fn hostile_lisp_never_panics() {
let mut s = session();
+1 -1
View File
@@ -101,7 +101,7 @@ impl Prompt {
self
}
pub fn kw(mut self, k: &[&str]) -> Self {
self.keywords = k.iter().map(|s| s.to_string()).collect();
self.keywords = k.iter().map(ToString::to_string).collect();
self
}
pub fn base(mut self, p: Vec2) -> Self {
+1 -1
View File
@@ -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).base(p),
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p),
Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p),
}
}
+3
View File
@@ -64,6 +64,7 @@ const SESSION_VARS: &[&str] = &[
"CURSORSIZE",
"MAXARRAY",
"ZOOMFACTOR",
"MEASUREINIT",
"LASTPOINT",
"FONTALT",
"FONTFALLBACK",
@@ -140,6 +141,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"CURSORSIZE" => json!(st.cursorsize),
"MAXARRAY" => json!(st.maxarray),
"ZOOMFACTOR" => json!(st.zoomfactor),
"MEASUREINIT" => json!(i32::from(st.measureinit)),
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
@@ -355,6 +357,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
"CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0),
"MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64,
"ZOOMFACTOR" => st.zoomfactor = as_i(v).filter(|z| (3..=100).contains(z)).ok_or_else(bad)? as u32,
"MEASUREINIT" => st.measureinit = small(v, 1).ok_or_else(bad)? == 1,
"FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?),
"FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?),
"SAVETIME" => crate::autosave::set_savetime(
+14 -1
View File
@@ -193,7 +193,13 @@ fn parse_mixed(s: &str) -> Option<f64> {
};
match parts.as_slice() {
[a] => frac(a),
[a, b] => Some(a.parse::<f64>().ok()? + frac(b)?),
[a, b] => {
let (n, d) = b.split_once('/')?;
if n.trim().starts_with(['-', '+']) || d.trim().starts_with(['-', '+']) {
return None;
}
Some(a.parse::<f64>().ok()? + frac(b)?)
}
_ => None,
}
}
@@ -350,6 +356,13 @@ pub const DEG: f64 = PI / 180.0;
mod tests {
use super::*;
#[test]
fn mixed_distance_needs_a_positive_fraction_after_the_whole_number() {
assert_eq!(parse_distance("6 1/2"), Some(6.5));
assert_eq!(parse_distance("5 5"), None);
assert_eq!(parse_distance("1 -1/2"), None);
}
#[test]
fn huge_architectural_and_fractional_distances_do_not_saturate() {
for lunits in [4, 5] {
+3 -1
View File
@@ -226,7 +226,9 @@ fn a_new_autosave_folder_is_owner_only() {
for (dir, expected) in [(base.join("new").join("autosave"), 0o700), (picked, 0o755)] {
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new(autosave::instance_id());
// Not instance_id(): a test started in the same millisecond would get the same id, and
// shutdown() would then remove its lock from the process-wide SAVEFILEPATH.
let mut a = Autosaver::new("41-c");
a.plan(&s, 0.0, 1, &dir.to_string_lossy());
let jobs = a.plan(&s, 60.0, 1, &dir.to_string_lossy());
let path = writes(&jobs).pop().unwrap();
+70
View File
@@ -0,0 +1,70 @@
//! #1645: a diameter dimension's text in its `*D` block, and in the MTEXT EXPLODE leaves, holds
//! the diameter sign as `%%c`, as CADCraft's own TEXT and MTEXT keep it. The decoded ⌀ (U+2300)
//! is in no SHX font, so other programs plotted the dimension in an SHX style ("romans.shx")
//! without its sign. CADCraft still draws and reads the sign.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use serde_json::{Value, json};
fn h(v: &Value) -> Handle {
v["handle"].as_str().and_then(Handle::parse_hex).unwrap()
}
/// The issue's drawing: an SHX text style, a dimension style using it, a diameter dimension
/// of a circle of radius 30.
fn session() -> (Session, Handle) {
let mut s = Session::new();
s.execute("units", &json!({"measurement": "metric"})).ok();
s.execute("style", &json!({"name": "R", "font": "romans.shx", "current": true})).unwrap();
s.execute("dimstyle", &json!({"name": "D", "current": true, "DIMTXSTY": "R", "DIMTXT": 5, "DIMASZ": 5, "DIMDEC": 0})).unwrap();
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 30})).unwrap());
let dim = h(&s.execute("dimdiameter", &json!({"handle": c.hex(), "at": [40, 40]})).unwrap());
(s, dim)
}
/// The MTEXT contents (group 1) inside each `*D…` block of a DXF.
fn dblock_texts(dxf: &str) -> Vec<String> {
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let mut out = Vec::new();
let (mut in_d, mut in_mtext) = (false, false);
for pair in lines.chunks(2) {
let [code, value] = pair else { continue };
match (*code, *value) {
("0", "BLOCK") => in_d = false,
("2", name) if !in_mtext && name.starts_with("*D") => in_d = true,
("0", "MTEXT") => in_mtext = in_d,
("0", _) => in_mtext = false,
("1", text) if in_mtext => out.push(text.to_string()),
_ => {}
}
}
out
}
#[test]
fn the_dimension_block_writes_the_sign_as_a_code() {
let (s, dim) = session();
let d = s.doc().unwrap();
let EntityKind::Dimension(dm) = &d.entity(dim).unwrap().kind else { panic!("not a dimension") };
assert!(matches!(dm.kind, cadcraft_engine::doc::DimKind::Diameter));
let texts = dblock_texts(&cadcraft_io::write_dxf(d));
assert_eq!(texts, vec!["%%c60".to_string()], "the *D block's MTEXT");
// Read back, CADCraft still shows the sign.
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(d).as_bytes()).unwrap();
let dm = read.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.clone()) } else { None }).unwrap();
let st = read.dim_styles.iter().find(|x| x.name.eq_ignore_ascii_case(&dm.style)).cloned().unwrap_or_default();
assert_eq!(cadcraft_render::dimension_geometry(&dm, &st, 1.0).value, "⌀60");
}
#[test]
fn exploding_keeps_the_sign_as_a_code() {
let (mut s, dim) = session();
s.execute("explode", &json!({"handles": [dim.hex()]})).unwrap();
let d = s.doc().unwrap();
let texts: Vec<String> =
d.model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect();
assert_eq!(texts, vec!["%%c60".to_string()]);
let saved = cadcraft_io::write_dxf(d);
assert!(!saved.contains('⌀'), "no U+2300 is saved");
}
+140
View File
@@ -0,0 +1,140 @@
//! #1651: the issue's repro through the commands. Every dimension CADCraft reports as
//! associated (the diameter, radius and angular ones, as well as the linear control) is saved
//! with a DIMASSOC object whose references name its geometry.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use serde_json::{Value, json};
/// The entity handle a command result reports, from its `handle` field.
fn handle(r: &Value) -> Handle {
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
}
/// The first line of a LINE result (`handles`).
fn first_line(r: &Value) -> Handle {
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
}
/// The DXF tag pairs, `(code, value)`.
fn pairs(text: &str) -> Vec<(i32, String)> {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
for chunk in lines.chunks(2) {
let [code, value] = chunk else { break };
let Ok(c) = code.trim().parse::<i32>() else { continue };
out.push((c, value.trim().to_string()));
}
out
}
/// The DIMASSOC object of a dimension, if the file has one.
fn dimassoc(text: &str, dim: Handle) -> Option<Vec<(i32, String)>> {
let hex = dim.hex();
let mut recs = Vec::new();
let mut cur: Option<Vec<(i32, String)>> = None;
for (c, v) in pairs(text) {
if c == 0 {
if let Some(r) = cur.take() {
recs.push(r);
}
cur = Some(vec![(c, v)]);
} else if let Some(r) = cur.as_mut() {
r.push((c, v));
}
}
if let Some(r) = cur {
recs.push(r);
}
recs.into_iter()
.filter(|r| r.first().is_some_and(|(c, v)| *c == 0 && v == "DIMASSOC"))
.find(|r| r.iter().rfind(|(c, _)| *c == 330).is_some_and(|(_, v)| v.eq_ignore_ascii_case(&hex)))
}
/// The `AcDbOsnapPointRef` blocks of a DIMASSOC record, in file order: one per reference the
/// dimension makes to its geometry.
fn point_refs(rec: &[(i32, String)]) -> Vec<Vec<(i32, String)>> {
let mut out = Vec::new();
let mut cur: Option<Vec<(i32, String)>> = None;
for (c, v) in rec {
if *c == 1 && v == "AcDbOsnapPointRef" {
if let Some(r) = cur.take() {
out.push(r);
}
cur = Some(Vec::new());
} else if let Some(r) = cur.as_mut() {
r.push((*c, v.clone()));
}
}
if let Some(r) = cur {
out.push(r);
}
out
}
/// The first integer value of a group code in a record (72 the osnap type, 90 the flags).
fn int(rec: &[(i32, String)], code: i32) -> Option<i64> {
rec.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.parse().ok())
}
/// The first string value of a group code in a record (331 the referenced entity, 332 the other
/// entity of an intersection).
fn string(rec: &[(i32, String)], code: i32) -> Option<&str> {
rec.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str())
}
/// #1651's repro through the commands: after DIMREASSOCIATE, every dimension the session calls
/// associated (diameter, radius, angular and a linear control) is written with a DIMASSOC whose
/// references name real geometry. Guards the saving side, where radial and diametric dimensions
/// used to fall through the kind-to-slot match and be written without any DIMASSOC object.
#[test]
fn the_issue_repro_saves_every_associated_dimension_with_dimassoc() {
let mut s = Session::new();
s.execute("dimstyle", &json!({ "name": "D", "current": true, "DIMTXT": 5, "DIMASZ": 5, "DIMDEC": 0 })).unwrap();
let circle = handle(&s.execute("circle", &json!({ "center": [0, 0], "radius": 30 })).unwrap());
let diameter = handle(&s.execute("dimdiameter", &json!({ "handle": circle.hex(), "at": [40, 40] })).unwrap());
let radius = handle(&s.execute("dimradius", &json!({ "handle": circle.hex(), "at": [-40, -40] })).unwrap());
let l1 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [200, 0]] })).unwrap());
let l2 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [180, 60]] })).unwrap());
let angular = handle(&s.execute("dimangular", &json!({ "lines": [l1.hex(), l2.hex()], "at": [160, 20] })).unwrap());
let l3 = first_line(&s.execute("line", &json!({ "points": [[0, -80], [100, -80]] })).unwrap());
let linear = handle(&s.execute("dimlinear", &json!({ "object": l3.hex(), "at": [50, -100] })).unwrap());
let dims = [diameter, radius, angular, linear];
{
let d = s.doc().unwrap();
for h in dims {
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Dimension(dm)) => assert!(!dm.assoc.is_empty(), "{} is associative in CADCraft", h.hex()),
_ => panic!("{} is not a dimension", h.hex()),
}
}
}
// The issue runs DIMREASSOCIATE before saving; it does not lose any of them.
let hs: Vec<String> = dims.iter().map(|h| h.hex()).collect();
let r = s.execute("dimreassociate", &json!({ "handles": hs })).unwrap();
assert_eq!(r["associated"], json!(4), "{r}");
let text = cadcraft_io::write_dxf(s.doc().unwrap());
for (h, what) in [(diameter, "diameter"), (radius, "radius"), (angular, "angular"), (linear, "linear")] {
let rec = dimassoc(&text, h).unwrap_or_else(|| panic!("{what} dimension {} has a DIMASSOC", h.hex()));
let refs = point_refs(&rec);
assert!(!refs.is_empty(), "{what}: the DIMASSOC has point references");
assert!(refs.iter().all(|r| string(r, 331).is_some()), "{what}: every reference names geometry");
}
// The radial and diametric dimensions name the circle by its centre.
for (h, what) in [(diameter, "diameter"), (radius, "radius")] {
let refs = point_refs(&dimassoc(&text, h).unwrap());
assert_eq!(refs.len(), 1, "{what}: one reference, as AutoCAD writes radial dimensions");
assert_eq!(int(&refs[0], 72), Some(3), "{what}: a centre snap");
assert!(string(&refs[0], 331).is_some_and(|h| h.eq_ignore_ascii_case(&circle.hex())), "{what}: to the circle");
}
// The angular dimension names both lines, and the linear one its line.
let refs = point_refs(&dimassoc(&text, angular).unwrap());
assert!(refs.len() >= 2, "angular: at least both leg points: {refs:?}");
let named = |r: &[(i32, String)], h: Handle| string(r, 331).is_some_and(|x| x.eq_ignore_ascii_case(&h.hex()));
assert!(refs.iter().any(|r| named(r, l1)) && refs.iter().any(|r| named(r, l2)), "angular: both lines");
let refs = point_refs(&dimassoc(&text, linear).unwrap());
assert_eq!(refs.len(), 2, "linear: both origins");
assert!(refs.iter().all(|r| named(r, l3)), "linear: its line");
}
+29
View File
@@ -162,3 +162,32 @@ fn hostile_nesting_is_bounded() {
assert!(r["blocks"]["LOOP"].as_u64().unwrap() >= 1, "{r}");
assert!(r["blocks"].get("L16").is_none(), "nesting stops at MAX_BLOCK_DEPTH: {r}");
}
/// #1440: COUNT says which objects it scanned, which it counted, and why the rest were skipped.
#[test]
fn self_check_says_what_count_covered() {
let mut s = plates();
put(&mut s, &Space::Model, circle(300.0, 1.0));
let r = s.execute("count", &json!({})).unwrap();
let sc = &r["selfCheck"];
assert_eq!(sc["schema"], 1);
assert_eq!(sc["found"], 4, "three block references and one circle: {r}");
assert_eq!(sc["analyzed"], 3);
assert_eq!(sc["skipped"]["Circle"], json!({ "count": 1, "reason": "not a block reference" }));
assert_eq!(sc["caps"], json!({}));
assert_eq!(sc["implausible"], json!([]));
// Counting the circles compares only circles, and says so.
let h = put(&mut s, &Space::Model, circle(310.0, 1.0));
let r = s.execute("count", &json!({"object": h.hex()})).unwrap();
let sc = &r["selfCheck"];
assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(5), json!(2)), "{r}");
assert_eq!(sc["skipped"]["Block Reference"]["reason"], "a different type");
// When nested expansion hits the work cap, the cap and its value are reported.
let mut s = Session::new();
define(&mut s, "LOOP", vec![insert("LOOP", 0.0, 1, 1)]);
put(&mut s, &Space::Model, insert("LOOP", 0.0, 1, 1));
let r: Value = s.execute("count", &json!({"space": "all"})).unwrap();
assert_eq!(r["selfCheck"]["caps"], json!({ "blockDepth": 16 }), "the infinite LOOP hits the depth cap: {r}");
}
+30
View File
@@ -42,6 +42,26 @@ fn gap_tolerance_closes_small_gaps() {
assert!(hatch(&s, &h).associative);
}
#[test]
fn lines_ending_on_an_ellipse_close_a_boundary() {
// #1690: the verticals end on the true ellipse, which the boundary tracer sees as chords
// that pass just below those ends.
let mut s = Session::new();
s.execute("ellipse", &json!({ "center": [0, 0], "major": [10, 0], "ratio": 0.5 })).unwrap();
for x in [1.0f64, 2.0] {
let y = 5.0 * (1.0 - x * x / 100.0).sqrt();
s.execute("line", &json!({ "points": [[x, 8.0], [x, y]] })).unwrap();
}
let top = s.execute("line", &json!({ "points": [[0, 8], [3, 8]] })).unwrap()["handles"][0].clone();
let h = handle(&s.execute("hatch", &json!({ "points": [[1.5, 7]] })).unwrap());
let hh = hatch(&s, &h);
assert_eq!(hh.loops.len(), 1);
assert_eq!(hh.loops[0].sources.len(), 4);
// The associative refill traces the same way: sliding the top edge keeps the hatch.
s.execute("move", &json!({ "handles": [top], "delta": [0.5, 0] })).unwrap();
assert!(hatch(&s, &h).associative);
}
#[test]
fn pattern_origin_options() {
let mut s = Session::new();
@@ -249,3 +269,13 @@ fn hatchedit_refuses_unknown_patterns_and_region_boundaries() {
// Region boundaries need REGION objects; the hatch is left as it was.
assert!(s.execute("hatchedit", &json!({ "handles": [h], "recreate": "region" })).is_err());
}
#[test]
fn multiline_wall_hatch_keeps_the_room_as_an_island() {
// A closed multiline wall: picking inside the wall hatches the band between its outer and
// inner lines; the room the inner lines enclose is an island, not filled.
let mut s = Session::new();
s.execute("mline", &json!({ "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true, "scale": 1, "justification": "zero" })).unwrap();
let h = handle(&s.execute("hatch", &json!({ "points": [[0, 5]], "pattern": "SOLID" })).unwrap());
assert_eq!(hatch(&s, &h).loops.len(), 2, "outer face + the room as an island");
}
+40
View File
@@ -29,12 +29,16 @@ fn rect_region(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
r
}
/// Add a circle and turn it into a region, returning the handle of the region's source circle.
fn circle_region(s: &mut Session, c: [f64; 2], r: f64) -> Handle {
let hd = h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap());
s.execute("region", &json!({"handles": [hd.hex()]})).unwrap();
hd
}
/// REGION turns closed rectangles and circles into regions and refuses an open line; AREA and
/// MASSPROP then report the expected area, perimeter, centroid and moments (a rectangle's and a
/// circle's integrals are known exactly).
#[test]
fn region_from_closed_objects_measures() {
let mut s = Session::new();
@@ -68,6 +72,42 @@ fn region_from_closed_objects_measures() {
assert!(s.execute("massprop", &json!({"handles": [line.hex()]})).is_err());
}
/// #1440: MASSPROP says what it measured and what it skipped, and carries a self-check.
#[test]
fn massprop_self_check_reports_skipped_objects() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
s.execute("line", &json!({"points": [[0, 10], [5, 10]]})).unwrap();
let line = s.doc().unwrap().model.iter().last().unwrap().handle;
let m = s.execute("massprop", &json!({"handles": [a.hex(), line.hex()]})).unwrap();
let sc = &m["selfCheck"];
assert_eq!(sc["schema"], 1);
assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(2), json!(1)), "{m}");
assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a region" }));
assert_eq!(sc["caps"], json!({}));
assert_eq!(sc["implausible"], json!([]));
}
/// Make `hd` invisible: it no longer counts towards the drawing extents.
fn hide(s: &mut Session, hd: Handle) {
s.doc_mut().unwrap().modify_entity(hd, |e| e.common.visible = false).unwrap();
}
/// #1440 review: with no visible extents (the only region is invisible) there is no drawing bound
/// to compare an area against, so it is not flagged as larger than the extents.
#[test]
fn massprop_does_not_flag_area_without_visible_extents() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
hide(&mut s, a);
assert!(s.doc().unwrap().extents(&Space::Model).is_empty());
let m = s.execute("massprop", &json!({"handles": [a.hex()]})).unwrap();
assert_eq!(m["regions"][0]["area"], json!(40.0));
assert_eq!(m["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {m}");
}
/// SUBTRACT cuts a hole where a region lies inside another, and UNION adds an island when a region
/// sits inside that hole; the loops, area and containment follow, and the removed region is gone.
#[test]
fn booleans_on_separate_and_nested_regions() {
// Subtract a circle inside a rectangle: a hole.
+135 -2
View File
@@ -2,18 +2,32 @@
//! closed shapes in a drawing (#1436).
use cadcraft_engine::Session;
use cadcraft_engine::doc::geom::{PolyVertex, Vec2};
use cadcraft_engine::doc::{Common, EntityKind, LwPolyline, Space};
use cadcraft_engine::doc::geom::{PolyVertex, Vec2, Vec3};
use cadcraft_engine::doc::{Common, EntityKind, Face3d, LwPolyline, Space};
use serde_json::{Value, json};
const EPS: f64 = 1e-9;
/// Add a polyline through `(x, y, bulge)` vertices to model space.
fn pline(s: &mut Session, pts: &[(f64, f64, f64)], closed: bool) {
let vertices = pts.iter().map(|&(x, y, b)| PolyVertex::with_bulge(Vec2::new(x, y), b)).collect();
let k = EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false });
s.doc_mut().unwrap().add(&Space::Model, Common::default(), k).unwrap();
}
/// Add a unit 3D face at height `z`; a face is not a closed 2D shape, so SHAPES must skip it.
fn face(s: &mut Session, z: f64) {
let corners = [[0.0, 0.0, z], [1.0, 0.0, z], [1.0, 1.0, z], [0.0, 1.0, z]].map(|c| Vec3::new(c[0], c[1], c[2]));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Face3d(Face3d { corners, hidden_edges: 0 })).unwrap();
}
/// The last model-space entity, made invisible so it lies outside the drawing extents.
fn hide_last(s: &mut Session) {
let h = s.doc().unwrap().model.iter().last().unwrap().handle;
s.doc_mut().unwrap().modify_entity(h, |e| e.common.visible = false).unwrap();
}
/// Add a closed 10 × 10 square polyline at the origin.
fn square(s: &mut Session) {
pline(s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], true);
}
@@ -75,6 +89,8 @@ fn bow_tie_reports_its_crossing() {
near(&x[1], 5.0);
}
/// Two coincident consecutive vertices are reported with their index and distance, while the
/// shape is still measured (the duplicate does not change its area).
#[test]
fn repeated_vertex_is_listed() {
let mut s = Session::new();
@@ -85,6 +101,8 @@ fn repeated_vertex_is_listed() {
near(&sh["area"], 100.0);
}
/// An open polyline whose last vertex repeats its first is treated as closed: one shape, its area
/// measured, and the coincident closing vertex not reported as a duplicate.
#[test]
fn open_polyline_with_matching_ends_is_closed() {
let mut s = Session::new();
@@ -95,6 +113,8 @@ fn open_polyline_with_matching_ends_is_closed() {
assert_eq!(r["shapes"][0]["duplicateVertices"], json!([]));
}
/// A circle is measured exactly, and objects that are not closed shapes (a line, a text, an open
/// polyline) are counted under `skipped` with their reasons instead of polluting the result.
#[test]
fn circle_and_skipped_entities() {
let mut s = Session::new();
@@ -117,6 +137,8 @@ fn circle_and_skipped_entities() {
assert_eq!(sk.values().filter(|v| v["reason"] == "not a closed shape").count(), 2);
}
/// Paging reports only the requested window of shapes, and a malformed option is an error rather
/// than silently falling back to a default.
#[test]
fn paging_and_bad_options() {
let mut s = Session::new();
@@ -131,3 +153,114 @@ fn paging_and_bad_options() {
assert!(s.execute("shapes", &json!({ "limit": "x" })).is_err());
assert!(s.execute("shapes", &json!({ "window": [1, 2] })).is_err());
}
/// #1440: an agent must be told what an analysis covered: found, analyzed and skipped, the caps
/// it hit, and values that cannot be right. The existing keys stay as they were.
#[test]
fn self_check_counts_found_analyzed_and_skipped() {
let mut s = Session::new();
square(&mut s);
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("text", &json!({"at": [0, 0], "text": "NOTE", "height": 0.25})).unwrap();
face(&mut s, 0.0);
let r = shapes(&mut s, json!({}));
// Add-only: the old keys and their meanings are unchanged.
assert_eq!((r["schema"].clone(), r["count"].clone(), r["returned"].clone()), (json!(1), json!(1), json!(1)));
for key in ["offset", "truncated", "shapes", "skipped"] {
assert!(!r[key].is_null(), "{key}: {r}");
}
let sc = &r["selfCheck"];
assert_eq!(sc["schema"], 1);
assert_eq!(sc["found"], 4);
assert_eq!(sc["analyzed"], 1);
assert_eq!(sc["skipped"]["3D Face"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"]["Text"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"], r["skipped"], "the self-check repeats the skip map it is built from");
assert_eq!(sc["caps"], json!({}));
assert_eq!(sc["implausible"], json!([]));
let analyzed = sc["analyzed"].as_u64().unwrap();
let skipped: u64 = sc["skipped"].as_object().unwrap().values().map(|v| v["count"].as_u64().unwrap()).sum();
assert_eq!(sc["found"].as_u64().unwrap(), analyzed + skipped);
}
/// #1440: a cap that stopped the analysis is reported with its value — the page `limit` when it
/// truncates, and `crossingSegments` when a shape has more edges than the self-intersection check
/// covers (which then reports no self-intersection) — while an unreached cap stays out of the
/// report.
#[test]
fn self_check_reports_the_caps_it_hits() {
let mut s = Session::new();
for _ in 0..3 {
square(&mut s);
}
let r = shapes(&mut s, json!({ "limit": 1 }));
assert_eq!(r["truncated"], true);
assert_eq!(r["selfCheck"]["caps"], json!({ "limit": 1 }), "the page cap and its value: {r}");
assert_eq!((r["selfCheck"]["found"].clone(), r["selfCheck"]["analyzed"].clone()), (json!(3), json!(3)));
let r = shapes(&mut s, json!({ "limit": 10 }));
assert_eq!(r["selfCheck"]["caps"], json!({}), "a cap that was not reached is not reported");
// A closed polyline with more edges than the self-intersection cap can check.
let zig: Vec<(f64, f64, f64)> = (0..=4_000).map(|i| (f64::from(i), 0.0, 0.0)).collect();
pline(&mut s, &zig, true);
let r = shapes(&mut s, json!({ "limit": 10 }));
assert_eq!(r["selfCheck"]["caps"], json!({ "crossingSegments": 4_000 }), "{r}");
assert_eq!(r["shapes"].as_array().unwrap().last().unwrap()["selfIntersection"], Value::Null);
}
/// #1440: geometry that cannot be right is flagged with the bound it broke — a perimeter past 10×
/// the shape's bounding-box diagonal, and an area larger than the drawing extents (invisible
/// objects lie outside the extents, which is how the test makes an out-of-extents shape).
#[test]
fn self_check_flags_implausible_geometry() {
let mut s = Session::new();
square(&mut s); // visible extents: 10 x 10, area 100
// A sawtooth in the same 10 x 10 box: perimeter about 410, past 10 x the 14.14 diagonal.
let teeth: Vec<(f64, f64, f64)> = (0..=40).map(|i| (f64::from(i) * 0.25, if i % 2 == 0 { 0.0 } else { 10.0 }, 0.0)).collect();
pline(&mut s, &teeth, true);
// Invisible (so outside the drawing extents): 100 x 100, area 10,000.
pline(&mut s, &[(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)], true);
hide_last(&mut s);
let r = shapes(&mut s, json!({}));
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
let perimeter = flags.iter().find(|f| f["field"] == "perimeter").unwrap_or_else(|| panic!("no perimeter flag: {r}"));
assert!(perimeter["reason"].as_str().unwrap().contains("10"), "{perimeter}");
assert_eq!(perimeter["type"], "Polyline");
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
assert!(area["reason"].as_str().unwrap().contains("drawing extents"), "{area}");
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap());
}
/// #1440 review: a border rectangle that fills the drawing extents has an area equal to them in
/// exact arithmetic, so it is not implausible; a shape clearly larger than the extents still is.
#[test]
fn self_check_does_not_flag_an_extents_filling_rectangle() {
let mut s = Session::new();
square(&mut s); // the extents are its own 10 x 10 box, area 100.
let r = shapes(&mut s, json!({}));
assert_eq!(r["selfCheck"]["implausible"], json!([]), "an extents-filling rectangle is not a bug: {r}");
// The same rectangle a millionth larger, invisible so it lies outside the extents: clearly
// past the tolerance, so it is still flagged.
pline(&mut s, &[(0.0, 0.0, 0.0), (10.000001, 0.0, 0.0), (10.000001, 10.000001, 0.0), (0.0, 10.000001, 0.0)], true);
hide_last(&mut s);
let r = shapes(&mut s, json!({}));
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap() * (1.0 + 1e-9), "{area}");
}
/// #1440 review: with no visible extents (the only shape is invisible) there is no drawing bound
/// to compare an area against, so it is not flagged as larger than the extents.
#[test]
fn self_check_has_no_area_bound_without_visible_extents() {
let mut s = Session::new();
square(&mut s);
hide_last(&mut s);
assert!(s.doc().unwrap().extents(&Space::Model).is_empty());
let r = shapes(&mut s, json!({}));
assert_eq!(r["count"], 1);
assert_eq!(r["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {r}");
}
+46
View File
@@ -128,3 +128,49 @@ fn hostile_polygon_and_fence_never_panic() {
s.cancel();
}
}
#[test]
fn polygon_moves_only_the_multiline_vertices_inside_it() {
let mut s = Session::new();
s.execute("mline", &json!({ "points": [[0, 0], [3000, 0], [3000, 2000]], "scale": 200 })).unwrap();
let r = s.execute("stretch", &json!({ "polygon": [[2500, 1500], [3500, 1500], [3500, 2500], [2500, 2500]], "delta": [610, 0] })).unwrap();
assert_eq!(r["stretched"], 1);
let pts = |s: &Session| -> Vec<(f64, f64)> {
s.doc()
.unwrap()
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::MLine(m) => Some(m.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()),
_ => None,
})
.unwrap()
};
assert_eq!(pts(&s), vec![(0.0, 0.0), (3000.0, 0.0), (3610.0, 2000.0)]);
// A window over the first vertex moves that vertex only, not the whole multiline.
s.execute("stretch", &json!({ "window": [[-100, -100], [100, 100]], "delta": [610, 0] })).unwrap();
assert_eq!(pts(&s), vec![(610.0, 0.0), (3000.0, 0.0), (3610.0, 2000.0)]);
}
#[test]
fn small_area_around_a_multiline_vertex_moves_it_at_a_large_scale() {
let mline_points = |s: &Session| -> Vec<(f64, f64)> {
s.doc()
.unwrap()
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::MLine(m) => Some(m.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()),
_ => None,
})
.unwrap()
};
let mut s = Session::new();
s.execute("mline", &json!({ "points": [[0, 0], [3000, 0], [3000, 2000]], "scale": 200 })).unwrap();
// The element lines run 100 away from the middle vertex, so this area touches none of them.
let r = s.execute("stretch", &json!({ "polygon": [[2995, -5], [3005, -5], [3005, 5], [2995, 5]], "delta": [0, 610] })).unwrap();
assert_eq!(r["stretched"], 1);
assert_eq!(mline_points(&s), vec![(0.0, 0.0), (3000.0, 610.0), (3000.0, 2000.0)]);
s.execute("stretch", &json!({ "window": [[2995, 605], [3005, 615]], "delta": [0, -610] })).unwrap();
assert_eq!(mline_points(&s), vec![(0.0, 0.0), (3000.0, 0.0), (3000.0, 2000.0)]);
}
+51
View File
@@ -0,0 +1,51 @@
//! MEASUREINIT: the units of a new drawing made without a template (issue #1182). It is a profile
//! setting, so a metric choice survives restarts; an explicit `metric` or template still wins.
use cadcraft_engine::Session;
use serde_json::json;
fn var(s: &mut Session, name: &str) -> serde_json::Value {
s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone()
}
#[test]
fn imperial_by_default() {
let mut s = Session::empty();
assert_eq!(var(&mut s, "MEASUREINIT"), json!(0));
s.execute("new", &json!({})).unwrap();
assert_eq!(var(&mut s, "INSUNITS"), json!(1));
assert_eq!(var(&mut s, "MEASUREMENT"), json!(0));
}
#[test]
fn new_follows_measureinit() {
let mut s = Session::empty();
s.execute("options", &json!({ "MEASUREINIT": 1 })).unwrap();
s.execute("new", &json!({})).unwrap();
assert_eq!(var(&mut s, "INSUNITS"), json!(4));
assert_eq!(var(&mut s, "MEASUREMENT"), json!(1));
assert_eq!(var(&mut s, "DIMSTYLE"), json!("ISO-25"));
// An explicit choice still wins.
s.execute("new", &json!({ "metric": false })).unwrap();
assert_eq!(var(&mut s, "INSUNITS"), json!(1));
}
#[test]
fn only_zero_or_one() {
let mut s = Session::empty();
for bad in [json!(2), json!(-1), json!("metric")] {
assert!(s.execute("options", &json!({ "MEASUREINIT": bad })).is_err(), "{bad}");
}
assert_eq!(var(&mut s, "MEASUREINIT"), json!(0));
}
#[test]
fn kept_in_the_profile() {
let mut s = Session::empty();
s.execute("options", &json!({ "MEASUREINIT": 1 })).unwrap();
let saved = cadcraft_engine::cmd::options::profile(&s);
assert_eq!(saved["MEASUREINIT"], json!(1));
let mut restarted = Session::empty();
cadcraft_engine::cmd::options::restore(&mut restarted, &saved);
assert_eq!(var(&mut restarted, "MEASUREINIT"), json!(1));
}
@@ -0,0 +1,79 @@
//! 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:?}");
}
}
+284
View File
@@ -0,0 +1,284 @@
//! USAGE: a read-only cross-reference of the layers, blocks, text styles and linetypes a drawing
//! uses (#1439), built on PURGE's usage scan.
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, Text, TextStyle};
use cadcraft_engine::geom::Vec3;
use serde_json::{Value, json};
/// A LINE on `layer` with an explicit `linetype`, ten units long so it is never degenerate.
fn line(handle: u64, layer: &str, linetype: &str) -> Entity {
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) }));
e.common = Common { layer: layer.into(), linetype: linetype.into(), ..Common::default() };
e
}
/// A TEXT reading NOTE in `style` on `layer`, so the text-style table has a visible user.
fn text(handle: u64, layer: &str, style: &str) -> Entity {
let mut e = Entity::new(
Handle(handle),
EntityKind::Text(Text {
insert: Vec3::ZERO,
align_pt: None,
height: 1.0,
value: "NOTE".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: style.into(),
halign: Default::default(),
valign: Default::default(),
}),
);
e.common = Common { layer: layer.into(), ..Common::default() };
e
}
/// An INSERT of `block` on `layer`, at unit scale and without attributes.
fn insert(handle: u64, block: &str, layer: &str) -> Entity {
let mut e = Entity::new(
Handle(handle),
EntityKind::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,
}),
);
e.common = Common { layer: layer.into(), ..Common::default() };
e
}
/// A block definition named `name` holding `entities`, shared the way the drawing's block table
/// stores it.
fn block(name: &str, entities: Vec<Entity>) -> Arc<Block> {
let mut b = Block::new(name);
for e in entities {
b.entities.push(e);
}
Arc::new(b)
}
/// Layers 0, Defpoints, Used, Empty and InBlock; linetypes Continuous, DASHED and SPARE; text
/// styles Standard, InBlockStyle and UnusedStyle; blocks WALLS (inserted) holding a line on
/// InBlock and a text in InBlockStyle and an insert of INNER, INNER (used only inside WALLS) and
/// the unreferenced OLD. Model space: a DASHED line and a text on Used, and WALLS.
fn drawing() -> Session {
let mut s = Session::new();
let d = s.doc_mut().unwrap();
d.layers.push(Layer::new("Used"));
d.layers.push(Layer::new("Empty"));
d.layers.push(Layer::new("InBlock"));
for (name, dashes) in [("DASHED", [0.5, 0.25]), ("SPARE", [0.1, 0.1])] {
d.linetypes.push(Linetype::simple(name, "", &dashes));
}
d.text_styles.push(TextStyle { name: "InBlockStyle".into(), ..TextStyle::default() });
d.text_styles.push(TextStyle { name: "UnusedStyle".into(), ..TextStyle::default() });
d.blocks.insert(
"WALLS".into(),
block("WALLS", vec![line(0x3000, "InBlock", "ByLayer"), text(0x3001, "InBlock", "InBlockStyle"), insert(0x3002, "INNER", "0")]),
);
d.blocks.insert("INNER".into(), block("INNER", vec![line(0x3010, "0", "ByLayer")]));
d.blocks.insert("OLD".into(), block("OLD", vec![line(0x3020, "0", "ByLayer")]));
d.model.push(line(0x3100, "Used", "DASHED"));
d.model.push(text(0x3101, "Used", "Standard"));
d.model.push(insert(0x3102, "WALLS", "0"));
s
}
/// Runs the `usage` command with `p` and unwraps the result, so a rejected call fails the test.
fn usage(s: &mut Session, p: Value) -> Value {
s.execute("usage", &p).unwrap()
}
/// The report item of kind `ty` named `name`, failing with the whole item list when it is absent.
fn item<'a>(r: &'a Value, ty: &str, name: &str) -> &'a Value {
r["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["type"] == ty && i["name"] == name)
.unwrap_or_else(|| panic!("no {ty} \"{name}\" in {}", r["items"]))
}
/// The `unused` names of kind `kind` as plain strings, in the order USAGE returned them.
fn names(r: &Value, kind: &str) -> Vec<String> {
r["unused"][kind]["names"].as_array().unwrap().iter().map(|n| n.as_str().unwrap().to_string()).collect()
}
/// Guards that each layer separates uses in model space and the layouts from uses inside block
/// definitions, that Defpoints is empty yet kept by PURGE, and that the current layer is never
/// offered as unused.
#[test]
fn layers_report_uses_and_where() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
assert_eq!(r["schema"], 1);
// A line and a text in model space.
let used = item(&r, "layer", "Used");
assert_eq!((used["entities"].clone(), used["blockEntities"].clone()), (json!(2), json!(0)));
assert_eq!((used["used"].clone(), used["insideBlocksOnly"].clone()), (json!(true), json!(false)));
assert_eq!((used["empty"].clone(), used["unused"].clone()), (json!(false), json!(false)));
assert_eq!(used["current"], false);
// Only a line inside the inserted block WALLS.
let inblock = item(&r, "layer", "InBlock");
assert_eq!((inblock["entities"].clone(), inblock["blockEntities"].clone()), (json!(0), json!(2)));
assert_eq!((inblock["used"].clone(), inblock["insideBlocksOnly"].clone()), (json!(true), json!(true)));
// Empty and purgeable.
let empty = item(&r, "layer", "Empty");
assert_eq!((empty["used"].clone(), empty["empty"].clone()), (json!(false), json!(true)));
assert_eq!(empty["unused"], true);
// Defpoints is empty but PURGE keeps it.
let defpoints = item(&r, "layer", "Defpoints");
assert_eq!((defpoints["empty"].clone(), defpoints["unused"].clone()), (json!(true), json!(false)));
// The current layer is not offered as unused even with nothing on it.
let current = &r["items"].as_array().unwrap()[0];
assert_eq!(current["name"], "0");
assert_eq!((current["used"].clone(), current["unused"].clone(), current["current"].clone()), (json!(true), json!(false), json!(true)));
assert_eq!(names(&r, "layers"), ["Empty"]);
}
/// Guards that a block's `used` follows reachability from model space — INNER, referenced only
/// by the inserted WALLS, is used but inside blocks only — and that an unreferenced definition
/// such as OLD is empty and purgeable.
#[test]
fn blocks_report_references_and_reachability() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
let walls = item(&r, "block", "WALLS");
assert_eq!((walls["entities"].clone(), walls["blockEntities"].clone()), (json!(1), json!(0)));
assert_eq!((walls["used"].clone(), walls["insideBlocksOnly"].clone()), (json!(true), json!(false)));
// Referenced by WALLS only, which model space inserts.
let inner = item(&r, "block", "INNER");
assert_eq!((inner["entities"].clone(), inner["blockEntities"].clone()), (json!(0), json!(1)));
assert_eq!((inner["used"].clone(), inner["insideBlocksOnly"].clone()), (json!(true), json!(true)));
let old = item(&r, "block", "OLD");
assert_eq!((old["used"].clone(), old["empty"].clone(), old["unused"].clone()), (json!(false), json!(true), json!(true)));
assert_eq!(names(&r, "blocks"), ["OLD"]);
assert_eq!(r["summary"]["blocks"]["unused"], 1);
}
/// Guards that text styles count the entities that use them and the dimension/multileader styles
/// that name them, that ByLayer and ByBlock are values rather than uses of the linetype table,
/// and that a layer's linetype counts as a reference to it.
#[test]
fn styles_and_linetypes_report_uses() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
let standard = item(&r, "textStyle", "Standard");
assert_eq!((standard["entities"].clone(), standard["blockEntities"].clone()), (json!(1), json!(0)));
assert_eq!((standard["used"].clone(), standard["unused"].clone()), (json!(true), json!(false)));
let inblock = item(&r, "textStyle", "InBlockStyle");
assert_eq!((inblock["entities"].clone(), inblock["blockEntities"].clone()), (json!(0), json!(1)));
assert_eq!((inblock["used"].clone(), inblock["insideBlocksOnly"].clone()), (json!(true), json!(true)));
let unused = item(&r, "textStyle", "UnusedStyle");
assert_eq!((unused["used"].clone(), unused["empty"].clone(), unused["unused"].clone()), (json!(false), json!(true), json!(true)));
assert_eq!(names(&r, "textStyles"), ["UnusedStyle"]);
// An entity's explicit linetype.
let dashed = item(&r, "linetype", "DASHED");
assert_eq!((dashed["entities"].clone(), dashed["blockEntities"].clone()), (json!(1), json!(0)));
assert_eq!((dashed["used"].clone(), dashed["unused"].clone()), (json!(true), json!(false)));
// ByLayer and ByBlock are values, not uses of the linetype table's entries.
for special in ["ByLayer", "ByBlock"] {
let it = item(&r, "linetype", special);
assert_eq!((it["entities"].clone(), it["blockEntities"].clone()), (json!(0), json!(0)), "{special}");
assert_eq!(it["unused"], false, "{special}");
}
let spare = item(&r, "linetype", "SPARE");
assert_eq!((spare["used"].clone(), spare["unused"].clone()), (json!(false), json!(true)));
assert_eq!(names(&r, "linetypes"), ["SPARE"]);
// A layer's linetype is a reference to it; every layer here is Continuous.
let continuous = item(&r, "linetype", "Continuous");
assert_eq!((continuous["entities"].clone(), continuous["references"].clone()), (json!(0), json!(5)));
assert_eq!((continuous["used"].clone(), continuous["unused"].clone()), (json!(true), json!(false)));
}
/// Guards the promise that USAGE is read-only: the drawing is not copied and its uid, revision,
/// dirty flag, undo stack and tables are exactly as they were.
#[test]
fn nothing_is_modified() {
let mut s = drawing();
let before = s.state().unwrap().doc.clone();
let (uid, revision) = (s.state().unwrap().uid, s.state().unwrap().revision);
let dirty = s.state().unwrap().is_dirty();
let tables = |d: &Drawing| {
(
d.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
d.linetypes.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
d.text_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
d.blocks.keys().cloned().collect::<Vec<_>>(),
d.entity_count(),
)
};
let before_tables = tables(&before);
let _ = usage(&mut s, json!({}));
let st = s.state().unwrap();
assert!(Arc::ptr_eq(&before, &st.doc), "USAGE copied the drawing");
assert_eq!((st.uid, st.revision), (uid, revision));
assert_eq!(tables(&st.doc), before_tables);
assert!(st.undo.is_empty(), "USAGE pushed an undo step");
assert_eq!(st.is_dirty(), dirty);
}
/// Guards the paging envelope (`count`, `returned`, `offset`, `truncated`), the `type` filter and
/// that a bad `type`, `limit` or `offset` is reported as an error rather than panicking.
#[test]
fn paging_and_filters() {
let mut s = drawing();
let all = usage(&mut s, json!({}));
let total = all["count"].as_u64().unwrap();
assert_eq!(total as usize, all["items"].as_array().unwrap().len());
assert_eq!((all["returned"].clone(), all["truncated"].clone()), (json!(total), json!(false)));
assert_eq!(all["offset"], 0);
let page = usage(&mut s, json!({ "limit": 2 }));
assert_eq!((page["count"].clone(), page["returned"].clone(), page["truncated"].clone()), (json!(total), json!(2), json!(true)));
let offset = usage(&mut s, json!({ "offset": 2, "limit": 2 }));
assert_eq!((offset["returned"].clone(), offset["truncated"].clone()), (json!(2), json!(total > 4)));
let layers = usage(&mut s, json!({ "type": "layers" }));
assert_eq!(layers["count"], 5);
assert!(layers["items"].as_array().unwrap().iter().all(|i| i["type"] == "layer"));
let styles = usage(&mut s, json!({ "type": "textstyles" }));
assert_eq!(styles["count"], 3);
let none = usage(&mut s, json!({ "type": "blocks", "offset": 100 }));
assert_eq!((none["returned"].clone(), none["truncated"].clone()), (json!(0), json!(false)));
assert!(s.execute("usage", &json!({ "type": "nope" })).is_err());
assert!(s.execute("usage", &json!({ "limit": "x" })).is_err());
assert!(s.execute("usage", &json!({ "offset": -1 })).is_err());
}
/// Guards that a drawing within the scan limit reports itself complete: `incomplete` is a real
/// signal, false here and with no reason, so callers can tell complete counts from partial ones.
#[test]
fn a_complete_scan_is_marked_complete() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
assert_eq!(r["incomplete"], false);
assert!(r.get("incompleteReason").is_none(), "a complete scan has no reason: {r}");
assert!(!r["message"].as_str().unwrap().contains("incomplete"), "{}", r["message"]);
}
+8 -286
View File
@@ -1,25 +1,13 @@
//! 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.
//! 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.
use std::collections::BTreeSet;
use skrifa::instance::{LocationRef, Size};
use skrifa::FontRef;
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 {
@@ -35,40 +23,17 @@ 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(t)).map(|d| d.as_bytes())
f.table_data(Tag::new(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 Some(f) = font(face) else { return Embedding::NotTrueType };
let Ok(f) = FontRef::from_index(&face.bytes, face.index) 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;
}
@@ -84,257 +49,14 @@ 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!(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);
assert_eq!(embedding(&Face { bytes: std::sync::Arc::new(Vec::new()), index: 7 }), Embedding::NotTrueType);
}
}
+112
View File
@@ -434,6 +434,88 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
out
}
/// The curves with each open end that stops within `snap` of another curve moved onto that
/// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened
/// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve,
/// so a line that ends on the true curve can stop just short of the chords.
pub fn snap_ends(polys: &[Vec<Vec2>], snap: f64, tol: f64) -> Vec<Vec<Vec2>> {
let mut out: Vec<Vec<Vec2>> = polys.to_vec();
if !(snap.is_finite() && snap > tol) {
return out;
}
// build() refuses this input anyway; don't spend ends x segments work on it.
let total: usize = polys.iter().map(|p| p.len().saturating_sub(1)).sum();
if total > MAX_SEGMENTS {
return out;
}
// (target polyline, segment index, parameter on it, the point) for every vertex to insert.
let mut inserts: Vec<(usize, usize, f64, Vec2)> = Vec::new();
let mut budget = MAX_GAP_PAIRS;
'ends: for (i, pl) in polys.iter().enumerate() {
let (Some(&first), Some(&last)) = (pl.first(), pl.last()) else { continue };
if pl.len() < 2 || first.near(last, tol) {
continue;
}
for at_end in [false, true] {
let e = if at_end { last } else { first };
if !e.is_finite() {
continue;
}
let mut best: Option<(f64, usize, usize, f64, Vec2)> = None;
let mut touching = false;
for (j, other) in polys.iter().enumerate() {
if j == i {
continue;
}
for (k, w) in other.windows(2).enumerate() {
if budget == 0 {
break 'ends;
}
budget -= 1;
let [a, b] = w else { continue };
if !(a.is_finite() && b.is_finite())
|| e.x < a.x.min(b.x) - snap
|| e.x > a.x.max(b.x) + snap
|| e.y < a.y.min(b.y) - snap
|| e.y > a.y.max(b.y) + snap
{
continue;
}
let line = Line::new(*a, *b);
let t = line.param_of(e).clamp(0.0, 1.0);
let q = line.at(t);
let d = q.dist(e);
if d <= tol {
touching = true;
} else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) {
best = Some((d, j, k, t, q));
}
}
}
let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue };
let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue };
*slot = q;
let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1)));
if let Some((&a, &b)) = ends
&& !q.near(a, tol)
&& !q.near(b, tol)
{
inserts.push((j, k, t, q));
}
}
}
// Later segments and larger parameters first, so earlier indices stay valid.
inserts.sort_by(|x, y| y.0.cmp(&x.0).then(y.1.cmp(&x.1)).then(y.2.total_cmp(&x.2)));
for (j, k, _, q) in inserts {
if let Some(v) = out.get_mut(j)
&& k < v.len()
{
v.insert(k + 1, q);
}
}
out
}
/// The closed loop enclosing `p`, counter-clockwise, or `None`.
pub fn enclosing_loop(polys: &[Vec<Vec2>], p: Vec2, tol: f64) -> Option<Vec<Vec2>> {
enclosing_loop_gap(polys, p, tol, 0.0)
@@ -721,4 +803,34 @@ mod tests {
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::NAN).is_none());
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::INFINITY).is_none());
}
#[test]
fn line_ending_just_short_of_a_chord_is_joined_to_it() {
// A strip between two verticals and a flattened arc that bows below the ends of the
// verticals by 0.05 (the verticals stop on the true curve, the chord is lower).
let top = vec![Vec2::new(0.0, 10.0), Vec2::new(2.0, 10.0)];
let left = vec![Vec2::new(0.0, 10.0), Vec2::new(0.0, 0.05)];
let right = vec![Vec2::new(2.0, 10.0), Vec2::new(2.0, 0.05)];
let chord = vec![Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0)];
let polys = vec![top, left, right, chord];
let p = Vec2::new(1.0, 5.0);
assert!(enclosing_loop(&polys, p, 1e-9).is_none());
let snapped = snap_ends(&polys, 0.1, 1e-9);
let l = enclosing_loop(&snapped, p, 1e-9).unwrap();
assert!((shoelace(&l) - 20.0).abs() < 1e-6, "{}", shoelace(&l));
// Too far away: left alone.
assert_eq!(snap_ends(&polys, 0.01, 1e-9), polys);
}
#[test]
fn snap_ends_skips_input_over_the_segment_cap() {
let mut polys: Vec<Vec<Vec2>> = (0..=MAX_SEGMENTS)
.map(|i| {
let x = i as f64 * 10.0;
vec![Vec2::new(x, 0.0), Vec2::new(x, 1.0)]
})
.collect();
polys.push(vec![Vec2::new(0.0, 1.05), Vec2::new(5.0, 1.05)]);
assert_eq!(snap_ends(&polys, 0.1, 1e-9), polys);
}
}
+3
View File
@@ -20,9 +20,12 @@ 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"] }
+1 -1
View File
@@ -285,7 +285,7 @@ pub(crate) fn arrow_block_name(name: &str) -> Option<(String, Arrowhead)> {
return None;
}
let kind = Arrowhead::parse(n);
let norm: String = n.trim_start_matches('_').chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let norm: String = n.trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
if kind == Arrowhead::ClosedFilled && !matches!(norm.as_str(), "closedfilled" | "filled") {
return None;
}
+93 -5
View File
@@ -455,6 +455,25 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
EntityKind::Unknown(u) => Some(up(&u.dxf_type)),
_ => None,
}));
// A proxy's class is the one its group 91 numbers in the file's CLASSES: keep it too, and the
// file's class order, by which saving renumbers the proxy.
let class_order: Vec<String> =
classes.iter().filter(|(k, _, _)| k == "CLASS").take(MAX_OBJECTS).map(|(_, _, t)| up(&val(t, 1).unwrap_or_default())).collect();
let mut proxies = false;
let proxy_tags = f.objects.iter().filter(|o| is_proxy(&o.kind)).map(|o| &o.tags);
let proxy_tags = proxy_tags.chain(all_entities(d).filter_map(|e| match &e.kind {
EntityKind::Unknown(u) if is_proxy(&u.dxf_type) => Some(&u.tags),
_ => None,
}));
for tags in proxy_tags {
proxies = true;
if let Some(name) = class_index(class_number(tags.iter().map(|r| (r.code, r.value.as_str())))).and_then(|i| class_order.get(i)) {
types.insert(name.clone());
}
}
if proxies {
f.class_order = class_order;
}
for (kind, _, t) in &classes {
if kind == "CLASS" && val(t, 1).is_some_and(|n| types.contains(&up(&n))) && f.classes.len() < MAX_OBJECTS {
f.classes.push(t.iter().map(raw).collect());
@@ -492,6 +511,45 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
}
}
/// Proxy records (DXF Reference, ACAD_PROXY_OBJECT and ACAD_PROXY_ENTITY): another application's
/// objects and entities, whose class is named by number in group 91.
fn is_proxy(kind: &str) -> bool {
matches!(up(kind).as_str(), "ACAD_PROXY_OBJECT" | "ACAD_PROXY_ENTITY")
}
/// Where a proxy's class number is: its first group 91 outside the `102 {NAME` … `102 }` groups
/// (an application's can hold a 91 of its own) and before any xdata.
fn class_group<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)>) -> Option<usize> {
let mut in_group = false;
for (i, (code, value)) in groups.into_iter().enumerate() {
match code {
1000.. => return None,
102 => in_group = value.as_ref().trim().starts_with('{'),
91 if !in_group => return Some(i),
_ => {}
}
}
None
}
/// A proxy's class number (see [`class_group`]).
fn class_number<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)> + Clone) -> Option<i64> {
let i = class_group(groups.clone())?;
groups.into_iter().nth(i).and_then(|(_, v)| v.as_ref().trim().parse().ok())
}
/// The place in CLASSES a class number names (500 is the first class).
fn class_index(number: Option<i64>) -> Option<usize> {
usize::try_from(number?.checked_sub(500)?).ok()
}
/// Set a proxy's class number (see [`class_group`]).
fn set_class_number(tags: &mut [Tag], n: i64) {
if let Some(t) = class_group(tags.iter().map(|t| (t.code, t.str()))).and_then(|i| tags.get_mut(i)) {
*t = Tag::i(91, n);
}
}
/// Most groups of a data store section kept (a damaged file's can be endless).
const MAX_DATA_STORE_GROUPS: usize = 1 << 26;
@@ -775,8 +833,28 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
roles.entry(k).or_insert(h);
}
let mut written: HashSet<String> = recs.iter().filter_map(|r| head(body(r)).handle).collect();
// The written CLASSES as numbered (500 for the first): the writer's own, then the kept ones it
// doesn't write (appended below, in this order). A kept proxy's group 91 is renumbered to its
// class's place here; a proxy whose class isn't written is dropped (#1812).
let class_names: HashSet<String> = recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).collect();
let appended = f.classes.iter().filter_map(|c| c.iter().find(|t| t.code == 1)).map(|n| up(&n.value)).filter(|n| !class_names.contains(n));
let mut numbers: HashMap<String, i64> = HashMap::new();
for (i, n) in recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).chain(appended).enumerate() {
numbers.entry(n).or_insert(500_i64.saturating_add(i64::try_from(i).unwrap_or(i64::MAX)));
}
let renumber = |old: Option<i64>| class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied());
// Entities of types CADCraft doesn't model, placed among the written ones.
let planned = plan_unknown(d, &recs, tags, &roles);
let mut planned = plan_unknown(d, &recs, tags, &roles);
for list in planned.values_mut() {
list.retain_mut(|(_, t)| {
if !t.first().is_some_and(|z| is_proxy(&z.str())) {
return true;
}
let Some(n) = renumber(class_number(t.iter().map(|x| (x.code, x.str())))) else { return false };
set_class_number(t, n);
true
});
}
for list in planned.values() {
for (_, t) in list {
written.extend(head(t.get(1..).unwrap_or(&[])).handle);
@@ -813,6 +891,9 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
.filter_map(|o| Some((o.handle.hex(), head(&o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect::<Vec<_>>()).owner?)))
.collect();
let mut alive_memo: HashMap<String, bool> = HashMap::new();
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none()) {
alive_memo.insert(o.handle.hex(), false);
}
for o in &f.objects {
let mut path: Vec<String> = Vec::new();
let mut cur = o.handle.hex();
@@ -850,7 +931,6 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
}
let entities: HashSet<String> = all_entities(d).map(|e| e.handle.hex()).filter(|h| written.contains(h)).collect();
let map = Map { f, alive, raw: objs.keys().cloned().collect(), roles, entities };
let class_names: HashSet<String> = recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).collect();
let mut out: Vec<Tag> = Vec::with_capacity(tags.len() + f.objects.iter().map(|o| o.tags.len() + 1).sum::<usize>());
let mut block = String::new();
for r in &recs {
@@ -866,7 +946,16 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
}
if r.kind == "ENDSEC" && r.sec == "OBJECTS" {
for o in f.objects.iter().filter(|o| map.alive.contains_key(&o.handle.hex())) {
map.object(o, &mut out);
let number = is_proxy(&o.kind).then(|| renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str()))))).flatten();
match number {
Some(n) => {
let mut tags: Vec<Tag> = o.tags.iter().map(tag).collect();
set_class_number(&mut tags, n);
let o = ForeignObject { tags: tags.iter().map(raw).collect(), ..o.clone() };
map.object(&o, &mut out);
}
None => map.object(o, &mut out),
}
}
}
for (sec, t) in planned.get(&r.start).into_iter().flatten() {
@@ -913,8 +1002,7 @@ fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap<Foreign
};
let br = |name: &str| roles.get(&("BLOCK_RECORD".to_string(), up(name))).cloned();
let first_at = |s: &cadcraft_doc::EntityStore| s.iter().find_map(|e| at.get(&e.handle.hex()).copied());
let mut layouts: Vec<&cadcraft_doc::Layout> = d.layouts.iter().collect();
layouts.sort_by_key(|l| l.tab_order);
let layouts = d.paper_space_layouts();
// (entities, owner, paper space, end of the space, section)
let mut stores: Vec<(&cadcraft_doc::EntityStore, Option<String>, bool, Option<usize>, &str)> = Vec::new();
let model_end = layouts.first().and_then(|l| first_at(&l.entities)).or(ents_end);
+80 -8
View File
@@ -1795,7 +1795,12 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
d.layouts.push(Layout::new("Layout1", 1));
paper_block_for.insert("*PAPER_SPACE".into(), "Layout1".into());
}
let first_layout = d.layouts.first().map(|l| l.name.clone()).unwrap_or_else(|| "Layout1".into());
// The layout in `*Paper_Space` is the active one, kept when it isn't the first tab (the first
// is active by default); paper-space entities without an owner go there.
let active = paper_block_for.get("*PAPER_SPACE").cloned();
let first_tab = d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone());
d.active_layout = active.clone().filter(|a| Some(a) != first_tab.as_ref());
let first_layout = active.or_else(|| d.layouts.first().map(|l| l.name.clone())).unwrap_or_else(|| "Layout1".into());
// Blocks: model/paper space blocks hold entities in newer files.
for (name, base, description, ents, xref) in blocks {
let up = name.to_ascii_uppercase();
@@ -2148,7 +2153,12 @@ impl Objects {
}
}
/// Associativity from a DIMASSOC object (files from other writers).
/// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides
/// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14`
/// for linear, aligned and arc-length dimensions, both ends of both lines (`p13`..`p16`) for
/// angular dimensions, the three points of an angular-3-point dimension, `p13` for ordinate
/// dimensions, and for radial or diametric dimensions the single circle reference
/// ([`radial_links`]). CADCraft's own xdata links, when present, win.
fn dimassoc(d: &mut Drawing, tags: &[Tag]) {
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0);
let tail = tags.get(start..).unwrap_or(&[]);
@@ -2157,10 +2167,25 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) {
if !dm.assoc.is_empty() {
return;
}
// A radial or diametric dimension names its circle once, by its centre.
if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) {
let links = radial_links(d, dm, tail);
if !links.is_empty() {
let _ = d.modify_entity(dim_h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.assoc = links;
}
});
}
return;
}
let slots: &[&str] = match dm.kind {
DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"],
DimKind::Angular => &["p13", "p14", "p15", "p16"],
DimKind::Angular3P => &["p13", "p14", "p15"],
DimKind::ArcLength => &["p13", "p14"],
DimKind::Ordinate { .. } => &["p13"],
_ => return,
DimKind::Radius | DimKind::Diameter => return,
};
let flags = T(tail).i(90).unwrap_or(0);
let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect();
@@ -2189,9 +2214,51 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) {
}
}
/// Associations of a radial or diametric dimension from its one standard reference: the circle
/// or arc it measures, by its centre (osnap 3). The definition points are then on that circle,
/// so the dimension follows its radius or diameter.
fn radial_links(d: &Drawing, dm: &Dimension, tail: &[Tag]) -> Vec<DimAssoc> {
let starts: Vec<usize> = tail.iter().enumerate().filter(|(_, x)| x.code == 1 && x.str() == "AcDbOsnapPointRef").map(|(i, _)| i).take(8).collect();
for (k, s) in starts.iter().enumerate() {
let end = starts.get(k + 1).copied().unwrap_or(tail.len());
let r = T(tail.get(s + 1..end).unwrap_or(&[]));
if !matches!(r.i(72), Some(3 | 4)) {
continue;
}
let Some(handle) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue };
let Some(centre) = circle_centre(d, handle) else { continue };
let on = |point: &str, p: Vec3| {
let angle = centre.angle_to(p.xy());
angle.is_finite().then(|| DimAssoc { point: point.into(), handle, snap: AssocSnap::OnCircle { angle } })
};
let links = match dm.kind {
DimKind::Radius => on("p15", dm.p15).map(|p15| vec![DimAssoc { point: "defpt".into(), handle, snap: AssocSnap::Center }, p15]),
DimKind::Diameter => [on("defpt", dm.defpt), on("p15", dm.p15)].into_iter().collect::<Option<Vec<_>>>(),
_ => None,
};
if let Some(links) = links {
return links;
}
}
Vec::new()
}
/// The planar centre of a circle or arc, or `None` for any other entity: the point a radial or
/// diametric dimension's single DIMASSOC reference names.
fn circle_centre(d: &Drawing, h: Handle) -> Option<Vec2> {
match &d.entity(h)?.kind {
EntityKind::Circle(c) => Some(c.center.xy()),
EntityKind::Arc(a) => Some(a.center.xy()),
_ => None,
}
}
/// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment
/// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment
/// and the osnap (1 its end, 13 its start) name the vertex.
/// and the osnap (1 its end, 13 its start) name the vertex. For a line or arc the osnap alone
/// says which end: 13 is the start and 1 the end, whatever the stored point (the point is
/// 0,0,0 for curves, and choosing by distance gave the wrong end for arcs). A point entity has
/// only one position, so both codes mean it.
fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option<Handle>, at: Vec2, segment: Option<i64>) -> Option<AssocSnap> {
let e = d.entity(main)?;
if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) {
@@ -2203,16 +2270,21 @@ fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option<Handle>, at:
}
Some(match osnap {
1 | 13 => match &e.kind {
// AutoCAD names a curve's start with 13 and its end with 1 (the stored point is 0,0,0).
// Other writers use 1 for either end of a line with the point stored: take the nearer.
EntityKind::Line(l) => {
if osnap == 13 || l.a.xy().dist(at) <= l.b.xy().dist(at) {
if osnap == 13 || (at != Vec2::ZERO && l.a.xy().dist(at) <= l.b.xy().dist(at)) {
AssocSnap::Start
} else {
AssocSnap::End
}
}
EntityKind::Arc(a) => {
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
if osnap == 13 || g.start_point().dist(at) <= g.end_point().dist(at) { AssocSnap::Start } else { AssocSnap::End }
EntityKind::Arc(_) => {
if osnap == 13 {
AssocSnap::Start
} else {
AssocSnap::End
}
}
EntityKind::LwPolyline(p) => {
let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?;
+55 -9
View File
@@ -1424,7 +1424,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
attach: 5,
rotation: g.text_angle,
style: style.text_style.clone(),
contents: g.value.clone(),
contents: g.mtext_contents(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
@@ -1559,20 +1559,34 @@ pub(crate) fn polyline_vertex_ref(p: &LwPolyline, index: usize) -> Option<(i64,
}
/// Standard DIMASSOC point references of an associative dimension: the extension-line
/// origins of linear/aligned dimensions and the feature point of ordinate dimensions, linked
/// origins of linear/aligned dimensions, both ends of both lines of an angular dimension, the
/// feature point of ordinate dimensions and the circle of a radial/diametric dimension, linked
/// to objects that exist ([`whole_object_ref`]). CADCraft's exact links travel in xdata as well.
fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec<OsnapRef>) {
if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) {
return radial_ref(d, &dm.assoc);
}
let slots: &[&str] = match dm.kind {
DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"],
DimKind::Angular => &["p13", "p14", "p15", "p16"],
DimKind::Angular3P => &["p13", "p14", "p15"],
// Saved as an aligned dimension (`dim_xdata` marks it): its extension origins are the arc ends.
DimKind::ArcLength => &["p13", "p14"],
DimKind::Ordinate { .. } => &["p13"],
_ => &[],
DimKind::Radius | DimKind::Diameter => &[],
};
let mut flags = 0;
let mut refs = Vec::new();
for (slot, name) in slots.iter().enumerate() {
let Some(a) = dm.assoc.iter().find(|a| a.point == *name) else { continue };
let Some(ent) = d.entity(a.handle) else { continue };
let point = if *name == "p13" { dm.p13 } else { dm.p14 };
let point = match *name {
"p13" => dm.p13,
"p14" => dm.p14,
"p15" => dm.p15,
"p16" => dm.p16,
_ => continue,
};
if let EntityKind::LwPolyline(p) = &ent.kind {
let index = match &a.snap {
AssocSnap::Vertex { index } => Some(*index),
@@ -1589,7 +1603,10 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec<OsnapRef>) {
continue;
}
let (osnap, param, other) = match &a.snap {
AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. } => (1, 0.0, None),
// AutoCAD marks a curve's start with osnap 13 and its end with osnap 1 (group 72).
AssocSnap::Start => (if matches!(ent.kind, EntityKind::Point(_)) { 1 } else { 13 }, 0.0, None),
AssocSnap::End => (1, 1.0, None),
AssocSnap::Vertex { .. } => (1, 0.0, None),
AssocSnap::Mid => (2, 0.0, None),
AssocSnap::Center => (if matches!(ent.kind, EntityKind::Point(_)) { 4 } else { 3 }, 0.0, None),
AssocSnap::OnCircle { angle } => (10, *angle, None),
@@ -1606,6 +1623,27 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec<OsnapRef>) {
(flags, refs)
}
/// The single standard reference of a radial or diametric dimension: the circle or arc it
/// measures, by its centre, with associativity flag 2. AutoCAD writes radial and diametric
/// dimensions that way: the one reference names the geometry, whose radius or diameter the
/// dimension then follows. Returns no flags and no references when the association does not name
/// a circle or arc, so such a dimension is saved unassociated rather than pointing at nothing.
fn radial_ref(d: &Drawing, assoc: &[DimAssoc]) -> (i64, Vec<OsnapRef>) {
for a in assoc {
let Some(ent) = d.entity(a.handle) else { continue };
let centre = match &ent.kind {
EntityKind::Circle(c) => c.center.xy(),
EntityKind::Arc(arc) => arc.center.xy(),
_ => continue,
};
if !centre.is_finite() {
continue;
}
return (2, vec![OsnapRef { osnap: 3, main: a.handle, segment: 0, param: 0.0, point: centre.to3(0.0), other: None }]);
}
(0, Vec::new())
}
/// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed
/// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space
/// viewport (DWG does not store viewport ids; readers number them in order), so a layout made in
@@ -1629,6 +1667,15 @@ fn paper_views(d: &Drawing) -> HashMap<Handle, usize> {
out
}
/// A layout's entities with its paper-space viewport (id 1) first. DWG stores no viewport ids and
/// readers number a layout's viewports in order, so the sheet must lead even when the draw order
/// (SORTENTS) put it behind a model viewport; otherwise that viewport becomes the sheet.
fn sheet_first(l: &Layout) -> Vec<&Entity> {
let sheet = |e: &&Entity| matches!(&e.kind, EntityKind::Viewport(v) if v.id == 1);
let all = || l.entities.iter().map(|e| e.as_ref());
all().filter(sheet).chain(all().filter(|e| !sheet(e))).collect()
}
/// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one
/// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader
/// leaves it implicit again.
@@ -1762,8 +1809,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
reactors: hatch_reactors(d),
};
// Pre-allocate handles for block records.
let mut paper_layouts: Vec<&Layout> = d.layouts.iter().collect();
paper_layouts.sort_by_key(|l| l.tab_order);
let paper_layouts = d.paper_space_layouts();
let ms_br = w.h();
// Xrefs are saved as references: without their objects and the symbols that came with them.
let saved_view = crate::dxf_xref::saved_view(d);
@@ -2341,7 +2387,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
if i == 0 {
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut std::iter::empty(), true);
} else {
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut l.entities.iter().map(|e| e.as_ref()), true);
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true);
}
}
for (n, h, b) in &user_blocks {
@@ -2372,7 +2418,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
entity(&mut w, d, e, &ms_br, false, &cx);
}
if let Some((_, h, l)) = ps_brs.first() {
for e in l.entities.iter() {
for e in sheet_first(l) {
entity(&mut w, d, e, h, true, &cx);
}
}
+360 -382
View File
@@ -1,11 +1,12 @@
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (pure Rust, no file
//! access, so it runs on the web too).
//! 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).
//!
//! 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 (`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.
//! 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.
//!
//! 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
@@ -19,6 +20,19 @@ 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;
@@ -31,9 +45,13 @@ 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).
@@ -64,8 +82,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 included on PDF layers that start
/// hidden (frozen layers are never included). Default off: they aren't plotted.
/// 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.
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.
@@ -356,23 +374,121 @@ 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")));
}
// 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;
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}")),
}
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))
}
}
/// 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);
}
}
}
/// One plotted sheet, ready to be written as a page.
@@ -383,7 +499,8 @@ struct SheetPage {
list: cadcraft_render::DisplayList,
map: Map,
clip_pt: Bounds2,
/// The drawing layers (indices in the layer table) of the content's `/L<i>` marks.
/// The drawing layers (indices in the layer table) drawn on, in order of first use: the
/// candidates for PDF layers.
layers: Vec<u32>,
/// Link annotations: rectangle in points and target.
links: Vec<(Bounds2, String)>,
@@ -556,125 +673,170 @@ 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 }
}
/// 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();
}
}
if s == "-0" {
s = "0".into();
}
out.push_str(&s);
/// 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))
}
fn pt(out: &mut String, p: Vec2) {
num(out, p.x);
out.push(' ');
num(out, p.y);
/// 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()
}
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(' ');
/// 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));
}
out.push_str(op);
out.push('\n');
}
/// The page's marks; those of primitives on `layers` are marked as optional content
/// (`/OC /L<layer> BDC` … `EMC`).
///
/// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its
/// embedded font (`/F<font>`), placed by a text matrix.
///
/// 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 c = String::new();
c.push_str("q\n1 J 1 j\n");
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.
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;
}
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();
}
surface.pop();
surface.finish();
page.finish();
drawn
}
/// 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,
}
/// 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)?;
}
if let Some(id) = to {
s.start_optional_content(id).map_err(krilla_failure)?;
}
Ok(())
}
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.
///
/// 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 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);
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();
if want != layer {
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");
}
flush(s, &mut batch);
layer_changed(s, layer, want)?;
layer = want;
}
let rgb = p.display_rgb(white);
let raw = list.points(p);
let rgb = prim.display_rgb(white);
let raw = list.points(prim);
if raw.iter().any(|q| !q.is_finite()) {
continue;
}
// A wipeout's mask always hides what is under it.
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 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 w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
match p.kind {
match prim.kind {
Kind::Polyline | Kind::Infinite { .. } => {
let pts: Vec<Vec2> = match p.kind {
let pts: Vec<Vec2> = match prim.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, clip_pt) {
match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) {
Some((x, y)) => vec![x, y],
None => continue,
}
@@ -686,301 +848,117 @@ fn content_stream(
if pts.len() < 2 {
continue;
}
// 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);
// 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);
}
if stroke != Some(rgb) || width != Some(w) {
flush(&mut c, &mut pending);
if stroke != Some(rgb) {
color(&mut c, rgb, "RG");
stroke = Some(rgb);
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 width != Some(w) {
num(&mut c, w);
c.push_str(" w\n");
width = Some(w);
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);
}
}
}
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(&mut c, &mut pending);
flush(s, &mut batch);
// A wipeout's mask is paper white.
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);
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);
continue;
}
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" });
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);
}
}
c.push_str("h\n");
any = true;
pb.close();
}
if any {
c.push_str("f\n");
if let Some(path) = pb.finish() {
fill_path(s, &path, rgb, t);
}
}
Kind::Point => {
let Some(q) = raw.first() else { continue };
flush(&mut c, &mut pending);
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
flush(s, &mut batch);
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");
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 text_font.is_some() {
c.push_str("ET\n");
}
if layer.is_some() {
c.push_str("EMC\n");
}
c.push_str("Q\n");
(c, alphas)
flush(s, &mut batch);
layer_changed(s, layer, None)
}
/// 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;
/// 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;
for v in [g.x.x * s, g.x.y * s, g.y.x * s, g.y.y * s] {
num(c, v);
c.push(' ');
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;
}
pt(c, map.pt(g.origin));
let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid);
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();
}
/// 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> {
/// 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> {
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::from("(");
let mut out = String::with_capacity(url.len());
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
}
+39 -150
View File
@@ -1,25 +1,22 @@
//! Real text in PDF plots: the outline-font glyphs a plot draws ([`DisplayList::texts`]) are
//! 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.
//! 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.
//!
//! 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::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Write as _;
use std::collections::HashMap;
use cadcraft_fonts::subset::{self, FontInfo};
use cadcraft_fonts::subset::{self, Embedding};
use cadcraft_fonts::ttf::Face;
use cadcraft_render::{DisplayList, PlotText};
use super::{Objects, num, refs, stream};
use craft_pdf::write::backend::text::Font;
/// Most embedded fonts in one file.
const MAX_FONTS: usize = 64;
/// Most embedded font data in one file (uncompressed bytes).
const MAX_FONT_BYTES: usize = 64 << 20;
/// 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;
/// A face's identity: its loaded file and face index.
type FaceKey = (usize, usize, u32);
@@ -28,153 +25,45 @@ fn key(f: &Face) -> FaceKey {
(f.bytes.as_ptr() as usize, f.bytes.len(), f.index)
}
/// 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)]
/// The fonts of a document, decided face by face as glyphs are written.
pub(super) struct Fonts {
fonts: Vec<Font>,
by_face: HashMap<FaceKey, Option<usize>>,
/// 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,
}
impl Fonts {
/// 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);
}
}
pub(super) fn new(enabled: bool) -> Fonts {
Fonts { enabled, by_face: HashMap::new(), count: 0, bytes: 0 }
}
/// 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 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);
}
}
let k = key(face);
if let Some((_, f)) = self.by_face.get(&k) {
return f.clone();
}
out
}
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.
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(' ');
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);
}
num(&mut bbox, v.round());
self.by_face.insert(k, (face.clone(), font.clone()));
font
}
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
/// 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()
}
+3 -3
View File
@@ -16,7 +16,7 @@
use std::collections::{BTreeMap, HashSet};
use cadcraft_color::Color;
use cadcraft_doc::*;
use cadcraft_doc::{Circle, Common, Drawing, Ellipse, EntityKind, Layer, Line, LwPolyline, Space};
use cadcraft_geom::{Mat3, PolyVertex, Spline, Vec2, Vec3};
use quick_xml::Reader;
use quick_xml::XmlVersion;
@@ -527,7 +527,7 @@ impl Parser {
} else if *rx > 0.0 && *ry > 0.0 {
self.flush_run(&mut run, false)?;
self.flush_ctrl(&mut ctrl)?;
for p in flatten_arc(&m, cur_user, *end, *rx, *ry, *rot, *large, *sweep)? {
for p in flatten_arc(&m, cur_user, *end, (*rx, *ry), *rot, *large, *sweep)? {
if run.is_empty() {
run.push(PolyVertex::new(cur));
}
@@ -1004,7 +1004,7 @@ fn pick_sweep(ccw: f64, large: bool, sweep: bool) -> f64 {
/// Flatten a general elliptical arc (any `rx`/`ry`/rotation) from `p0` to `p1` in user
/// space, then map every sample through `m`.
fn flatten_arc(m: &Mat3, p0: Vec2, p1: Vec2, rx: f64, ry: f64, rot_deg: f64, large: bool, sweep: bool) -> Result<Vec<Vec2>> {
fn flatten_arc(m: &Mat3, p0: Vec2, p1: Vec2, (rx, ry): (f64, f64), rot_deg: f64, large: bool, sweep: bool) -> Result<Vec<Vec2>> {
let (c, theta, delta) = elliptical_params(p0, p1, rx, ry, rot_deg.to_radians(), large, sweep)
.ok_or_else(|| IoError::Format("SVG: elliptical arc cannot be converted".into()))?;
let axes = Mat3::rotate(rot_deg.to_radians());
+245 -63
View File
@@ -4,6 +4,9 @@ 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() });
@@ -418,50 +421,22 @@ fn hostile_export_window_is_an_error() {
assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok());
}
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
/// Read a PDF back (its structure checked by the reader) and return the first page's decoded
/// content.
fn check_pdf(bytes: &[u8]) -> 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()
}
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()
}
fn media_box(bytes: &[u8]) -> (f64, f64) {
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])
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()
}
#[test]
@@ -471,16 +446,17 @@ 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!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
assert!(pdf_read::count(&content, "m") > 0 && pdf_read::count(&content, "l") > 0 && pdf_read::count(&content, "S") > 0);
// 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!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
assert!(!pdf_read::read(&raw).pages[0].compressed);
assert!(pdf_read::read(&bytes).pages[0].compressed);
assert_eq!(media_box(&raw), (612.0, 792.0));
assert!(c2.contains("re W n"));
assert!(pdf_read::clip_rect(&c2).is_some(), "clipped to the plot area: {c2}");
// write() picks PDF by extension.
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
}
@@ -513,11 +489,12 @@ 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.
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
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}");
// Lineweights on: default 0.25 mm = 0.709 pt.
assert!(content.contains("0.709 w"));
assert!(widths(&content).iter().any(|w| (w - 0.709).abs() < 0.001), "{content}");
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
assert!(check_pdf(&off).contains("0 w"));
assert!(widths(&check_pdf(&off)).contains(&0.0));
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
}
@@ -1073,6 +1050,11 @@ fn damaged_dwg_opens_partially_and_says_what_it_left_out() {
assert!(partial > 0, "no damaged copy read partially");
}
/// A dimension's standard DIMASSOC objects carry the same associations as CADCraft's xdata, so
/// a file from another writer (xdata stripped) still follows its geometry: a linear dimension's
/// two origins, a polyline's vertex named by its segment (#1049), and since #1651 a radial
/// dimension's circle, whose centre comes back as a `Center` link and its rim point as
/// `OnCircle`.
#[test]
fn dimension_associativity_roundtrips() {
let mut d = Drawing::new_imperial();
@@ -1144,7 +1126,10 @@ fn dimension_associativity_roundtrips() {
assert_eq!(fa.len(), 2);
assert_eq!(fa[1], ali.assoc[1], "the polyline vertex, named by its segment (#1049)");
assert!(matches!(fa[0].snap, AssocSnap::OnCircle { angle } if angle.abs() < 1e-9));
assert!(assoc_of(&foreign, hr).is_empty(), "radial links are CADCraft-only");
let fr = assoc_of(&foreign, hr);
assert_eq!(fr.len(), 2, "a radial dimension's circle comes from the standard DIMASSOC");
assert_eq!(fr[0], rad.assoc[0], "the centre");
assert!(matches!(fr[1].snap, AssocSnap::OnCircle { angle } if angle.is_finite()));
}
/// #1049: a polyline vertex is referenced as AutoCAD does: an end of one of its segments,
@@ -2430,15 +2415,16 @@ 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);
let text = String::from_utf8_lossy(&on).to_string();
assert!(text.contains("/ExtGState << /T50 << /Type /ExtGState /CA 0.5 /ca 0.5 >> >>"), "{text}");
// One graphics state: fill alpha 0.5 (strokes stay opaque).
assert_eq!(pdf_read::read(&on).pages[0].alphas, [(1.0, 0.5)]);
// The line is opaque, the solid at half alpha.
assert!(content.contains("/T50 gs\n1 0 0 rg"), "{content}");
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}");
// 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());
let off_text = String::from_utf8_lossy(&off).to_string();
assert!(!off_text.contains("ExtGState") && !off_text.contains(" gs\n"));
assert!(pdf_read::read(&off).pages[0].alphas.is_empty() && pdf_read::count(&check_pdf(&off), "gs") == 0);
// On, but nothing transparent: the same bytes as off.
let opaque = transparent_over_line(0);
assert_eq!(
@@ -2457,13 +2443,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});
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).contains("ExtGState"));
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());
d.layouts[0].page.plot_transparency = true;
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}");
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:?}");
// The plot's own option wins over the page setup.
let off = serde_json::json!({"compress": false, "plotTransparency": false});
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &off).unwrap()).contains("ExtGState"));
assert!(alphas(&d, &serde_json::json!({"compress": false, "plotTransparency": false})).is_empty());
}
#[test]
@@ -2518,11 +2504,14 @@ 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!(glass.contains("/T50 gs"), "{glass}");
assert_eq!(glass.matches("S\n").count(), 2, "{glass}");
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}");
// Opaque: one path as before, whether the option is on or off.
let opaque = content(&crossing(0), true);
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
assert_eq!(pdf_read::count(&opaque, "S"), 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);
@@ -2559,11 +2548,15 @@ 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!(half.contains("/T50 gs"), "{half}");
assert_eq!(half.matches("S\n").count(), 2, "{half}");
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}");
// Opaque: one path, the same with the option on or off.
let opaque = content(&crossing_block(0), true);
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque, content(&crossing_block(0), false));
assert_eq!(content(&crossing_block(50), false), opaque);
}
@@ -2607,3 +2600,192 @@ fn drawing_without_insunits_stays_unitless() {
assert_eq!(insunits(&write_dxf(&Drawing::new_metric())), Some("4".into()));
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();
lines.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].trim().to_string(), c[1].trim().to_string())).collect()
}
/// The records of type `kind` in DXF pairs, each as its groups after the type.
fn dxf_records<'a>(pairs: &'a [(String, String)], kind: &str) -> Vec<&'a [(String, String)]> {
let mut out = Vec::new();
for (i, p) in pairs.iter().enumerate() {
if p.0 == "0" && p.1 == kind {
let rest = &pairs[i + 1..];
let end = rest.iter().position(|q| q.0 == "0").unwrap_or(rest.len());
out.push(&rest[..end]);
}
}
out
}
fn group<'a>(rec: &'a [(String, String)], code: &str) -> Option<&'a str> {
rec.iter().find(|p| p.0 == code).map(|p| p.1.as_str())
}
/// #1812: a file whose CLASSES start with VISUALSTYLE (500) and WIDGETX (501), with proxy objects
/// of both classes and of a number past its last class, and a proxy entity of WIDGETX.
fn proxy_file() -> Vec<u8> {
let text = String::from_utf8(write(&Drawing::new_metric(), "p.dxf").unwrap()).unwrap();
let mut pairs = dxf_pairs(&text);
let p = |c: &str, v: &str| (c.to_string(), v.to_string());
let class = |name: &str, cpp: &str| {
vec![p("0", "CLASS"), p("1", name), p("2", cpp), p("3", "ObjectDBX Classes"), p("90", "4095"), p("280", "1"), p("281", "0")]
};
let classes = pairs.iter().position(|q| q.0 == "2" && q.1 == "CLASSES").unwrap() + 1;
let mut injected = class("VISUALSTYLE", "AcDbVisualStyle");
injected.extend(class("WIDGETX", "AcDbWidgetX"));
pairs.splice(classes..classes, injected);
let objects = pairs.iter().position(|q| q.0 == "2" && q.1 == "OBJECTS").unwrap();
let nod = objects + 1;
let nod_handle = pairs[nod + 1].1.clone();
let after = nod + pairs[nod..].iter().position(|q| q.0 == "281").unwrap() + 1;
pairs.splice(after..after, [p("3", "ACAD_VISUALSTYLE"), p("350", "AAA0")]);
let proxy = |h: &str, class: &str, data: &str| {
vec![
p("0", "ACAD_PROXY_OBJECT"),
p("5", h),
// An application's 102 group before the proxy's own groups holds a 91 of its own.
p("102", "{MYAPP"),
p("91", "7"),
p("102", "}"),
p("330", "AAA0"),
p("100", "AcDbProxyObject"),
p("90", "499"),
p("91", class),
p("95", "0"),
p("70", "0"),
p("92", "8"),
p("310", data),
p("94", "0"),
]
};
let mut objs = vec![
p("0", "DICTIONARY"),
p("5", "AAA0"),
p("330", &nod_handle),
p("100", "AcDbDictionary"),
p("281", "1"),
p("3", "Realistic"),
p("350", "AAA1"),
p("3", "Widget"),
p("350", "AAA2"),
p("3", "Stray"),
p("350", "AAA3"),
];
objs.extend(proxy("AAA1", "500", "AA"));
objs.extend(proxy("AAA2", "501", "BB"));
objs.extend(proxy("AAA3", "537", "CC"));
let end = objects + pairs[objects..].iter().position(|q| q.0 == "0" && q.1 == "ENDSEC").unwrap();
pairs.splice(end..end, objs);
let ms = {
let recs = dxf_records(&pairs, "BLOCK_RECORD");
group(recs.iter().find(|r| group(r, "2") == Some("*Model_Space")).unwrap(), "5").unwrap().to_string()
};
let ents = pairs.iter().position(|q| q.0 == "2" && q.1 == "ENTITIES").unwrap() + 1;
pairs.splice(
ents..ents,
[
p("0", "ACAD_PROXY_ENTITY"),
p("5", "AAA4"),
p("330", &ms),
p("100", "AcDbEntity"),
p("8", "0"),
p("100", "AcDbProxyEntity"),
p("90", "498"),
p("91", "501"),
p("92", "0"),
p("93", "0"),
p("94", "0"),
p("95", "0"),
p("70", "0"),
],
);
pairs.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
}
#[test]
fn proxies_keep_their_class_when_classes_are_rewritten() {
let d = read(&proxy_file(), "p.dxf").unwrap();
let text = String::from_utf8(write(&d, "out.dxf").unwrap()).unwrap();
let pairs = dxf_pairs(&text);
let classes: Vec<String> = dxf_records(&pairs, "CLASS").iter().map(|r| group(r, "1").unwrap().to_string()).collect();
let class_of = |n: &str| classes.get(n.parse::<usize>().unwrap() - 500).cloned();
// The proxy's own 91 (after its subclass marker); the application group's stays as it was.
let own_91 = |r: &[(String, String)]| r.iter().skip_while(|q| q.1 != "AcDbProxyObject").find(|q| q.0 == "91").map(|q| q.1.clone()).unwrap();
let objects: Vec<(String, Option<String>)> =
dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().map(|r| (group(r, "310").unwrap().to_string(), class_of(&own_91(r)))).collect();
assert!(dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().all(|r| group(r, "91") == Some("7")), "the application's 91 changed");
assert_eq!(
objects,
[("AA".to_string(), Some("VISUALSTYLE".to_string())), ("BB".to_string(), Some("WIDGETX".to_string()))],
"classes {classes:?}"
);
let entities: Vec<Option<String>> = dxf_records(&pairs, "ACAD_PROXY_ENTITY").iter().map(|r| class_of(group(r, "91").unwrap())).collect();
assert_eq!(entities, [Some("WIDGETX".to_string())]);
// The proxy whose number named no class is gone, with its dictionary entry.
assert!(!text.contains("\nStray\n"));
// And it stays so through another save.
let again = String::from_utf8(write(&read(text.as_bytes(), "out.dxf").unwrap(), "again.dxf").unwrap()).unwrap();
let pairs = dxf_pairs(&again);
let classes: Vec<String> = dxf_records(&pairs, "CLASS").iter().map(|r| group(r, "1").unwrap().to_string()).collect();
let names: Vec<String> =
dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().map(|r| classes[own_91(r).parse::<usize>().unwrap() - 500].clone()).collect();
assert_eq!(names, ["VISUALSTYLE", "WIDGETX"]);
}
+290
View File
@@ -0,0 +1,290 @@
//! #1651: angular, diameter and radius dimensions are saved with a DIMASSOC object, as linear
//! and ordinate dimensions already are, so AutoCAD follows their geometry. The standard objects
//! alone (CADCraft xdata stripped) must associate the same points, in DXF and in DWG.
use cadcraft_doc::{AssocSnap, Circle, Common, DimAssoc, DimKind, Dimension, Drawing, EntityKind, Handle, Line, Space};
use cadcraft_geom::Vec3;
use cadcraft_io::{Version, read, read_dxf, write_dxf};
/// A dimension entity of the given kind with all its definition points and associations, for
/// adding straight to a drawing; the style and text are left at defaults since only the
/// geometry and associations are under test.
fn dim(kind: DimKind, defpt: Vec3, p13: Vec3, p14: Vec3, p15: Vec3, p16: Vec3, assoc: Vec<DimAssoc>) -> EntityKind {
EntityKind::Dimension(Dimension {
kind,
defpt,
text_mid: Vec3::ZERO,
p13,
p14,
p15,
p16,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc,
marks: Default::default(),
})
}
/// The drawing of the issue's repro: a circle with a diameter and a radius dimension, two lines
/// with an angular dimension between them, and a line with a linear dimension. The associations
/// are the ones the commands make (`assoc::radial_assoc` and `assoc::reassociate`).
struct Case {
d: Drawing,
circle: Handle,
l1: Handle,
l2: Handle,
hd: Handle,
hr: Handle,
ha: Handle,
hl: Handle,
}
fn case() -> Case {
let mut d = Drawing::new_metric();
let c = Common::default;
let circle = d.add(&Space::Model, c(), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 30.0 })).unwrap();
let l1 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 0.0), b: Vec3::new(200.0, 0.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 0.0), b: Vec3::new(180.0, 60.0, 0.0) })).unwrap();
let l3 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(0.0, -80.0, 0.0), b: Vec3::new(100.0, -80.0, 0.0) })).unwrap();
let on = |point: &str, angle: f64| DimAssoc { point: point.into(), handle: circle, snap: AssocSnap::OnCircle { angle } };
let diameter = dim(
DimKind::Diameter,
Vec3::new(30.0, 0.0, 0.0),
Vec3::ZERO,
Vec3::ZERO,
Vec3::new(-30.0, 0.0, 0.0),
Vec3::ZERO,
vec![on("defpt", 0.0), on("p15", std::f64::consts::PI)],
);
let side = 30.0 / std::f64::consts::SQRT_2;
let radius = dim(
DimKind::Radius,
Vec3::ZERO,
Vec3::ZERO,
Vec3::ZERO,
Vec3::new(-side, -side, 0.0),
Vec3::ZERO,
vec![DimAssoc { point: "defpt".into(), handle: circle, snap: AssocSnap::Center }, on("p15", 225f64.to_radians())],
);
let angular = dim(
DimKind::Angular3P,
Vec3::new(160.0, 20.0, 0.0),
Vec3::new(200.0, 0.0, 0.0),
Vec3::new(180.0, 60.0, 0.0),
Vec3::new(100.0, 0.0, 0.0),
Vec3::ZERO,
vec![
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::End },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::End },
DimAssoc { point: "p15".into(), handle: l1, snap: AssocSnap::Intersection { other: l2 } },
],
);
let linear = dim(
DimKind::Linear { rotation: 0.0 },
Vec3::new(50.0, -100.0, 0.0),
Vec3::new(0.0, -80.0, 0.0),
Vec3::new(100.0, -80.0, 0.0),
Vec3::ZERO,
Vec3::ZERO,
vec![
DimAssoc { point: "p13".into(), handle: l3, snap: AssocSnap::Start },
DimAssoc { point: "p14".into(), handle: l3, snap: AssocSnap::End },
],
);
let hd = d.add(&Space::Model, Common::default(), diameter).unwrap();
let hr = d.add(&Space::Model, Common::default(), radius).unwrap();
let ha = d.add(&Space::Model, Common::default(), angular).unwrap();
let hl = d.add(&Space::Model, Common::default(), linear).unwrap();
Case { d, circle, l1, l2, hd, hr, ha, hl }
}
/// The DXF tag pairs, `(code, value)`.
fn pairs(text: &str) -> Vec<(i32, String)> {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
for chunk in lines.chunks(2) {
let [code, value] = chunk else { break };
let Ok(c) = code.trim().parse::<i32>() else { continue };
out.push((c, value.trim().to_string()));
}
out
}
/// The records of a DXF (0-group name and the groups that follow it).
fn records(text: &str) -> Vec<Vec<(i32, String)>> {
let mut out = Vec::new();
let mut cur: Option<Vec<(i32, String)>> = None;
for (c, v) in pairs(text) {
if c == 0 {
if let Some(r) = cur.take() {
out.push(r);
}
cur = Some(vec![(c, v)]);
} else if let Some(r) = cur.as_mut() {
r.push((c, v));
}
}
if let Some(r) = cur {
out.push(r);
}
out
}
/// The DIMASSOC object of a dimension, if the file has one.
fn dimassoc(text: &str, dim: Handle) -> Option<Vec<(i32, String)>> {
let hex = dim.hex();
records(text)
.into_iter()
.filter(|r| r.first().is_some_and(|(c, v)| *c == 0 && v == "DIMASSOC"))
.find(|r| r.iter().rfind(|(c, _)| *c == 330).is_some_and(|(_, v)| v.eq_ignore_ascii_case(&hex)))
}
/// The AcDbOsnapPointRef blocks of a DIMASSOC record.
fn point_refs(rec: &[(i32, String)]) -> Vec<Vec<(i32, String)>> {
let mut out = Vec::new();
let mut cur: Option<Vec<(i32, String)>> = None;
for (c, v) in rec {
if *c == 1 && v == "AcDbOsnapPointRef" {
if let Some(r) = cur.take() {
out.push(r);
}
cur = Some(Vec::new());
} else if let Some(r) = cur.as_mut() {
r.push((*c, v.clone()));
}
}
if let Some(r) = cur {
out.push(r);
}
out
}
/// The first integer value of a group code in a record (72 the osnap type, 90 the flags).
fn int(rec: &[(i32, String)], code: i32) -> Option<i64> {
rec.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.parse().ok())
}
/// The first string value of a group code in a record (331 the referenced entity, 332 the other
/// entity of an intersection).
fn string(rec: &[(i32, String)], code: i32) -> Option<&str> {
rec.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str())
}
/// A DXF without CADCraft's xdata, as a file from another writer (whose DIMASSOC objects must
/// stand on their own).
fn without_cadcraft_xdata(text: &str) -> String {
let mut out = String::new();
let mut skipping = false;
for (c, v) in pairs(text) {
if c == 1001 {
skipping = v == "CADCRAFT";
} else if c < 1000 {
skipping = false;
}
if !skipping {
out.push_str(&format!("{c}\n{v}\n"));
}
}
out
}
/// The associations a read-back dimension carries, or none if the handle is not a dimension.
fn assoc_of(d: &Drawing, h: Handle) -> Vec<DimAssoc> {
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Dimension(dm)) => dm.assoc.clone(),
_ => Vec::new(),
}
}
/// Whether two angles name the same direction, allowing for a whole turn and rounding.
fn same_angle(a: f64, b: f64) -> bool {
let turn = std::f64::consts::TAU;
let d = (a - b).rem_euclid(turn);
d.min(turn - d) < 1e-6
}
/// The associations the standard objects give, without CADCraft's xdata.
fn assert_standard_assoc(c: &Case, d: &Drawing, what: &str) {
let dia = assoc_of(d, c.hd);
assert_eq!(dia.len(), 2, "{what}: diameter defpt and p15");
assert!(dia.iter().all(|a| a.handle == c.circle), "{what}: diameter follows the circle: {dia:?}");
assert!(matches!(dia[0].snap, AssocSnap::OnCircle { angle } if same_angle(angle, 0.0)), "{what}: {:?}", dia[0]);
assert!(matches!(dia[1].snap, AssocSnap::OnCircle { angle } if same_angle(angle, std::f64::consts::PI)), "{what}: {:?}", dia[1]);
let rad = assoc_of(d, c.hr);
assert_eq!(rad.len(), 2, "{what}: radius defpt and p15");
assert_eq!(rad[0].handle, c.circle, "{what}: radius centre: {rad:?}");
assert!(matches!(rad[0].snap, AssocSnap::Center), "{what}: {:?}", rad[0]);
assert!(matches!(rad[1].snap, AssocSnap::OnCircle { angle } if same_angle(angle, 225f64.to_radians())), "{what}: {:?}", rad[1]);
let ang = assoc_of(d, c.ha);
assert_eq!(ang.len(), 3, "{what}: angular leg points and vertex");
assert_eq!((ang[0].handle, &ang[0].snap), (c.l1, &AssocSnap::End), "{what}: {ang:?}");
assert_eq!((ang[1].handle, &ang[1].snap), (c.l2, &AssocSnap::End), "{what}: {ang:?}");
assert_eq!(ang[2].handle, c.l1, "{what}: the vertex is the lines' intersection: {ang:?}");
assert!(matches!(ang[2].snap, AssocSnap::Intersection { other } if other == c.l2), "{what}: {ang:?}");
let lin = assoc_of(d, c.hl);
assert_eq!(lin.len(), 2, "{what}: linear is unchanged");
assert_eq!((lin[0].handle, &lin[0].snap), (lin[1].handle, &AssocSnap::Start), "{what}: {lin:?}");
assert!(matches!(lin[1].snap, AssocSnap::End), "{what}: {lin:?}");
}
/// The writer half of #1651: the DXF of the repro's drawing has one DIMASSOC per dimension, with
/// the references AutoCAD writes for each kind -- one centre reference (flag 2) for radius and
/// diameter, three for an angular dimension (both leg ends and the lines' intersection), and the
/// two origins of the linear control. Guards the kind-to-slot table and the radial/diametric
/// branch in `std_assoc_refs`, which used to emit no reference for those kinds at all.
#[test]
fn angular_diameter_and_radius_dimensions_are_saved_with_dimassoc() {
let c = case();
let text = write_dxf(&c.d);
let dia = dimassoc(&text, c.hd).unwrap_or_else(|| panic!("diameter dimension {} has a DIMASSOC", c.hd.hex()));
let rad = dimassoc(&text, c.hr).unwrap_or_else(|| panic!("radius dimension {} has a DIMASSOC", c.hr.hex()));
let ang = dimassoc(&text, c.ha).unwrap_or_else(|| panic!("angular dimension {} has a DIMASSOC", c.ha.hex()));
assert!(dimassoc(&text, c.hl).is_some(), "the linear control still has one");
// Radial and diametric dimensions associate their circle once, by its centre, as AutoCAD
// writes them (90 = 2).
for (dm, what) in [(&dia, "diameter"), (&rad, "radius")] {
let refs = point_refs(dm);
assert_eq!(int(dm, 90), Some(2), "{what}: associativity flag");
assert_eq!(refs.len(), 1, "{what}: one reference");
assert_eq!(int(&refs[0], 72), Some(3), "{what}: a centre snap");
assert!(string(&refs[0], 331).is_some_and(|h| h.eq_ignore_ascii_case(&c.circle.hex())), "{what}: to the circle");
}
// The angular dimension associates its two leg points and their intersection (90 = 7).
let refs = point_refs(&ang);
assert_eq!(int(&ang, 90), Some(7), "angular: three references");
assert_eq!(refs.len(), 3, "angular: three references");
for (i, (osnap, line)) in [(1, c.l1), (1, c.l2), (6, c.l1)].into_iter().enumerate() {
assert_eq!(int(&refs[i], 72), Some(osnap), "angular ref {i}");
assert!(string(&refs[i], 331).is_some_and(|h| h.eq_ignore_ascii_case(&line.hex())), "angular ref {i}: to its line");
}
assert!(string(&refs[2], 332).is_some_and(|h| h.eq_ignore_ascii_case(&c.l2.hex())), "angular: the intersection's other line");
}
/// The reader half of #1651, and the one that matters for interchange: with CADCraft's xdata
/// stripped, the standard DIMASSOC objects alone must rebuild the same associations, both from
/// DXF and through the DWG bridge. Guards the dimension-kind slot table and the radial centre
/// reference in `dxf_read::dimassoc`, plus the curve start/end osnap semantics in `infer_snap`.
#[test]
fn standard_dimassoc_associates_in_dxf_and_dwg() {
let c = case();
let plain = without_cadcraft_xdata(&write_dxf(&c.d));
let back = read_dxf(plain.as_bytes()).unwrap();
assert_standard_assoc(&c, &back, "DXF");
let dwg = cadcraft_dwg::dxf_to_dwg_as(plain.as_bytes(), Version::R2018.code()).unwrap();
let back = read(&dwg, "case.dwg").unwrap();
assert_standard_assoc(&c, &back, "DWG");
}
+82
View File
@@ -0,0 +1,82 @@
//! A drawing whose active layout is not the first tab keeps it active when saved: its block is
//! the one written as `*Paper_Space` (the DWG header's paper space block), the others
//! `*Paper_SpaceN`. Saving used to make the first tab the active one again.
use std::io::Cursor;
use cadcraft_doc::{Common, Drawing, EntityKind, Layout, Line, Space};
use cadcraft_geom::Vec3;
use cadcraft_io::{SaveOptions, Version, read, write_with};
fn three_sheets() -> Drawing {
let mut d = Drawing::new_metric();
d.layouts = vec![Layout::new("S1", 1), Layout::new("S2", 2), Layout::new("S3", 3)];
let line = |a: f64| EntityKind::Line(Line { a: Vec3::new(a, 0.0, 0.0), b: Vec3::new(a + 10.0, 10.0, 0.0) });
d.add(&Space::Paper("S1".into()), Common::default(), line(0.0)).unwrap();
d.add(&Space::Paper("S2".into()), Common::default(), line(20.0)).unwrap();
d.add(&Space::Paper("S2".into()), Common::default(), line(40.0)).unwrap();
d
}
/// The layout the DWG marks active: the one whose block the header names as paper space.
fn dwg_active_layout(dwg: &[u8]) -> String {
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.to_vec())).read().unwrap();
let ps = doc.header.paper_space_block_handle;
doc.objects
.iter()
.find_map(|(_, o)| match o {
acadrust::objects::ObjectType::Layout(l) if l.block_record == ps => Some(l.name.clone()),
_ => None,
})
.unwrap()
}
fn dwg(d: &Drawing) -> Vec<u8> {
write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap()
}
#[test]
fn the_active_layout_stays_active_through_dwg_saves() {
let mut d = three_sheets();
d.active_layout = Some("S2".into());
let first = dwg(&d);
assert_eq!(dwg_active_layout(&first), "S2");
// Opened again and saved again, as a drawing from another program would be.
let back = read(&first, "case.dwg").unwrap();
assert_eq!(back.active_layout.as_deref(), Some("S2"));
let count = |d: &Drawing, n: &str| d.layouts.iter().find(|l| l.name == n).map_or(0, |l| l.entities.len());
assert_eq!((count(&back, "S1"), count(&back, "S2"), count(&back, "S3")), (1, 2, 0));
let mut tabs: Vec<(u32, &str)> = back.layouts.iter().map(|l| (l.tab_order, l.name.as_str())).collect();
tabs.sort();
assert_eq!(tabs.iter().map(|t| t.1).collect::<Vec<_>>(), ["S1", "S2", "S3"], "the tab order is kept");
let second = dwg(&back);
assert_eq!(dwg_active_layout(&second), "S2");
let again = read(&second, "case.dwg").unwrap();
assert_eq!(again.active_layout.as_deref(), Some("S2"));
assert_eq!((count(&again, "S1"), count(&again, "S2"), count(&again, "S3")), (1, 2, 0));
}
#[test]
fn the_active_layout_survives_dxf_too() {
let mut d = three_sheets();
d.active_layout = Some("S3".into());
let dxf = write_with(&d, "case.dxf", &SaveOptions::default()).unwrap();
let back = read(&dxf, "case.dxf").unwrap();
assert_eq!(back.active_layout.as_deref(), Some("S3"));
}
#[test]
fn without_an_active_layout_the_first_tab_is_active() {
let mut d = three_sheets();
d.active_layout = Some("gone".into());
assert_eq!(dwg_active_layout(&dwg(&d)), "S1");
}
#[test]
fn the_active_layout_name_is_matched_ignoring_case() {
let mut d = three_sheets();
d.active_layout = Some("s2".into());
assert_eq!(dwg_active_layout(&dwg(&d)), "S2");
}
@@ -0,0 +1,62 @@
//! A polyline's vertices come back in the order its own vertex list stores them, not sorted by
//! handle (#1856). A vertex inserted into an existing polyline gets a newer, higher handle, so a
//! handle-ordered read silently reorders the outline (wrong shape, longer length).
//!
//! The DWG is written here with acadrust, giving the second 3D-polyline vertex the highest
//! handle: exactly what an editing program produces when it inserts a vertex.
#![cfg(not(target_arch = "wasm32"))]
use acadrust::entities::{EntityType, Polyline3D, Vertex3DPolyline};
use acadrust::types::{DxfVersion, Handle, Vector3};
use cadcraft_doc::EntityKind;
use cadcraft_geom::Vec3;
/// A drawing with one 6-vertex 3D polyline whose vertex handles are not ascending: the second
/// vertex is the newest (highest) handle.
fn dwg_with_out_of_order_vertex_handles(version: DxfVersion) -> Vec<u8> {
let mut doc = acadrust::CadDocument::with_version(version);
let handles = [0x113C, 0x1141, 0x113D, 0x113E, 0x113F, 0x1140];
let points = [(0.0, 0.0, 0.0), (10.0, 0.0, 1.0), (10.0, 10.0, 2.0), (0.0, 10.0, 3.0), (0.0, 20.0, 4.0), (10.0, 20.0, 5.0)];
let mut polyline = Polyline3D::new();
polyline.vertices = handles
.iter()
.zip(points)
.map(|(handle, (x, y, z))| Vertex3DPolyline {
handle: Handle::new(*handle),
layer: "0".to_string(),
position: Vector3::new(x, y, z),
flags: 32,
})
.collect();
doc.add_entity(EntityType::Polyline3D(polyline)).expect("add the polyline");
acadrust::DwgWriter::write_to_vec(&doc).expect("write the DWG")
}
/// Reads `dwg` through `cadcraft_io::read` and returns the points of its only 3D polyline, in the
/// order the reader produced them; panics if the drawing cannot be read or holds no 3D polyline,
/// since either would mean the test is no longer exercising vertex order.
fn polyline3d_points(dwg: &[u8]) -> Vec<Vec3> {
let drawing = cadcraft_io::read(dwg, "x.dwg").expect("read the DWG");
let polyline = drawing.model.iter().find_map(|e| if let EntityKind::Polyline3d(p) = &e.kind { Some(p) } else { None });
polyline.expect("one 3D polyline").points.clone()
}
/// Regression guard for #1856, run for both DWG versions the writer emits (AC1018 and AC1032): the
/// reader must keep a polyline's vertices in the order the entity's own vertex list lists them,
/// even when a mid-outline vertex carries the highest handle. Ordering by handle would move the
/// second point to the end, changing the outline's shape and length.
#[test]
fn dwg_read_keeps_polyline_vertex_order() {
let expected = vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(10.0, 0.0, 1.0),
Vec3::new(10.0, 10.0, 2.0),
Vec3::new(0.0, 10.0, 3.0),
Vec3::new(0.0, 20.0, 4.0),
Vec3::new(10.0, 20.0, 5.0),
];
for version in [DxfVersion::AC1018, DxfVersion::AC1032] {
let dwg = dwg_with_out_of_order_vertex_handles(version);
assert_eq!(polyline3d_points(&dwg), expected, "{version:?}");
}
}
@@ -0,0 +1,147 @@
//! #1813: AutoCAD always writes an `AcDbVariableDictionary` in the named object dictionary;
//! ezdxf, and CADCraft's own DXF writer, do not. Vectric Aspire 10.5 reads an AC1018 DWG without
//! it as empty ("No data was imported..."), though it reads the same drawing as DXF (and as an
//! AC1032 DWG) fine. The DWG bridge adds the dictionary AutoCAD writes, with its usual variables,
//! when the DXF has none.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Cursor;
use cadcraft_doc::{Common, Drawing, EntityKind, LwPolyline, Space};
use cadcraft_geom::{PolyVertex, Vec2};
use cadcraft_io::{SaveOptions, Version, write_with};
/// ezdxf's rectangle: one closed LWPOLYLINE in model space.
fn rectangle() -> Drawing {
let mut d = Drawing::new_metric();
let corners = [(0.0, 0.0), (4.0, 0.0), (4.0, 2.0), (0.0, 2.0)];
let pl = LwPolyline {
vertices: corners.iter().map(|(x, y)| PolyVertex::new(Vec2::new(*x, *y))).collect(),
closed: true,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
};
d.add(&Space::Model, Common::default(), EntityKind::LwPolyline(pl)).expect("add the rectangle");
d
}
/// The drawing variables in the named object dictionary, with their values, or nothing when the
/// dictionary is missing. Dictionary keys are case-insensitive in AutoCAD; its own spelling is
/// `AcDbVariableDictionary` (the DXF Reference calls it the ACDBVARIABLEDICTIONARY).
fn variable_dictionary(doc: &acadrust::CadDocument) -> Option<Vec<(String, String)>> {
let acadrust::objects::ObjectType::Dictionary(root) = doc.objects.get(&doc.header.named_objects_dict_handle)? else {
return None;
};
let acadrust::objects::ObjectType::Dictionary(vars) = doc.objects.get(&root.get("AcDbVariableDictionary")?)? else {
return None;
};
Some(
vars.entries
.iter()
.filter_map(|(name, handle)| match doc.objects.get(handle) {
Some(acadrust::objects::ObjectType::DictionaryVariable(v)) => Some((name.clone(), v.value.clone())),
_ => None,
})
.collect(),
)
}
/// Read a DWG back with acadrust's reader, so the assertions inspect the file as another
/// application would rather than through CADCraft's own importer.
fn read_dwg(dwg: &[u8]) -> acadrust::CadDocument {
acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().expect("read the DWG back")
}
/// The value of the variable called `name`, matched case-insensitively as AutoCAD matches
/// dictionary keys, or nothing when the dictionary has no such entry.
fn value<'a>(vars: &'a [(String, String)], name: &str) -> Option<&'a str> {
vars.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
}
/// #1813's regression: a DXF written without an `AcDbVariableDictionary` (as ezdxf and CADCraft
/// write them) must gain AutoCAD's dictionary when exported as an AC1018 DWG, so readers that
/// require it see the drawing's contents; the added objects must not break CADCraft's own read.
#[test]
fn an_ac1018_dwg_gets_the_variable_dictionary_autocad_always_writes() {
let d = rectangle();
let dxf = write_with(&d, "rect.dxf", &SaveOptions { version: Some(Version::R2000), binary: false }).expect("write the DXF");
let text = String::from_utf8_lossy(&dxf).to_ascii_lowercase();
assert!(!text.contains("acdbvariabledictionary"), "this DXF is one without the dictionary, like ezdxf's");
let dwg = write_with(&d, "rect.dwg", &SaveOptions { version: Some(Version::R2004), binary: false }).expect("write the DWG");
assert!(dwg.starts_with(b"AC1018"), "the DWG is AutoCAD 2004");
let doc = read_dwg(&dwg);
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
// The variables AutoCAD keeps there, by name (Aspire needs at least the dictionary; these
// are the ones the reference application writes).
for (name, want) in [("CMLEADERSTYLE", "Standard"), ("CTABLESTYLE", "Standard"), ("CANNOSCALE", "1:1"), ("DIMASSOC", "2"), ("HIDETEXT", "1")] {
assert_eq!(value(&vars, name), Some(want), "{name} in {vars:?}");
}
assert!(value(&vars, "LAYEREVAL").is_some() && value(&vars, "LAYERNOTIFY").is_some(), "{vars:?}");
// The added objects don't disturb CADCraft's own read of the file: the rectangle is there.
let back = cadcraft_io::read(&dwg, "rect.dwg").expect("CADCraft reads its own 2004 DWG");
assert_eq!(back.model.len(), 1);
}
/// The dictionary must carry the drawing's own `$DIMASSOC` and `$HIDETEXT`, not AutoCAD's
/// defaults: a drawing that turned text hiding off or stores dimensions non-associatively keeps
/// those settings for every consumer that reads them from the dictionary.
#[test]
fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() {
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n1\n9\n$HIDETEXT\n70\n0\n0\nENDSEC\n0\nEOF\n";
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
let doc = read_dwg(&dwg);
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
assert_eq!(value(&vars, "DIMASSOC"), Some("1"), "DIMASSOC in {vars:?}");
assert_eq!(value(&vars, "HIDETEXT"), Some("0"), "HIDETEXT in {vars:?}");
// The DWG's own header settings match the dictionary, so both kinds of consumer agree.
assert_eq!(doc.header.dimension_associativity, 1);
assert_eq!(doc.header.hide_text, 0);
}
/// A header variable holding a value the variable doesn't accept is written with AutoCAD's
/// default, as a hostile file can put any integer there.
#[test]
fn impossible_header_values_fall_back_to_the_defaults() {
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n9\n9\n$HIDETEXT\n70\n7\n0\nENDSEC\n0\nEOF\n";
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
let doc = read_dwg(&dwg);
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
assert_eq!(value(&vars, "DIMASSOC"), Some("2"), "DIMASSOC in {vars:?}");
assert_eq!(value(&vars, "HIDETEXT"), Some("1"), "HIDETEXT in {vars:?}");
}
/// The AC1032 (AutoCAD 2018) path is deliberately unchanged: those DWGs read in Vectric Aspire
/// without the dictionary, so no dictionary is injected and the existing output stays as it was.
#[test]
fn an_ac1032_dwg_stays_as_it_was() {
// 2018 DWGs read fine in Aspire without the dictionary, so the latest format is unchanged.
let d = rectangle();
let dwg = write_with(&d, "rect.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("write the DWG");
assert!(dwg.starts_with(b"AC1032"));
let doc = read_dwg(&dwg);
assert_eq!(variable_dictionary(&doc), None, "no dictionary is added to a 2018 DWG");
}
/// A drawing that already has the dictionary must keep its own entries and values: conversion
/// only supplies AutoCAD's dictionary when it is absent, rather than merging defaults into one
/// that a producer such as ezdxf's `add_dict_var` already wrote.
#[test]
fn a_variable_dictionary_the_drawing_has_is_left_alone() {
// A drawing that already has the dictionary keeps its own values and entries (here only
// CMLEADERSTYLE, as ezdxf's add_dict_var("CMLEADERSTYLE", "Standard") writes it): a DWG
// export must not add defaults next to them.
let mut source = acadrust::CadDocument::new();
source.version = acadrust::DxfVersion::AC1015;
assert!(source.set_drawing_variable("CMLEADERSTYLE", "Notes"));
let dxf = acadrust::DxfWriter::new(&source).write_to_vec().expect("write a DXF with the dictionary");
let dwg = cadcraft_dwg::dxf_to_dwg_as(&dxf, "AC1018").expect("write the DWG");
let doc = read_dwg(&dwg);
let vars = variable_dictionary(&doc).expect("the drawing's dictionary is kept");
assert_eq!(vars.len(), 1, "{vars:?}");
assert_eq!(value(&vars, "CMLEADERSTYLE"), Some("Notes"), "{vars:?}");
}
+78 -2
View File
@@ -1,8 +1,8 @@
//! The anonymous `*D` block written for a dimension (what other programs draw) holds its text at
//! the height it is drawn at: the dimension's overrides and DIMSCALE (0 = the drawing's) count
//! (issue #367).
//! (issue #367). Angular text also keeps its decimal separator in the stored block (#1646).
use cadcraft_doc::{Common, DimKind, Dimension, Drawing, EntityKind, Space};
use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space};
use cadcraft_geom::Vec3;
/// The group 40 (height) of the first MTEXT in the BLOCKS section.
@@ -51,3 +51,79 @@ fn dimension_block_text_has_the_drawn_height() {
let zero = height_written(0.0, serde_json::json!({}));
assert!(near(zero, 0.18), "DIMSCALE 0 in model space is scale 1, not ~0: {zero}");
}
fn angular_drawing(separator_override: Option<&str>) -> Drawing {
let mut d = Drawing::new_metric();
d.dim_styles.push(DimStyle { name: "Comma".into(), decimal_separator: ",".into(), angular_decimals: 1, ..DimStyle::default() });
let angle = 39.4_f64.to_radians();
let mut dm = Dimension {
kind: DimKind::Angular3P,
defpt: Vec3::new(50.0 * (angle / 2.0).cos(), 50.0 * (angle / 2.0).sin(), 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::new(100.0, 0.0, 0.0),
p14: Vec3::new(100.0 * angle.cos(), 100.0 * angle.sin(), 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Comma".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
if let Some(separator) = separator_override {
dm.overrides.insert("DIMDSEP".into(), serde_json::json!(separator));
}
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
d
}
#[test]
fn dxf_angular_block_text_keeps_the_decimal_separator() {
for (separator_override, expected) in [(None, "39,4°"), (Some("."), "39.4°")] {
let dxf = cadcraft_io::write_dxf(&angular_drawing(separator_override));
let stored = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
// Inspect the saved block's MTEXT, without regenerating the dimension's text.
let texts: Vec<&str> = stored
.blocks
.values()
.filter(|b| b.name.starts_with("*D"))
.flat_map(|b| b.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::MText(t) => Some(t.contents.as_str()),
_ => None,
})
.collect();
assert_eq!(texts, [expected], "DIMDSEP override: {separator_override:?}");
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_angular_block_text_keeps_the_decimal_separator() {
use std::io::Cursor;
use acadrust::entities::EntityType;
use cadcraft_io::{SaveOptions, Version};
for (separator_override, expected) in [(None, "39,4°"), (Some("."), "39.4°")] {
let dwg =
cadcraft_io::write_with(&angular_drawing(separator_override), "angle.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })
.unwrap();
// DWG consumers use the stored *D geometry: read it directly, without CADCraft rendering it.
let stored = acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().unwrap();
let texts: Vec<&str> = stored
.entities()
.filter_map(|e| match e {
EntityType::MText(t) if stored.block_records.iter().any(|b| b.handle == t.common.owner_handle && b.name.starts_with("*D")) => {
Some(t.value.as_str())
}
_ => None,
})
.collect();
assert_eq!(texts, [expected], "DIMDSEP override: {separator_override:?}");
}
}
@@ -138,3 +138,24 @@ fn viewport_ids_survive_dwg_and_the_paper_viewport_stays_implicit() {
assert!(!text.contains("PAPERVIEW"));
assert_eq!(viewport_ids(&cadcraft_io::read_dxf(text.as_bytes()).unwrap().layouts[0]), [1, 2]);
}
/// The paper-space viewport (id 1) of a drawing from another program can come after a model
/// viewport in the layout, e.g. when the draw order (SORTENTS) put it behind. DWG numbers in
/// order, so the writer puts it first: otherwise the model viewport became the sheet on reopen.
#[test]
fn a_paper_viewport_behind_a_model_viewport_stays_id_1_through_dwg() {
let mut d = Drawing::new_metric();
let names: Vec<String> = d.layouts.iter().map(|l| l.name.clone()).collect();
for name in &names {
let space = Space::Paper(name.clone());
let model = d.add(&space, Common::default(), viewport(2)).unwrap();
let sheet = d.add(&space, Common::default(), viewport(1)).unwrap();
let back = through_dwg(&d);
let l = back.layouts.iter().find(|l| &l.name == name).unwrap();
let id = |h: Handle| match &l.entities.get(h).map(|e| &e.kind) {
Some(EntityKind::Viewport(v)) => Some(v.id),
_ => None,
};
assert_eq!((id(model), id(sheet)), (Some(2), Some(1)), "layout {name}");
}
}
+2 -2
View File
@@ -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 text = String::from_utf8_lossy(&bytes);
let doc = crate::pdf_read::read(&bytes);
let xs: Vec<f64> =
text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect();
crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).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");
+2
View File
@@ -45,6 +45,8 @@ 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;
+18 -16
View File
@@ -12,18 +12,22 @@ fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) {
d.add(&Space::Model, Common::default(), k).unwrap();
}
fn pdf_text(d: &Drawing, opts: Value) -> String {
fn pdf_doc(d: &Drawing, opts: Value) -> crate::pdf_read::Doc {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) {
m.extend(e.clone());
}
String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned()
crate::pdf_read::read(&plotter::plot(d, &Space::Model, &o).unwrap())
}
/// 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()
/// 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()
}
#[test]
@@ -32,17 +36,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_text(&d, json!({})));
let plain = clip(&pdf_doc(&d, json!({})));
assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}");
// A device paper with 20 mm margins: the printable area starts 20 mm in.
let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
let r = clip(&pdf_doc(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
assert_eq!(r.len(), 4, "{r:?}");
assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}");
let (w, h) = (r[2].min(r[3]), r[2].max(r[3]));
assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}");
// Hostile margins are ignored.
for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] {
assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}");
assert_eq!(clip(&pdf_doc(&d, json!({"margins": bad}))), plain, "{bad}");
}
}
@@ -107,13 +111,11 @@ 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 = 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"));
let pdf = uris(&pdf_doc(&read, json!({})));
assert_eq!(pdf, ["https://example.com/plan"], "only the web link");
// Turned off, and given explicitly for the same object (the explicit one wins).
assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0);
assert!(uris(&pdf_doc(&read, json!({"hyperlinks": false}))).is_empty());
let first = read.model.iter().next().unwrap().handle.hex();
let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]}));
assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}");
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:?}");
}
+131 -118
View File
@@ -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 parse the written objects.
//! linked entities get link annotations. The tests read the written file back.
use cadcraft_color::Color;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Space, Viewport};
@@ -9,57 +9,19 @@ use cadcraft_io::pdf::{MAX_PAGES, pdf_sheets};
use cadcraft_io::{PdfOptions, plot};
use serde_json::json;
/// 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
use crate::pdf_read::{self, Doc};
fn read(bytes: &[u8]) -> Doc {
pdf_read::read(bytes)
}
fn object(objs: &[(usize, String)], n: usize) -> &str {
&objs.iter().find(|(i, _)| *i == n).unwrap().1
fn ocg_names(doc: &Doc) -> Vec<String> {
doc.ocgs.iter().map(|g| g.name.clone()).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()
/// 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()
}
fn add_line(d: &mut Drawing, space: &Space, layer: &str, a: (f64, f64), b: (f64, f64)) -> Handle {
@@ -109,45 +71,66 @@ fn raw(extra: serde_json::Value) -> serde_json::Value {
#[test]
fn drawing_layers_become_pdf_layers() {
let d = layered();
let objs = objects(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
// Plotted layers only, in drawing order of first use; block contents on layer 0 are on
// 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 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 page maps its marks to the groups, and the marked sections are balanced.
// 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 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:?}");
}
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");
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");
}
#[test]
fn layers_that_are_off_are_included_hidden_on_request() {
let d = layered();
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"));
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);
}
#[test]
@@ -169,43 +152,50 @@ 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 objs = objects(&plot(&d, &paper, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
let names = ocg_names(&read(&plot(&d, &paper, &raw(json!({}))).unwrap()));
// 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 bytes = plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap();
let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
// "all": model (it has objects), then every layout in tab order.
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 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.
let c = contents(&objs);
assert!(c[0].contains("1 0 0 RG") && c[1].contains("1 0 0 RG"));
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 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);
// The library call does the same.
let o = PdfOptions { compress: false, ..PdfOptions::default() };
let objs2 = objects(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
assert!(catalog(&objs2).contains("0 << /P (Layout1) >> 1 << /P (Model) >>"));
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());
}
#[test]
@@ -230,28 +220,51 @@ fn hyperlinks_become_link_annotations() {
{"handle": h.hex(), "url": "https://example.com/ü"},
{"url": "https://example.com/no-handle"},
]);
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 ["));
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");
// The rectangle is inside the A4 landscape sheet and wide like the line.
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();
let v = annots[0].rect;
assert!(v[0] >= 0.0 && v[2] <= 841.9 && v[1] >= 0.0 && v[3] <= 595.3 && v[2] - v[0] > 100.0, "{v:?}");
}
#[test]
fn unusual_layer_names_and_titles_are_escaped() {
fn unusual_names() -> Drawing {
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));
}
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>")));
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));
}
+4 -3
View File
@@ -14,9 +14,10 @@ fn plotted_mm(opts: Value) -> f64 {
o.extend(e.clone());
}
let bytes = cadcraft_io::plot(&d, &Space::Paper("Layout1".into()), &o).unwrap();
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}");
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());
(xs[1] - xs[0]).abs() * 25.4 / 72.0
}
+2 -2
View File
@@ -15,14 +15,14 @@ fn layout_with_red_line() -> Drawing {
d
}
/// The uncompressed PDF of Layout1 with extra plot options.
/// The page content of Layout1's PDF 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();
String::from_utf8_lossy(&bytes).into_owned()
crate::pdf_read::read(&bytes).content().to_string()
}
#[test]
+91 -77
View File
@@ -10,6 +10,11 @@ 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) {
@@ -46,8 +51,9 @@ fn simple_glyph(pts: &[(i16, i16)]) -> Vec<u8> {
g
}
/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`.
fn test_font(fs_type: u16) -> Vec<u8> {
/// 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> {
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();
@@ -82,7 +88,7 @@ fn test_font(fs_type: u16) -> Vec<u8> {
be16(&mut maxp, v);
}
let mut hmtx = Vec::new();
for _ in 0..3 {
for _ in 0..metrics {
be16(&mut hmtx, 600);
be16(&mut hmtx, 50);
}
@@ -142,12 +148,14 @@ fn test_font(fs_type: u16) -> Vec<u8> {
out
}
/// Register the test fonts once: an embeddable one and one with a restricted licence.
/// 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).
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));
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002));
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));
});
}
@@ -178,77 +186,72 @@ 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 as text (offsets kept: non-ASCII bytes become `?`).
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> String {
/// The uncompressed PDF, read back.
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> Doc {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), extra.as_object()) {
m.extend(e.clone());
}
let bytes = plot(d, space, &o).unwrap();
check_xref(&bytes);
bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect()
pdf_read::read(&plot(d, space, &o).unwrap())
}
/// 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 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 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]]
})
/// 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<_>>())
.collect()
}
/// 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()
/// 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()
}
#[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 p = pdf(&d, &Space::Model, json!({}));
let doc = pdf(&d, &Space::Model, json!({}));
let page = &doc.pages[0];
// A Type0 font over a CIDFontType2 subset with a ToUnicode map.
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());
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]));
// No filled outlines left for them.
assert!(!p.contains("h\nf\n"), "glyph outlines are not drawn: {p}");
assert_eq!(fills(&doc), 0, "glyph outlines are not drawn: {}", page.content);
// The glyphs advance along the baseline by 600/1000 em, scaled like the outline would be.
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 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 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");
@@ -258,21 +261,12 @@ 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 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 doc = pdf(&d, &Space::Model, json!({}));
let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file");
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) 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);
// Glyph A kept, B (unused) left out: the subset is smaller than the font.
assert!(file.len() < test_font(0, 3).len());
}
#[test]
@@ -283,7 +277,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);
assert_eq!(m.len(), 3, "{m:?}");
// 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]);
@@ -297,21 +291,29 @@ 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!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "restricted licence: outlines");
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "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!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "text as geometry");
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "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!(!p.contains("BT\n") && p.contains(" S\n") || p.contains("\nS\n"));
assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0);
}
#[test]
@@ -337,14 +339,26 @@ 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!(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"));
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)));
}
#[test]
+518
View File
@@ -0,0 +1,518 @@
//! 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
}
+29
View File
@@ -4,12 +4,16 @@ use serde_json::{Value, json};
use crate::{Headless, Server};
/// Send one JSON-RPC request to the server and return the parsed reply; fails the test if the
/// server does not answer or answers with invalid JSON.
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let r = s.handle_line(&line).expect("reply");
serde_json::from_str(&r).unwrap()
}
/// Call an MCP tool and return its decoded result: JSON text is parsed, an image is passed
/// through, and a tool error fails the test with the reported message.
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
let r = call(s, 99, "tools/call", json!({"name": name, "arguments": args}));
let c = &r["result"]["content"][0];
@@ -17,6 +21,8 @@ fn tool(s: &mut Server, name: &str, args: Value) -> Value {
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
}
/// The MCP handshake works: `initialize` names the server, the `initialized` notification is
/// accepted, `tools/list` offers the command line, and an unknown method is -32601.
#[test]
fn lifecycle_and_tools() {
let mut s = Server::new(Box::new(Headless::default()));
@@ -30,6 +36,8 @@ fn lifecycle_and_tools() {
assert_eq!(bad["error"]["code"], -32601);
}
/// Guards that an agent can complete a drawing (plate, holes, layer, text) using only the MCP
/// tools, and verify it only through MCP: counts, selection queries, undo and a PNG render.
#[test]
fn agent_draws_a_plate_with_holes() {
let mut s = Server::new(Box::new(Headless::default()));
@@ -70,6 +78,8 @@ fn agent_draws_a_plate_with_holes() {
assert!(img["data"].as_str().unwrap().len() > 100);
}
/// Guards that bad input is reported, not fatal: a refused command and a screenshot without a
/// UI are `isError` results, and malformed JSON is a parse error (-32700).
#[test]
fn errors_are_tool_errors_not_crashes() {
let mut s = Server::new(Box::new(Headless::default()));
@@ -111,6 +121,19 @@ fn bad_tool_calls_are_invalid_params() {
assert_eq!(s.handle_line("[]").map(|r| serde_json::from_str::<Value>(&r).unwrap()["error"]["code"].clone()), Some(json!(-32600)));
}
/// `open` reads both DXF and DWG, so `tools/list` must advertise both (#1875).
#[test]
fn open_tool_description_names_dxf_and_dwg() {
let mut s = Server::new(Box::new(Headless::default()));
let tools = call(&mut s, 1, "tools/list", json!({}));
let open = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "open").unwrap();
let desc = open["description"].as_str().unwrap();
assert!(desc.contains("DXF"), "`open` must still name DXF: {desc}");
assert!(desc.contains("DWG"), "`open` reads DWG too: {desc}");
}
/// Guards that `query_entities` advertises its `selected` filter and honours it (active-space
/// selection only, both ways) without changing the drawing.
#[test]
fn query_entities_selection_filter_is_discoverable_and_forwarded() {
let mut s = Server::new(Box::new(Headless::default()));
@@ -149,6 +172,8 @@ fn query_entities_pages_and_windows_by_geometry() {
assert_eq!(r["count"], 1, "only the circle is entirely inside: {r}");
}
/// Guards that the `list_commands` filter matches ids, labels and aliases but not parameter
/// docs, so searching `circle` does not return QSELECT merely for mentioning circles.
#[test]
fn list_commands_filter_matches_names_not_docs() {
let mut s = Server::new(Box::new(Headless::default()));
@@ -160,6 +185,8 @@ fn list_commands_filter_matches_names_not_docs() {
assert!(r.as_array().unwrap().iter().any(|c| c["id"] == "pline"), "aliases and ids match");
}
/// Guards that `find_text` returns TEXT and MTEXT with their placement, strips MTEXT formatting,
/// and filters by text, layer and window; a windowed render keeps that area's aspect ratio.
#[test]
fn find_text_reads_texts_and_render_shows_one_area() {
let mut s = Server::new(Box::new(Headless::default()));
@@ -199,6 +226,8 @@ fn find_text_reads_texts_and_render_shows_one_area() {
assert_eq!((w, h), (1600, 800));
}
/// Guards a lazy Remote bridge: it serves tools without the app, reports how to start it
/// (`--control`), and reaches the app once that app starts listening on the port.
#[test]
fn a_lazy_bridge_starts_without_the_app_and_says_how_to_open_it() {
// Nothing listens on a port that was just freed.
+5 -1
View File
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
use crate::backend::Backend;
/// The `tools/list` payload: one definition (name, description, input schema) per MCP tool,
/// including the UI-only tools when `has_ui` says a window is available.
pub fn tool_definitions(has_ui: bool) -> Value {
let mut tools = vec![
json!({"name": "command_line", "description": "Type one line at the CADCraft command line, exactly as a user would at an AutoCAD-style prompt. Starts commands by name or alias (LINE, L, CIRCLE, C, PLINE, RECTANG, MOVE, TRIM, OFFSET, ZOOM…) and answers the active prompt: points `x,y`, relative `@dx,dy`, polar `@dist<angle`, distances, keywords (e.g. `c` for Close), empty text = Enter. Spaces act as Enter, so `circle 0,0 5` works in one call. Returns the new prompt and command-line output, and `error` when the line was refused.",
@@ -30,7 +32,7 @@ pub fn tool_definitions(has_ui: bool) -> Value {
"limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}),
json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).",
"inputSchema": {"type": "object", "properties": {"metric": {"type": "boolean"}}}}),
json!({"name": "open", "description": "Open a DXF file.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}),
json!({"name": "open", "description": "Open a DXF or DWG file.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}),
json!({"name": "save", "description": "Save the drawing (DXF or DWG by extension). A .svg, .png or .pdf path exports instead: the drawing keeps its name and unsaved changes.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}}),
json!({"name": "cancel", "description": "Press Escape: cancel the running command (or clear the selection).", "inputSchema": {"type": "object", "properties": {}}}),
];
@@ -42,10 +44,12 @@ pub fn tool_definitions(has_ui: bool) -> Value {
json!({ "tools": tools })
}
/// Wrap a JSON value as a successful MCP result containing pretty-printed text.
fn text(v: &Value) -> Value {
json!({"content": [{"type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default()}]})
}
/// Wrap an error message as an MCP tool error result (`isError`), not a protocol error.
fn err_text(e: &str) -> Value {
json!({"content": [{"type": "text", "text": e}], "isError": true})
}
+1 -1
View File
@@ -14,7 +14,7 @@ fn keys(v: &Value) -> Vec<String> {
/// The `cmdline.state` fields plus `extra`.
fn want(b: &mut Headless, extra: &[&str]) -> Vec<String> {
let mut k = keys(&b.call("cmdline.state", json!({})).unwrap());
k.extend(extra.iter().map(|s| s.to_string()));
k.extend(extra.iter().map(ToString::to_string));
k.sort();
k
}
+1 -1
View File
@@ -13,7 +13,7 @@ fn keys(v: &Value) -> Vec<String> {
}
fn want(extra: &[&str]) -> Vec<String> {
let mut k: Vec<String> = STATE_KEYS.iter().chain(extra).map(|s| s.to_string()).collect();
let mut k: Vec<String> = STATE_KEYS.iter().chain(extra).map(ToString::to_string).collect();
k.sort();
k
}
+42 -3
View File
@@ -10,7 +10,7 @@ use cadcraft_doc::{DimKind, DimStyle, Dimension};
use cadcraft_fonts::{MTextParams, TextFont};
use cadcraft_geom::{Arc, Vec2, ccw_sweep};
use crate::units::{NumFormat, format_angle, format_decimal, format_linear};
use crate::units::{NumFormat, format_angle_with_separator, format_decimal, format_linear};
/// Most DIMBREAK gaps drawn per dimension.
const MAX_BREAKS: usize = 1000;
@@ -53,6 +53,22 @@ pub struct DimGeometry {
}
impl DimGeometry {
/// `value` as the contents of a plain MTEXT (the dimension's `*D` block, its EXPLODE
/// pieces): the diameter sign as `%%c`, not the decoded ⌀ (U+2300), which no SHX font has,
/// so other programs plot nothing for it, while every font draws `%%c` (#1645). A `%`
/// that would run into a code is written `%%%`.
pub fn mtext_contents(&self) -> String {
let mut out = String::with_capacity(self.value.len() + 2);
let mut chars = self.value.chars().peekable();
while let Some(c) = chars.next() {
match c {
'⌀' => out.push_str("%%c"),
'%' if matches!(chars.peek(), Some('%' | '⌀')) => out.push_str("%%%"),
c => out.push(c),
}
}
out
}
fn dim(&mut self, l: Vec<Vec2>) {
if l.len() >= 2 {
self.lines.push(l);
@@ -141,7 +157,7 @@ pub enum Arrowhead {
impl Arrowhead {
/// Parse a DIMBLK value ("" / "_DOT" / "Architectural tick" / "closed blank"…).
pub fn parse(name: &str) -> Arrowhead {
let n: String = name.trim().trim_start_matches('_').chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let n: String = name.trim().trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
match n.as_str() {
"" | "closedfilled" | "filled" => Arrowhead::ClosedFilled,
"closedblank" | "blank" => Arrowhead::ClosedBlank,
@@ -887,7 +903,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
g.measurement = sweep;
// DIMAZIN 1 / 2 suppress leading / trailing zeros (DIMZIN's bits 4 / 8).
let azin = (st.angular_zero_suppression & 3) * 4;
let txt = format_angle(sweep, st.angular_unit, st.angular_decimals, azin);
let txt = format_angle_with_separator(sweep, st.angular_unit, st.angular_decimals, azin, &st.decimal_separator);
// DIMPOST is a linear-dimension prefix/suffix; angles have their own format only.
g.mtext = inspected(apply_override(&txt, d), d);
let mut pts = Vec::new();
@@ -1381,6 +1397,7 @@ mod tests {
d.defpt = Vec3::new(-3.0, 0.0, 0.0);
let g = dimension_geometry(&d, &st_with(|s| s.decimals = 1), 1.0);
assert_eq!(g.value, "⌀6.0");
assert_eq!(g.mtext_contents(), "%%c6.0", "the *D block's text (#1645)");
let mut a = lin((10.0, 0.0), (0.0, 10.0), (3.0, 3.0), 0.0);
a.kind = DimKind::Angular3P;
a.p15 = Vec3::ZERO;
@@ -1430,6 +1447,28 @@ mod tests {
let g = dimension_geometry(&d, &DimStyle::default(), 1.0);
assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines);
}
/// #1645: the MTEXT contents give back the dimension's text when decoded, with the diameter
/// sign as `%%c` (no SHX font has U+2300) and a `%` that would start a code escaped.
#[test]
fn mtext_contents_write_the_diameter_sign_as_a_code() {
let with = |v: &str| DimGeometry { value: v.into(), ..DimGeometry::default() };
for (value, contents) in [
("⌀60", "%%c60"),
("2x ⌀8.5 THRU", "2x %%c8.5 THRU"),
("R3.0", "R3.0"),
("36.9° ±0.1", "36.9° ±0.1"),
("50%", "50%"),
("5%⌀6", "5%%%%%c6"),
("%%", "%%%%"),
("", ""),
] {
let g = with(value);
assert_eq!(g.mtext_contents(), contents, "{value:?}");
assert_eq!(decode_value(&g.mtext_contents()), value, "{value:?} reads back");
assert!(!g.mtext_contents().contains('⌀'));
}
}
}
#[cfg(test)]
+9 -4
View File
@@ -188,8 +188,13 @@ pub fn format_linear(v: f64, f: &NumFormat) -> (String, String) {
}
}
/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC.
/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC, with a decimal point.
pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String {
format_angle_with_separator(rad, unit, decimals, zin, ".")
}
/// Format an angle with the dimension style's DIMDSEP separator.
pub(crate) fn format_angle_with_separator(rad: f64, unit: u8, decimals: u8, zin: u8, separator: &str) -> String {
let rad = if rad.is_finite() { rad } else { 0.0 };
let deg = rad.to_degrees();
match unit {
@@ -209,9 +214,9 @@ pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String {
_ => format!("{d}°{m}'{s}\""),
}
}
2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, ".")),
3 => format!("{}r", format_decimal(rad, decimals, zin, ".")),
_ => format!("{}°", format_decimal(deg, decimals, zin, ".")),
2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, separator)),
3 => format!("{}r", format_decimal(rad, decimals, zin, separator)),
_ => format!("{}°", format_decimal(deg, decimals, zin, separator)),
}
}
+56
View File
@@ -72,3 +72,59 @@ fn dimpost_is_not_added_to_angular_dimensions() {
a.text = "<> TYP".into();
assert_eq!(text(&a, serde_json::json!({"DIMPOST": "<>mm"})), "90° TYP");
}
fn angular(radians: f64) -> Dimension {
let mut d = linear(0.0);
d.kind = DimKind::Angular3P;
d.p13 = Vec3::new(10.0, 0.0, 0.0);
d.p14 = Vec3::new(10.0 * radians.cos(), 10.0 * radians.sin(), 0.0);
d.defpt = Vec3::new(5.0 * (radians / 2.0).cos(), 5.0 * (radians / 2.0).sin(), 0.0);
d
}
#[test]
fn angular_dimensions_use_the_style_decimal_separator() {
for (unit, radians, decimals, dot, comma) in
[(0, 39.4_f64.to_radians(), 1, "39.4°", "39,4°"), (2, std::f64::consts::FRAC_PI_4, 1, "50.0g", "50,0g"), (3, 1.25, 2, "1.25r", "1,25r")]
{
let three_point = angular(radians);
let mut two_line = three_point.clone();
two_line.kind = DimKind::Angular;
two_line.p16 = three_point.defpt;
two_line.p15 = three_point.p14;
two_line.p14 = three_point.p13;
two_line.p13 = Vec3::ZERO;
two_line.defpt = Vec3::ZERO;
for d in [three_point, two_line] {
let vars = serde_json::json!({"DIMAUNIT": unit, "DIMADEC": decimals});
assert_eq!(text(&d, vars.clone()), dot);
let mut vars = vars;
vars["DIMDSEP"] = serde_json::json!(",");
assert_eq!(text(&d, vars), comma);
}
}
}
#[test]
fn angular_decimal_separator_respects_overrides_and_literal_text() {
let mut d = angular(39.4_f64.to_radians());
d.text = "<> at 1.5".into();
d.overrides.insert("DIMDSEP".into(), serde_json::json!(","));
assert_eq!(text(&d, serde_json::json!({"DIMADEC": 1, "DIMDSEP": "."})), "39,4° at 1.5");
d.overrides.insert("DIMDSEP".into(), serde_json::json!("."));
assert_eq!(text(&d, serde_json::json!({"DIMADEC": 1, "DIMDSEP": ","})), "39.4° at 1.5");
}
#[test]
fn angular_zero_suppression_works_with_a_decimal_comma() {
let d = angular(0.5_f64.to_radians());
for (zin, expected) in [(0, "0,500°"), (1, ",500°"), (2, "0,5°"), (3, ",5°")] {
assert_eq!(text(&d, serde_json::json!({"DIMADEC": 3, "DIMAZIN": zin, "DIMDSEP": ","})), expected);
}
}
#[test]
fn angular_decimal_separator_keeps_dms_notation() {
let d = angular(45.5125_f64.to_radians());
assert_eq!(text(&d, serde_json::json!({"DIMAUNIT": 1, "DIMADEC": 3, "DIMDSEP": ","})), "45°30'45\"");
}
+1
View File
@@ -176,6 +176,7 @@ mod tests {
b.borrow_mut().push(name.to_string());
None
})),
..Services::default()
};
(CadApp::new(Session::new(), services), asked)
}
+6
View File
@@ -255,6 +255,7 @@
Drawing Template (DWT) Шаблон креслення (DWT)
Drawing Utilities Утиліти креслення
Drawings (DWG, DXF) Креслення (DWG, DXF)
AutoLISP (LSP) AutoLISP (LSP)
SVG drawings (SVG, SVGZ) Креслення SVG (SVG, SVGZ)
Dynamic Динамічно
Dynamic Dimensions Динамічні розміри
@@ -980,6 +981,10 @@
Reset keeps the interface language. Скидання не змінює мову інтерфейсу.
Reset to Defaults Скинути до типових
Save new drawings as Зберігати нові креслення як
Units of new drawings Одиниці нових креслень
Imperial (inches) Імперські (дюйми)
Metric (millimetres) Метричні (міліметри)
New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Команда «Створити» і перше креслення під час запуску використовують ці одиниці, якщо не задано типовий шаблон (QNEWTEMPLATE).
Screen magnifiers, screen readers and recorders follow the system cursor. Екранні лупи, програми читання з екрана та запису стежать за системним курсором.
Select objects first, then choose a command Спочатку вибирати об'єкти, потім команду
Selection Вибір
@@ -1281,6 +1286,7 @@
Run Script... Виконати сценарій...
Query Texts Запит текстів
Analyze Shapes Аналіз фігур
Analyze Usage Аналіз використання
Implied Loops Неявні контури
Hatch Pick Вибір для штрихування
# Hatch and Gradient dialog
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
255
256
257
258
259
260
261
981
982
983
984
985
986
987
988
989
990
1286
1287
1288
1289
1290
1291
1292 # Hatch and Gradient dialog
+16
View File
@@ -111,6 +111,8 @@ pub struct UiState {
pub save_version: String,
/// Drawings opened from a path, newest first (File ▸ Open Recent and the Start page).
pub recent_files: Vec<String>,
/// The folder the last APPLOAD file came from: the next pick starts there.
pub last_lisp_dir: Option<String>,
}
/// A DXF/DWG file version (2000 … 2018).
@@ -182,6 +184,7 @@ impl Default for UiState {
save_format: SaveFormat::default(),
save_version: SaveVersion::default().year().to_string(),
recent_files: Vec::new(),
last_lisp_dir: None,
}
}
}
@@ -191,6 +194,13 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Pick a `.lsp` file for APPLOAD, starting in the given folder (empty for the default one).
/// `None` where there is no native file dialog: the desktop without one asks for the name,
/// and the web (no file system) says to drop the file (`no_file_system`).
pub pick_lisp: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// The host has no file system to read a path from (the browser): a dropped file's contents
/// are what it loads, and APPLOAD says so instead of asking for a name it cannot read.
pub no_file_system: bool,
}
pub struct CadApp {
@@ -402,6 +412,9 @@ impl CadApp {
if !self.ui.recent_files.is_empty() {
prefs["recentFiles"] = json!(self.ui.recent_files);
}
if let Some(dir) = &self.ui.last_lisp_dir {
prefs["lispDir"] = json!(dir);
}
prefs.to_string()
}
@@ -429,6 +442,9 @@ impl CadApp {
if let Some(files) = v.get("recentFiles").and_then(Value::as_array) {
self.ui.recent_files = files.iter().filter_map(Value::as_str).filter(|p| !p.is_empty()).take(RECENT_FILES).map(str::to_string).collect();
}
if let Some(dir) = v.get("lispDir").and_then(Value::as_str).filter(|d| !d.is_empty()) {
self.ui.last_lisp_dir = Some(dir.to_string());
}
}
pub fn set_status(&mut self, s: impl Into<String>) {
+24
View File
@@ -156,6 +156,29 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
}
Ok(Value::Null)
}
// APPLOAD typed or chosen from a menu: the `.lsp` file picker, starting where the last
// one came from. JSON calls carry `path` and never open one. A picked file is loaded as a
// typed name is, through the interactive command, so a routine it starts that asks for a
// point waits for it; on the web (no file system) it says to drop the file instead.
"appload" | "ap" if params.is_null() => {
if app.services.no_file_system {
let hint = "APPLOAD: there is no file system here; drop a .lsp file on the window to load it";
app.session.echo(hint.to_string());
app.set_status(hint);
return Some(Ok(Value::Null));
}
let pick = app.services.pick_lisp.as_ref()?;
let dir = app.ui.last_lisp_dir.clone().unwrap_or_default();
let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) };
if let Some(dir) = std::path::Path::new(&path).parent().and_then(|p| p.to_str()).filter(|d| !d.is_empty()) {
app.ui.last_lisp_dir = Some(dir.to_string());
}
if let Err(e) = app.session.start("appload") {
return Some(Err(e.to_string()));
}
app.cmdline(&path);
Ok(Value::Null)
}
// Pick a template (.dwt or .dxf) and start a new drawing from it.
"ui.newtemplate" => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
@@ -734,6 +757,7 @@ mod tests {
log.borrow_mut().push(name.to_string());
answer.clone()
})),
..Services::default()
};
(CadApp::new(Session::new(), services), asked)
}
+1 -1
View File
@@ -234,7 +234,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
.width(120.0)
.selected_text(shown.clone())
.show_ui(ui, |ui| {
for fam in FAMILIES.iter().map(|s| s.to_string()).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) {
for fam in FAMILIES.iter().map(ToString::to_string).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) {
if ui.selectable_label(shown == fam, &fam).clicked() {
change(st, |c| c.font = Some(fam.clone()));
refocus = true;
+1 -1
View File
@@ -110,7 +110,7 @@ mod tests {
/// cannot be read is reported like a failed OPEN without adding a drawing.
#[test]
fn open_path_reads_in_the_background_and_reports_failure() {
let mut app = CadApp::new(Session::new(), Services { pick_open: None, pick_save: None });
let mut app = CadApp::new(Session::new(), Services::default());
let before = app.session.docs.len();
let missing = std::env::temp_dir().join(format!("cadcraft-missing-{}.dxf", std::process::id()));
let p = missing.to_string_lossy().to_string();

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