diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5606b50
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/target
+/result
+*.swp
+.direnv/
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..2426354
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,3489 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android-activity"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
+dependencies = [
+ "android-properties",
+ "bitflags 2.11.1",
+ "cc",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "num_enum",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "android-properties"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bit-set"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blake3"
+version = "1.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures",
+]
+
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "bone-app"
+version = "0.0.0"
+dependencies = [
+ "bone-document",
+ "bone-render",
+ "bone-types",
+ "pollster",
+ "thiserror 2.0.18",
+ "tracing",
+ "tracing-subscriber",
+ "uom",
+ "winit",
+]
+
+[[package]]
+name = "bone-document"
+version = "0.0.0"
+dependencies = [
+ "blake3",
+ "bone-solver",
+ "bone-types",
+ "insta",
+ "nalgebra",
+ "proptest",
+ "ron",
+ "serde",
+ "slotmap",
+ "tempfile",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
+[[package]]
+name = "bone-kernel"
+version = "0.0.0"
+dependencies = [
+ "bone-types",
+ "insta",
+ "thiserror 2.0.18",
+ "uom",
+]
+
+[[package]]
+name = "bone-render"
+version = "0.0.0"
+dependencies = [
+ "bone-document",
+ "bone-kernel",
+ "bone-types",
+ "bytemuck",
+ "png",
+ "pollster",
+ "proptest",
+ "slotmap",
+ "thiserror 2.0.18",
+ "tracing",
+ "uom",
+ "wgpu",
+]
+
+[[package]]
+name = "bone-solver"
+version = "0.0.0"
+dependencies = [
+ "bone-kernel",
+ "bone-types",
+ "faer",
+ "insta",
+ "nalgebra",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "bone-types"
+version = "0.0.0"
+dependencies = [
+ "insta",
+ "nalgebra",
+ "serde",
+ "slotmap",
+ "thiserror 2.0.18",
+ "tracing",
+ "tracing-subscriber",
+ "uom",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.11.1",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "codespan-reporting"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "console"
+version = "0.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "defer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8"
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
+[[package]]
+name = "dyn-stack"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8"
+dependencies = [
+ "bytemuck",
+ "dyn-stack-macros",
+]
+
+[[package]]
+name = "dyn-stack-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "equator"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea"
+dependencies = [
+ "equator-macro 0.2.1",
+]
+
+[[package]]
+name = "equator"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02da895aab06bbebefb6b2595f6d637b18c9ff629b4cd840965bb3164e4194b0"
+dependencies = [
+ "equator-macro 0.6.0",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "equator-macro"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b14b339eb76d07f052cdbad76ca7c1310e56173a138095d3bf42a23c06ef5d8"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "faer"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d2ecfb80b6f8b0c569e36988a052e64b14d8def9d372390b014e8bf79f299a"
+dependencies = [
+ "bytemuck",
+ "dyn-stack",
+ "equator 0.6.0",
+ "faer-traits",
+ "gemm",
+ "generativity",
+ "libm",
+ "nano-gemm",
+ "num-complex",
+ "num-traits",
+ "private-gemm-x86",
+ "pulp",
+ "reborrow",
+]
+
+[[package]]
+name = "faer-traits"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b87d23ed7ab1f26c0cba0e5b9e061a796fbb7dc170fa8bee6970055a1308bb0f"
+dependencies = [
+ "bytemuck",
+ "dyn-stack",
+ "generativity",
+ "libm",
+ "num-complex",
+ "num-traits",
+ "pulp",
+ "qd",
+ "reborrow",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gemm"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
+dependencies = [
+ "dyn-stack",
+ "gemm-c32",
+ "gemm-c64",
+ "gemm-common",
+ "gemm-f16",
+ "gemm-f32",
+ "gemm-f64",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c32"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c64"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-common"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
+dependencies = [
+ "bytemuck",
+ "dyn-stack",
+ "half",
+ "libm",
+ "num-complex",
+ "num-traits",
+ "once_cell",
+ "paste",
+ "pulp",
+ "raw-cpuid",
+ "seq-macro",
+ "sysctl",
+]
+
+[[package]]
+name = "gemm-f16"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "gemm-f32",
+ "half",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-f32"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-f64"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "generativity"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5881e4c3c2433fe4905bb19cfd2b5d49d4248274862b68c27c33d9ba4e13f9ec"
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glow"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
+dependencies = [
+ "ash",
+ "hashbrown 0.16.1",
+ "log",
+ "presser",
+ "thiserror 2.0.18",
+ "windows",
+]
+
+[[package]]
+name = "gpu-descriptor"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
+dependencies = [
+ "bitflags 2.11.1",
+ "gpu-descriptor-types",
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "gpu-descriptor-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "crunchy",
+ "num-traits",
+ "zerocopy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hexf-parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+]
+
+[[package]]
+name = "insta"
+version = "1.47.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e"
+dependencies = [
+ "console",
+ "once_cell",
+ "similar",
+ "tempfile",
+]
+
+[[package]]
+name = "interpol"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb58032ba748f4010d15912a1855a8a0b1ba9eaad3395b0c171c09b3b356ae50"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.185"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+dependencies = [
+ "bitflags 2.11.1",
+ "libc",
+ "plain",
+ "redox_syscall 0.7.4",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matrixmultiply"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "memmap2"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "naga"
+version = "29.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa2630921705b9b01dcdd0b6864b9562ca3c1951eecd0f0c4f5f04f61e412647"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags 2.11.1",
+ "cfg-if",
+ "cfg_aliases",
+ "codespan-reporting",
+ "half",
+ "hashbrown 0.16.1",
+ "hexf-parse",
+ "indexmap",
+ "libm",
+ "log",
+ "num-traits",
+ "once_cell",
+ "rustc-hash",
+ "spirv",
+ "thiserror 2.0.18",
+ "unicode-ident",
+]
+
+[[package]]
+name = "nalgebra"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd"
+dependencies = [
+ "approx",
+ "matrixmultiply",
+ "num-complex",
+ "num-rational",
+ "num-traits",
+ "simba",
+ "typenum",
+]
+
+[[package]]
+name = "nano-gemm"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e04345dc84b498ff89fe0d38543d1f170da9e43a2c2bcee73a0f9069f72d081"
+dependencies = [
+ "equator 0.2.2",
+ "nano-gemm-c32",
+ "nano-gemm-c64",
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+ "nano-gemm-f32",
+ "nano-gemm-f64",
+ "num-complex",
+]
+
+[[package]]
+name = "nano-gemm-c32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0775b1e2520e64deee8fc78b7732e3091fb7585017c0b0f9f4b451757bbbc562"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+ "num-complex",
+]
+
+[[package]]
+name = "nano-gemm-c64"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9af49a20d58816e6b5ee65f64142e50edb5eba152678d4bb7377fcbf63f8437a"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+ "num-complex",
+]
+
+[[package]]
+name = "nano-gemm-codegen"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cc8d495c791627779477a2cf5df60049f5b165342610eb0d76bee5ff5c5d74c"
+
+[[package]]
+name = "nano-gemm-core"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d998dfa644de87a0f8660e5ea511d7cb5c33b5a2d9847b7af57a2565105089f0"
+
+[[package]]
+name = "nano-gemm-f32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879d962e79bc8952e4ad21ca4845a21132540ed3f5e01184b2ff7f720e666523"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+]
+
+[[package]]
+name = "nano-gemm-f64"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9a513473dce7dc00c7e7c318481ca4494034e76997218d8dad51bd9f007a815"
+dependencies = [
+ "nano-gemm-codegen",
+ "nano-gemm-core",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.11.1",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "bytemuck",
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "libc",
+ "objc2 0.5.2",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation 0.2.2",
+ "objc2-quartz-core 0.2.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.11.1",
+ "dispatch2",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-contacts",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "dispatch",
+ "libc",
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-app-kit",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+ "objc2-link-presentation",
+ "objc2-quartz-core 0.2.2",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "orbclient"
+version = "0.3.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12c6933ddbbd16539a7672e697bb8d41ac3a4e99ac43eeb40c07236bd7fcb2dd"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "ordered-float"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "private-gemm-x86"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0af8c3e5087969c323f667ccb4b789fa0954f5aa650550e38e81cf9108be21b5"
+dependencies = [
+ "defer",
+ "interpol",
+ "num_cpus",
+ "raw-cpuid",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+
+[[package]]
+name = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bitflags 2.11.1",
+ "num-traits",
+ "rand",
+ "rand_chacha",
+ "rand_xorshift",
+ "regex-syntax",
+ "unarray",
+]
+
+[[package]]
+name = "pulp"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "libm",
+ "num-complex",
+ "paste",
+ "pulp-wasm-simd-flag",
+ "raw-cpuid",
+ "reborrow",
+ "version_check",
+]
+
+[[package]]
+name = "pulp-wasm-simd-flag"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0"
+
+[[package]]
+name = "qd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15f1304a5aecdcfe9ee72fbba90aa37b3aa067a69d14cb7f3d9deada0be7c07c"
+dependencies = [
+ "bytemuck",
+ "libm",
+ "num-traits",
+ "pulp",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "range-alloc"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "raw-window-metal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
+dependencies = [
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+]
+
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "reborrow"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430"
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[package]]
+name = "ron"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc"
+dependencies = [
+ "bitflags 2.11.1",
+ "once_cell",
+ "serde",
+ "serde_derive",
+ "typeid",
+ "unicode-ident",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.11.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "safe_arch"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "ab_glyph",
+ "log",
+ "memmap2",
+ "smithay-client-toolkit",
+ "tiny-skia",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "seq-macro"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simba"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95"
+dependencies = [
+ "approx",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "wide",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "serde",
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.11.1",
+ "calloop",
+ "calloop-wayland-source",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "spirv"
+version = "0.4.0+sdk-1.4.341.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sysctl"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc"
+dependencies = [
+ "bitflags 2.11.1",
+ "byteorder",
+ "enum-as-inner",
+ "libc",
+ "thiserror 1.0.69",
+ "walkdir",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.11+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "uom"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a739f83872836c82a4f2527d4e54b37007b3de68cafe7edde95fd695968bf4b9"
+dependencies = [
+ "num-traits",
+ "typenum",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.4",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags 2.11.1",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.11.1",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.4",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wgpu"
+version = "29.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72c239a9a747bbd379590985bac952c2e53cb19873f7072b3370c6a6a8e06837"
+dependencies = [
+ "arrayvec",
+ "bitflags 2.11.1",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.16.1",
+ "js-sys",
+ "log",
+ "naga",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "29.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e80ac6cf1895df6342f87d975162108f9d98772a0d74bc404ab7304ac29469e"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bit-vec",
+ "bitflags 2.11.1",
+ "bytemuck",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.16.1",
+ "indexmap",
+ "log",
+ "naga",
+ "once_cell",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wgpu-core-deps-apple",
+ "wgpu-core-deps-emscripten",
+ "wgpu-core-deps-windows-linux-android",
+ "wgpu-hal",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "29.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43acd053312501689cd92a01a9638d37f3e41a5fd9534875efa8917ee2d11ac0"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "29.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-windows-linux-android"
+version = "29.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "725d5c006a8c02967b6d93ef04f6537ec4593313e330cfe86d9d3f946eb90f28"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "29.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89a47aef47636562f3937285af4c44b4b5b404b46577471411cc5313a921da7e"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set",
+ "bitflags 2.11.1",
+ "block2 0.6.2",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-allocator",
+ "gpu-descriptor",
+ "hashbrown 0.16.1",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "naga",
+ "ndk-sys",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "once_cell",
+ "ordered-float",
+ "parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "raw-window-metal",
+ "renderdoc-sys",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "wayland-sys",
+ "web-sys",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "wgpu-naga-bridge"
+version = "29.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4684f4410da0cf95a4cb63bb5edaac022461dedb6adf0b64d0d9b5f6890d51"
+dependencies = [
+ "naga",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "29.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec2675540fb1a5cfa5ef122d3d5f390e2c75711a0b946410f2d6ac3a0f77d1f6"
+dependencies = [
+ "bitflags 2.11.1",
+ "bytemuck",
+ "js-sys",
+ "log",
+ "raw-window-handle",
+ "web-sys",
+]
+
+[[package]]
+name = "wide"
+version = "0.7.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
+dependencies = [
+ "bytemuck",
+ "safe_arch",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winit"
+version = "0.30.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+dependencies = [
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.11.1",
+ "block2 0.5.1",
+ "bytemuck",
+ "calloop",
+ "cfg_aliases",
+ "concurrent-queue",
+ "core-foundation",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2 0.5.2",
+ "objc2-app-kit",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit",
+ "orbclient",
+ "percent-encoding",
+ "pin-project",
+ "raw-window-handle",
+ "redox_syscall 0.4.1",
+ "rustix 0.38.44",
+ "sctk-adwaita",
+ "smithay-client-toolkit",
+ "smol_str",
+ "tracing",
+ "unicode-segmentation",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-plasma",
+ "web-sys",
+ "web-time",
+ "windows-sys 0.52.0",
+ "x11-dl",
+ "x11rb",
+ "xkbcommon-dl",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "libloading",
+ "once_cell",
+ "rustix 1.1.4",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xcursor"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
+
+[[package]]
+name = "xkbcommon-dl"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
+dependencies = [
+ "bitflags 2.11.1",
+ "dlib",
+ "log",
+ "once_cell",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "xml-rs"
+version = "0.8.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2ff520d
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,59 @@
+[workspace]
+resolver = "3"
+members = [
+ "crates/bone-types",
+ "crates/bone-kernel",
+ "crates/bone-solver",
+ "crates/bone-document",
+ "crates/bone-render",
+ "crates/bone-app",
+]
+
+[workspace.package]
+version = "0.0.0"
+edition = "2024"
+license = "AGPL-3.0-or-later"
+rust-version = "1.95"
+
+[workspace.lints.rust]
+unsafe_code = "forbid"
+
+[workspace.lints.clippy]
+all = { level = "deny", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+unwrap_used = "deny"
+expect_used = "deny"
+missing_panics_doc = "allow"
+missing_errors_doc = "allow"
+
+[workspace.dependencies]
+bone-types = { path = "crates/bone-types" }
+bone-kernel = { path = "crates/bone-kernel" }
+bone-solver = { path = "crates/bone-solver" }
+bone-document = { path = "crates/bone-document" }
+bone-render = { path = "crates/bone-render" }
+
+blake3 = { version = "1", default-features = false, features = ["std"] }
+bytemuck = { version = "1", default-features = false, features = ["derive"] }
+faer = { version = "0.24", default-features = false, features = ["std"] }
+insta = "1"
+nalgebra = { version = "0.33", default-features = false, features = ["std"] }
+png = { version = "0.17", default-features = false }
+pollster = "0.4"
+proptest = { version = "1", default-features = false, features = ["std"] }
+ron = "0.12"
+serde = { version = "1", default-features = false, features = ["std", "derive", "rc"] }
+slotmap = { version = "1", default-features = false, features = ["std", "serde"] }
+tempfile = "3"
+thiserror = "2"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+uom = { version = "0.38", default-features = false, features = ["f64", "si", "std", "autoconvert"] }
+wgpu = "29"
+winit = "0.30"
+
+[profile.release]
+lto = "fat"
+strip = true
+codegen-units = 1
+panic = "abort"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..af68f86
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,3 @@
+Everything in this repository is licensed under AGPLv3 or later, unless a file says otherwise at its top.
+
+Full licence text: LICENSE-AGPL-3.0-or-later
diff --git a/LICENSE-AGPL-3.0-or-later b/LICENSE-AGPL-3.0-or-later
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENSE-AGPL-3.0-or-later
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/crates/bone-app/Cargo.toml b/crates/bone-app/Cargo.toml
new file mode 100644
index 0000000..d09066e
--- /dev/null
+++ b/crates/bone-app/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "bone-app"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+bone-types = { workspace = true }
+bone-document = { workspace = true }
+bone-render = { workspace = true }
+
+pollster = { workspace = true }
+thiserror = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+uom = { workspace = true }
+winit = { workspace = true }
+
+[lints]
+workspace = true
diff --git a/crates/bone-app/src/main.rs b/crates/bone-app/src/main.rs
new file mode 100644
index 0000000..5180fa3
--- /dev/null
+++ b/crates/bone-app/src/main.rs
@@ -0,0 +1,409 @@
+use std::sync::Arc;
+
+use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity};
+use bone_render::{
+ Camera2, PixelsPerMm, SketchRenderer, SketchScene, Style, SurfaceContext, ViewportExtent,
+ ViewportPx,
+};
+use bone_types::{
+ Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3, Vec2,
+};
+use tracing_subscriber::EnvFilter;
+use uom::si::length::millimeter;
+use winit::{
+ application::ApplicationHandler,
+ dpi::{PhysicalPosition, PhysicalSize},
+ event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
+ event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
+ keyboard::{KeyCode, ModifiersState, PhysicalKey},
+ window::{Window, WindowId},
+};
+
+#[derive(Debug, thiserror::Error)]
+enum AppError {
+ #[error("event loop: {0}")]
+ EventLoop(#[from] winit::error::EventLoopError),
+}
+
+const DEFAULT_LOG_FILTER: &str = "bone_app=info,bone_render=info,bone_document=info,bone_kernel=info,bone_types=info,wgpu_core=warn,wgpu_hal=warn";
+const ZOOM_STEP_PER_LINE: f64 = 1.1;
+const ZOOM_STEP_PER_PIXEL: f64 = 1.0025;
+const ZOOM_KEY_STEP: f64 = 1.25;
+const ZOOM_MIN: f64 = 0.01;
+const ZOOM_MAX: f64 = 1.0e5;
+const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0;
+const PAN_STEP_PX: f64 = 40.0;
+const PAN_FAST_MULTIPLIER: f64 = 5.0;
+const ZOOM_FIT_MARGIN: f64 = 0.9;
+
+struct RenderState {
+ surface: SurfaceContext,
+ renderer: SketchRenderer,
+ scene: SketchScene,
+ camera: Camera2,
+ style: Style,
+}
+
+#[derive(Default)]
+struct InputState {
+ cursor_px: Option>,
+ left_down: bool,
+ middle_down: bool,
+ modifiers: ModifiersState,
+}
+
+impl InputState {
+ fn panning(&self) -> bool {
+ self.middle_down || (self.left_down && self.modifiers.shift_key())
+ }
+
+ fn pan_step_px(&self) -> f64 {
+ if self.modifiers.shift_key() {
+ PAN_STEP_PX * PAN_FAST_MULTIPLIER
+ } else {
+ PAN_STEP_PX
+ }
+ }
+}
+
+struct App {
+ window: Option>,
+ render: Option,
+ input: InputState,
+}
+
+fn plane_xy() -> SketchPlaneBasis {
+ let Ok(basis) = SketchPlaneBasis::new(
+ Point3::origin(),
+ UnitVec3::x_axis(),
+ UnitVec3::y_axis(),
+ Tolerance::new(1e-9),
+ ) else {
+ unreachable!("canonical XY axes are orthonormal");
+ };
+ basis
+}
+
+fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) {
+ let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(x, y)),
+ )) else {
+ unreachable!("AddEntity(Point) on fresh sketch yields Entity outcome");
+ };
+ (next, id)
+}
+
+fn add_line(sketch: Sketch, a: SketchEntityId, b: SketchEntityId) -> Sketch {
+ let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else {
+ unreachable!("AddEntity(Line) referencing freshly added Points succeeds");
+ };
+ next
+}
+
+fn add_circle(sketch: Sketch, center: SketchEntityId, radius_mm: f64) -> Sketch {
+ let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::circle(
+ center,
+ Length::new::(radius_mm),
+ false,
+ ))) else {
+ unreachable!("AddEntity(Circle) referencing freshly added center succeeds");
+ };
+ next
+}
+
+fn add_arc(
+ sketch: Sketch,
+ center: SketchEntityId,
+ start: SketchEntityId,
+ end: SketchEntityId,
+) -> Sketch {
+ let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::arc(
+ center, start, end, false,
+ ))) else {
+ unreachable!("AddEntity(Arc) referencing freshly added points succeeds");
+ };
+ next
+}
+
+fn default_sketch() -> Sketch {
+ let sketch = Sketch::new(plane_xy());
+ let (sketch, p0) = add_point(sketch, -20.0, -12.5);
+ let (sketch, p1) = add_point(sketch, 20.0, -12.5);
+ let (sketch, p2) = add_point(sketch, 20.0, 12.5);
+ let (sketch, p3) = add_point(sketch, -20.0, 12.5);
+ let sketch = add_line(sketch, p0, p1);
+ let sketch = add_line(sketch, p1, p2);
+ let sketch = add_line(sketch, p2, p3);
+ let sketch = add_line(sketch, p3, p0);
+ let (sketch, origin) = add_point(sketch, 0.0, 0.0);
+ let sketch = add_circle(sketch, origin, 5.0);
+ let (sketch, arc_center) = add_point(sketch, -12.0, 0.0);
+ let (sketch, arc_start) = add_point(sketch, -8.0, 0.0);
+ let (sketch, arc_end) = add_point(sketch, -12.0, 4.0);
+ add_arc(sketch, arc_center, arc_start, arc_end)
+}
+
+fn viewport_extent(size: PhysicalSize) -> ViewportExtent {
+ ViewportExtent::new(
+ ViewportPx::new(size.width.max(1)),
+ ViewportPx::new(size.height.max(1)),
+ )
+}
+
+fn zoom_factor(delta: MouseScrollDelta) -> f64 {
+ match delta {
+ MouseScrollDelta::LineDelta(_, y) => ZOOM_STEP_PER_LINE.powf(f64::from(y)),
+ MouseScrollDelta::PixelDelta(p) => ZOOM_STEP_PER_PIXEL.powf(p.y),
+ }
+}
+
+fn zoom_about(camera: Camera2, cursor: Option>, factor: f64) -> Camera2 {
+ if !factor.is_finite() || factor <= 0.0 {
+ return camera;
+ }
+ let zoom_before = camera.zoom().value();
+ let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX);
+ if !zoom_after.is_finite() {
+ return camera;
+ }
+ let extent = camera.extent();
+ let w = f64::from(extent.width().value());
+ let h = f64::from(extent.height().value());
+ let (pan_x, pan_y) = camera.pan_mm().coords_mm();
+ let (cursor_x, cursor_y) = match cursor {
+ Some(c) => (c.x, c.y),
+ None => (w * 0.5, h * 0.5),
+ };
+ let horizontal_px = cursor_x - w * 0.5;
+ let vertical_px = h * 0.5 - cursor_y;
+ let world_x = pan_x + horizontal_px / zoom_before;
+ let world_y = pan_y + vertical_px / zoom_before;
+ let new_pan_x = world_x - horizontal_px / zoom_after;
+ let new_pan_y = world_y - vertical_px / zoom_after;
+ camera
+ .with_zoom(PixelsPerMm::new(zoom_after))
+ .with_pan(Vec2::from_mm(new_pan_x, new_pan_y))
+}
+
+fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 {
+ let mm_per_px = camera.world_mm_per_pixel();
+ let (pan_x, pan_y) = camera.pan_mm().coords_mm();
+ camera.with_pan(Vec2::from_mm(
+ pan_x - horizontal_px * mm_per_px,
+ pan_y + vertical_px * mm_per_px,
+ ))
+}
+
+fn zoom_fit(camera: Camera2, scene: &SketchScene) -> Camera2 {
+ let Some(aabb) = scene.aabb() else {
+ return camera;
+ };
+ let (mnx, mny) = aabb.min().coords_mm();
+ let (mxx, mxy) = aabb.max().coords_mm();
+ let center = Vec2::from_mm((mnx + mxx) * 0.5, (mny + mxy) * 0.5);
+ let world_w = mxx - mnx;
+ let world_h = mxy - mny;
+ if world_w <= 0.0 && world_h <= 0.0 {
+ return camera.with_pan(center);
+ }
+ let extent = camera.extent();
+ let w_px = f64::from(extent.width().value());
+ let h_px = f64::from(extent.height().value());
+ let axis_zoom = |pixels: f64, world: f64| {
+ if world > 0.0 {
+ pixels / world
+ } else {
+ f64::INFINITY
+ }
+ };
+ let raw_zoom = axis_zoom(w_px, world_w).min(axis_zoom(h_px, world_h)) * ZOOM_FIT_MARGIN;
+ let clamped = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX);
+ camera.with_zoom(PixelsPerMm::new(clamped)).with_pan(center)
+}
+
+enum KeyAction {
+ Exit,
+ Camera(Camera2),
+}
+
+fn keyboard_action(code: KeyCode, input: &InputState, state: &RenderState) -> Option {
+ match (code, input.modifiers.control_key()) {
+ (KeyCode::KeyQ, true) => Some(KeyAction::Exit),
+ _ => keyboard_camera(code, input, state).map(KeyAction::Camera),
+ }
+}
+
+fn keyboard_camera(code: KeyCode, input: &InputState, state: &RenderState) -> Option {
+ let camera = state.camera;
+ let step = input.pan_step_px();
+ let shift = input.modifiers.shift_key();
+ match code {
+ KeyCode::ArrowLeft => Some(pan_by_px(camera, step, 0.0)),
+ KeyCode::ArrowRight => Some(pan_by_px(camera, -step, 0.0)),
+ KeyCode::ArrowUp => Some(pan_by_px(camera, 0.0, step)),
+ KeyCode::ArrowDown => Some(pan_by_px(camera, 0.0, -step)),
+ KeyCode::KeyF => Some(zoom_fit(camera, &state.scene)),
+ KeyCode::KeyZ => Some(zoom_about(
+ camera,
+ input.cursor_px,
+ if shift {
+ 1.0 / ZOOM_KEY_STEP
+ } else {
+ ZOOM_KEY_STEP
+ },
+ )),
+ KeyCode::Equal => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)),
+ KeyCode::Minus => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)),
+ _ => None,
+ }
+}
+
+impl ApplicationHandler for App {
+ fn resumed(&mut self, event_loop: &ActiveEventLoop) {
+ if self.window.is_some() {
+ return;
+ }
+ let attrs = Window::default_attributes().with_title("bone");
+ let window = match event_loop.create_window(attrs) {
+ Ok(w) => Arc::new(w),
+ Err(e) => {
+ tracing::error!(error = %e, "create_window failed");
+ event_loop.exit();
+ return;
+ }
+ };
+ let extent = viewport_extent(window.inner_size());
+ let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) {
+ Ok(s) => s,
+ Err(e) => {
+ tracing::error!(error = %e, "SurfaceContext::new failed");
+ event_loop.exit();
+ return;
+ }
+ };
+ let renderer = SketchRenderer::new(surface.gpu(), surface.color_format());
+ let sketch = default_sketch();
+ let scene = match SketchScene::extract(&sketch) {
+ Ok(s) => s,
+ Err(e) => {
+ tracing::error!(error = %e, "scene extract failed");
+ event_loop.exit();
+ return;
+ }
+ };
+ let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM));
+ let style = Style::default();
+ window.request_redraw();
+ self.window = Some(window);
+ self.render = Some(RenderState {
+ surface,
+ renderer,
+ scene,
+ camera,
+ style,
+ });
+ }
+
+ fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
+ let (Some(state), Some(window)) = (self.render.as_mut(), self.window.as_ref()) else {
+ return;
+ };
+ match event {
+ WindowEvent::CloseRequested => event_loop.exit(),
+ WindowEvent::Resized(size) => {
+ let extent = viewport_extent(size);
+ state.surface.resize(extent);
+ state.camera = state.camera.with_extent(extent);
+ window.request_redraw();
+ }
+ WindowEvent::RedrawRequested => {
+ let surface = &mut state.surface;
+ let renderer = &state.renderer;
+ let scene = &state.scene;
+ let camera = state.camera;
+ let style = &state.style;
+ surface.render(
+ |encoder, color, pick| {
+ renderer.encode_passes(encoder, color, pick, scene, camera, style);
+ },
+ || window.pre_present_notify(),
+ );
+ }
+ WindowEvent::Focused(false) => {
+ self.input.cursor_px = None;
+ self.input.left_down = false;
+ self.input.middle_down = false;
+ self.input.modifiers = ModifiersState::empty();
+ }
+ WindowEvent::ModifiersChanged(mods) => {
+ self.input.modifiers = mods.state();
+ }
+ WindowEvent::CursorMoved { position, .. } => {
+ let prev = self.input.cursor_px;
+ self.input.cursor_px = Some(position);
+ if self.input.panning()
+ && let Some(p) = prev
+ {
+ state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y);
+ window.request_redraw();
+ }
+ }
+ WindowEvent::CursorLeft { .. } => {
+ self.input.cursor_px = None;
+ }
+ WindowEvent::MouseInput {
+ state: btn_state,
+ button: MouseButton::Left,
+ ..
+ } => {
+ self.input.left_down = btn_state == ElementState::Pressed;
+ }
+ WindowEvent::MouseInput {
+ state: btn_state,
+ button: MouseButton::Middle,
+ ..
+ } => {
+ self.input.middle_down = btn_state == ElementState::Pressed;
+ }
+ WindowEvent::MouseWheel { delta, .. } => {
+ state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta));
+ window.request_redraw();
+ }
+ WindowEvent::KeyboardInput {
+ event:
+ KeyEvent {
+ physical_key: PhysicalKey::Code(code),
+ state: ElementState::Pressed,
+ ..
+ },
+ ..
+ } => match keyboard_action(code, &self.input, state) {
+ Some(KeyAction::Exit) => event_loop.exit(),
+ Some(KeyAction::Camera(next)) => {
+ state.camera = next;
+ window.request_redraw();
+ }
+ None => {}
+ },
+ _ => {}
+ }
+ }
+}
+
+fn main() -> Result<(), AppError> {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)),
+ )
+ .init();
+ let event_loop = EventLoop::new()?;
+ event_loop.set_control_flow(ControlFlow::Wait);
+ let mut app = App {
+ window: None,
+ render: None,
+ input: InputState::default(),
+ };
+ event_loop.run_app(&mut app)?;
+ Ok(())
+}
diff --git a/crates/bone-document/Cargo.toml b/crates/bone-document/Cargo.toml
new file mode 100644
index 0000000..e20a743
--- /dev/null
+++ b/crates/bone-document/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "bone-document"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+bone-types = { workspace = true }
+bone-solver = { workspace = true }
+blake3 = { workspace = true }
+ron = { workspace = true }
+serde = { workspace = true }
+slotmap = { workspace = true }
+thiserror = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+insta = { workspace = true }
+nalgebra = { workspace = true }
+proptest = { workspace = true }
+tempfile = { workspace = true }
+
+[lints]
+workspace = true
diff --git a/crates/bone-document/src/document/feature_tree.rs b/crates/bone-document/src/document/feature_tree.rs
new file mode 100644
index 0000000..a2629ee
--- /dev/null
+++ b/crates/bone-document/src/document/feature_tree.rs
@@ -0,0 +1,105 @@
+use bone_types::{FeatureId, SketchId};
+use serde::{Deserialize, Serialize};
+use slotmap::{Key, KeyData};
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum PrincipalPlane {
+ Xy,
+ Yz,
+ Zx,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum FeatureNode {
+ Origin,
+ PrincipalPlane(PrincipalPlane),
+ Sketch(SketchId),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct FeatureEntry {
+ id: FeatureId,
+ node: FeatureNode,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct FeatureTree {
+ entries: Vec,
+}
+
+impl FeatureTree {
+ #[must_use]
+ pub fn seeded() -> Self {
+ let seeds = [
+ FeatureNode::Origin,
+ FeatureNode::PrincipalPlane(PrincipalPlane::Xy),
+ FeatureNode::PrincipalPlane(PrincipalPlane::Yz),
+ FeatureNode::PrincipalPlane(PrincipalPlane::Zx),
+ ];
+ let entries = seeds
+ .into_iter()
+ .zip(1u32..)
+ .map(|(node, idx)| FeatureEntry {
+ id: feature_id_from_idx(idx),
+ node,
+ })
+ .collect();
+ Self { entries }
+ }
+
+ #[must_use]
+ pub fn node(&self, id: FeatureId) -> Option {
+ self.entries.iter().find(|e| e.id == id).map(|e| e.node)
+ }
+
+ pub fn iter(&self) -> impl Iterator- + '_ {
+ self.entries.iter().map(|e| (e.id, e.node))
+ }
+
+ #[must_use]
+ pub fn feature_of_sketch(&self, sketch: SketchId) -> Option {
+ self.entries
+ .iter()
+ .find(|e| matches!(e.node, FeatureNode::Sketch(s) if s == sketch))
+ .map(|e| e.id)
+ }
+
+ pub fn push_sketch(&mut self, sketch: SketchId) -> FeatureId {
+ if let Some(existing) = self.feature_of_sketch(sketch) {
+ return existing;
+ }
+ let id = self.allocate();
+ self.entries.push(FeatureEntry {
+ id,
+ node: FeatureNode::Sketch(sketch),
+ });
+ id
+ }
+
+ pub fn remove_sketch(&mut self, sketch: SketchId) -> Option {
+ let id = self.feature_of_sketch(sketch)?;
+ self.entries.retain(|e| e.id != id);
+ Some(id)
+ }
+
+ fn allocate(&self) -> FeatureId {
+ let highest = self.entries.iter().map(|e| idx_of(e.id)).max().unwrap_or(0);
+ let Some(next) = highest.checked_add(1) else {
+ panic!("FeatureTree exhausted 32-bit feature id space");
+ };
+ feature_id_from_idx(next)
+ }
+}
+
+fn feature_id_from_idx(idx: u32) -> FeatureId {
+ FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+fn idx_of(id: FeatureId) -> u32 {
+ let Ok(idx) = u32::try_from(id.data().as_ffi() & 0xFFFF_FFFF) else {
+ panic!("lower 32 bits of ffi key fit in u32");
+ };
+ idx
+}
diff --git a/crates/bone-document/src/document/mod.rs b/crates/bone-document/src/document/mod.rs
new file mode 100644
index 0000000..0283440
--- /dev/null
+++ b/crates/bone-document/src/document/mod.rs
@@ -0,0 +1,271 @@
+use std::collections::BTreeMap;
+
+use bone_types::{DocumentId, FeatureId, SchemaHeader, SketchId};
+use serde::{Deserialize, Serialize};
+
+use crate::Sketch;
+
+pub mod feature_tree;
+
+pub use feature_tree::{FeatureNode, FeatureTree, PrincipalPlane};
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub enum UnitsPreference {
+ Millimetre,
+ Centimetre,
+ Metre,
+ Inch,
+ Foot,
+}
+
+impl UnitsPreference {
+ pub const DEFAULT: Self = Self::Millimetre;
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct SketchRegistryEntry {
+ pub label: String,
+ pub filename: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct SketchRegistry {
+ order: Vec,
+ entries: BTreeMap,
+}
+
+impl SketchRegistry {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ #[must_use]
+ pub fn order(&self) -> &[SketchId] {
+ &self.order
+ }
+
+ #[must_use]
+ pub fn entry(&self, id: SketchId) -> Option<&SketchRegistryEntry> {
+ self.entries.get(&id)
+ }
+
+ pub fn iter(&self) -> impl Iterator
- + '_ {
+ self.order
+ .iter()
+ .filter_map(|id| self.entries.get(id).map(|e| (*id, e)))
+ }
+
+ pub(crate) fn insert(&mut self, id: SketchId, entry: SketchRegistryEntry) {
+ if !self.entries.contains_key(&id) {
+ self.order.push(id);
+ }
+ self.entries.insert(id, entry);
+ }
+
+ pub(crate) fn remove(&mut self, id: SketchId) -> Option {
+ self.order.retain(|other| *other != id);
+ self.entries.remove(&id)
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct DocumentParameters {
+ order: Vec,
+ entries: BTreeMap,
+}
+
+impl DocumentParameters {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ #[must_use]
+ pub fn order(&self) -> &[String] {
+ &self.order
+ }
+
+ #[must_use]
+ pub fn get(&self, name: &str) -> Option {
+ self.entries.get(name).copied()
+ }
+
+ pub fn insert(&mut self, name: String, value: f64) {
+ if !self.entries.contains_key(&name) {
+ self.order.push(name.clone());
+ }
+ self.entries.insert(name, value);
+ }
+
+ pub fn remove(&mut self, name: &str) -> Option {
+ self.order.retain(|n| n != name);
+ self.entries.remove(name)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct DocumentHeader {
+ pub schema: SchemaHeader,
+ pub id: DocumentId,
+ pub name: String,
+ pub units: UnitsPreference,
+ pub parameters: DocumentParameters,
+ pub feature_tree: FeatureTree,
+ pub sketches: SketchRegistry,
+}
+
+impl DocumentHeader {
+ #[must_use]
+ pub fn new(id: DocumentId, name: String) -> Self {
+ Self {
+ schema: SchemaHeader::bone_document(),
+ id,
+ name,
+ units: UnitsPreference::DEFAULT,
+ parameters: DocumentParameters::new(),
+ feature_tree: FeatureTree::seeded(),
+ sketches: SketchRegistry::new(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct SketchFile {
+ pub schema: SchemaHeader,
+ pub sketch: Sketch,
+}
+
+impl SketchFile {
+ #[must_use]
+ pub fn new(sketch: Sketch) -> Self {
+ Self {
+ schema: SchemaHeader::bone_document(),
+ sketch,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Document {
+ header: DocumentHeader,
+ sketches: BTreeMap,
+}
+
+impl Document {
+ #[must_use]
+ pub fn new(id: DocumentId, name: String) -> Self {
+ Self {
+ header: DocumentHeader::new(id, name),
+ sketches: BTreeMap::new(),
+ }
+ }
+
+ pub(crate) fn from_parts(header: DocumentHeader, sketches: BTreeMap) -> Self {
+ Self { header, sketches }
+ }
+
+ #[must_use]
+ pub fn header(&self) -> &DocumentHeader {
+ &self.header
+ }
+
+ #[must_use]
+ pub fn id(&self) -> DocumentId {
+ self.header.id
+ }
+
+ #[must_use]
+ pub fn name(&self) -> &str {
+ &self.header.name
+ }
+
+ #[must_use]
+ pub fn units(&self) -> UnitsPreference {
+ self.header.units
+ }
+
+ #[must_use]
+ pub fn feature_tree(&self) -> &FeatureTree {
+ &self.header.feature_tree
+ }
+
+ #[must_use]
+ pub fn parameters(&self) -> &DocumentParameters {
+ &self.header.parameters
+ }
+
+ #[must_use]
+ pub fn registry(&self) -> &SketchRegistry {
+ &self.header.sketches
+ }
+
+ #[must_use]
+ pub fn sketch(&self, id: SketchId) -> Option<&Sketch> {
+ self.sketches.get(&id)
+ }
+
+ pub fn sketches(&self) -> impl Iterator
- + '_ {
+ self.header
+ .sketches
+ .order()
+ .iter()
+ .filter_map(|id| self.sketches.get(id).map(|s| (*id, s)))
+ }
+
+ #[must_use]
+ pub fn sketches_map(&self) -> &BTreeMap {
+ &self.sketches
+ }
+
+ pub fn set_name(&mut self, name: String) {
+ self.header.name = name;
+ }
+
+ pub fn set_units(&mut self, units: UnitsPreference) {
+ self.header.units = units;
+ }
+
+ pub fn insert_sketch(&mut self, id: SketchId, label: String, sketch: Sketch) {
+ let filename = sketch_filename(id);
+ self.header
+ .sketches
+ .insert(id, SketchRegistryEntry { label, filename });
+ self.header.feature_tree.push_sketch(id);
+ self.sketches.insert(id, sketch);
+ }
+
+ pub fn replace_sketch(&mut self, id: SketchId, sketch: Sketch) -> Option {
+ self.sketches.insert(id, sketch)
+ }
+
+ pub fn remove_sketch(&mut self, id: SketchId) -> Option {
+ self.header.sketches.remove(id);
+ self.header.feature_tree.remove_sketch(id);
+ self.sketches.remove(&id)
+ }
+
+ pub fn set_parameter(&mut self, name: String, value: f64) {
+ self.header.parameters.insert(name, value);
+ }
+
+ #[must_use]
+ pub fn sketch_of_feature(&self, feature: FeatureId) -> Option<&Sketch> {
+ match self.header.feature_tree.node(feature)? {
+ FeatureNode::Sketch(id) => self.sketches.get(&id),
+ FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => None,
+ }
+ }
+}
+
+#[must_use]
+pub fn sketch_filename(id: SketchId) -> String {
+ use slotmap::Key;
+ format!("{:016x}.ron", id.data().as_ffi())
+}
diff --git a/crates/bone-document/src/evaluator.rs b/crates/bone-document/src/evaluator.rs
new file mode 100644
index 0000000..85ae626
--- /dev/null
+++ b/crates/bone-document/src/evaluator.rs
@@ -0,0 +1,81 @@
+use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
+
+use bone_solver::SolverError;
+use bone_types::FeatureId;
+
+use crate::Sketch;
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum EvaluatedSketch {
+ Solved(Sketch),
+ Failed(SolverError),
+}
+
+#[must_use]
+pub fn evaluate_sketch(input: &Sketch) -> EvaluatedSketch {
+ match input.solve() {
+ Ok(solved) => EvaluatedSketch::Solved(solved),
+ Err(error) => EvaluatedSketch::Failed(error),
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct FeatureCache {
+ entries: BTreeMap,
+}
+
+#[derive(Clone, Debug)]
+struct CachedSketch {
+ input: Sketch,
+ output: EvaluatedSketch,
+}
+
+impl FeatureCache {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn evaluate(&mut self, feature: FeatureId, input: &Sketch) -> &EvaluatedSketch {
+ let cached = match self.entries.entry(feature) {
+ Entry::Occupied(mut slot) => {
+ if &slot.get().input != input {
+ *slot.get_mut() = CachedSketch {
+ input: input.clone(),
+ output: evaluate_sketch(input),
+ };
+ }
+ slot.into_mut()
+ }
+ Entry::Vacant(slot) => slot.insert(CachedSketch {
+ input: input.clone(),
+ output: evaluate_sketch(input),
+ }),
+ };
+ &cached.output
+ }
+
+ #[must_use]
+ pub fn lookup(&self, feature: FeatureId) -> Option<&EvaluatedSketch> {
+ self.entries.get(&feature).map(|cached| &cached.output)
+ }
+
+ pub fn invalidate(&mut self, feature: FeatureId) -> bool {
+ self.entries.remove(&feature).is_some()
+ }
+
+ pub fn retain(&mut self, live: impl IntoIterator
- ) {
+ let keep: BTreeSet = live.into_iter().collect();
+ self.entries.retain(|id, _| keep.contains(id));
+ }
+
+ #[must_use]
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+}
diff --git a/crates/bone-document/src/io/blob.rs b/crates/bone-document/src/io/blob.rs
new file mode 100644
index 0000000..e28964b
--- /dev/null
+++ b/crates/bone-document/src/io/blob.rs
@@ -0,0 +1,167 @@
+use std::path::PathBuf;
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
+pub struct BlobHash(blake3::Hash);
+
+impl BlobHash {
+ #[must_use]
+ pub fn of(bytes: &[u8]) -> Self {
+ Self(blake3::hash(bytes))
+ }
+
+ #[must_use]
+ pub const fn from_bytes(bytes: [u8; 32]) -> Self {
+ Self(blake3::Hash::from_bytes(bytes))
+ }
+
+ #[must_use]
+ pub fn bytes(self) -> [u8; 32] {
+ *self.0.as_bytes()
+ }
+
+ #[must_use]
+ pub fn full_hex(self) -> String {
+ self.0.to_hex().to_string()
+ }
+
+ #[must_use]
+ pub fn truncated_128_hex(self) -> String {
+ self.0.to_hex()[..32].to_string()
+ }
+
+ #[must_use]
+ pub fn relative_path(self, kind: BlobKind) -> PathBuf {
+ let hex = self.truncated_128_hex();
+ let (aa, rest) = hex.split_at(2);
+ PathBuf::from(aa).join(format!("{rest}.{ext}", ext = kind.as_str()))
+ }
+}
+
+impl core::fmt::Display for BlobHash {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "{}", self.0.to_hex())
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
+pub struct BlobKind(&'static str);
+
+impl BlobKind {
+ pub const STEP: Self = Self("step");
+ pub const TESS: Self = Self("tess");
+ pub const THUMB: Self = Self("thumb");
+
+ #[must_use]
+ pub fn new(ext: &'static str) -> Self {
+ assert!(
+ !ext.is_empty()
+ && ext
+ .bytes()
+ .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()),
+ "BlobKind extension must be non-empty lowercase ASCII alphanumeric",
+ );
+ Self(ext)
+ }
+
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ self.0
+ }
+}
+
+impl core::fmt::Display for BlobKind {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ f.write_str(self.0)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn of_matches_blake3_reference() {
+ let hash = BlobHash::of(b"");
+ assert_eq!(
+ hash.full_hex(),
+ "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
+ );
+ }
+
+ #[test]
+ fn truncated_is_first_32_hex_chars() {
+ let hash = BlobHash::of(b"example");
+ let full = hash.full_hex();
+ let short = hash.truncated_128_hex();
+ assert_eq!(short.len(), 32);
+ assert_eq!(short, full[..32]);
+ }
+
+ #[test]
+ fn relative_path_is_fanout_then_rest_dot_kind() {
+ let hash = BlobHash::of(b"example");
+ let path = hash.relative_path(BlobKind::STEP);
+ let parts: Vec = path
+ .components()
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
+ .collect();
+ let Some([fanout, file]) = parts
+ .get(..2)
+ .and_then(|s| <&[String; 2]>::try_from(s).ok())
+ else {
+ panic!("two components, got {parts:?}");
+ };
+ assert_eq!(fanout.len(), 2);
+ let Some(stem) = file.strip_suffix(".step") else {
+ panic!("ends with .step: {file}");
+ };
+ assert_eq!(stem.len(), 30);
+ let hex = hash.truncated_128_hex();
+ assert_eq!(fanout, &hex[..2]);
+ assert_eq!(stem, &hex[2..]);
+ }
+
+ #[test]
+ fn from_bytes_roundtrips_through_bytes() {
+ let bytes = [7u8; 32];
+ let hash = BlobHash::from_bytes(bytes);
+ assert_eq!(hash.bytes(), bytes);
+ }
+
+ #[test]
+ fn blob_kind_presets_match_adr_examples() {
+ assert_eq!(BlobKind::STEP.as_str(), "step");
+ assert_eq!(BlobKind::TESS.as_str(), "tess");
+ assert_eq!(BlobKind::THUMB.as_str(), "thumb");
+ }
+
+ #[test]
+ fn blob_kind_new_accepts_lowercase_alphanumeric() {
+ assert_eq!(BlobKind::new("gltf").as_str(), "gltf");
+ assert_eq!(BlobKind::new("v1").as_str(), "v1");
+ }
+
+ #[test]
+ #[should_panic = "BlobKind extension"]
+ fn blob_kind_new_rejects_empty() {
+ let _ = BlobKind::new("");
+ }
+
+ #[test]
+ #[should_panic = "BlobKind extension"]
+ fn blob_kind_new_rejects_uppercase() {
+ let _ = BlobKind::new("STEP");
+ }
+
+ #[test]
+ #[should_panic = "BlobKind extension"]
+ fn blob_kind_new_rejects_path_separator() {
+ let _ = BlobKind::new("bad/ext");
+ }
+
+ #[test]
+ #[should_panic = "BlobKind extension"]
+ fn blob_kind_new_rejects_dot() {
+ let _ = BlobKind::new("step.bak");
+ }
+}
diff --git a/crates/bone-document/src/io/folder.rs b/crates/bone-document/src/io/folder.rs
new file mode 100644
index 0000000..b6e7713
--- /dev/null
+++ b/crates/bone-document/src/io/folder.rs
@@ -0,0 +1,412 @@
+use std::collections::{BTreeMap, BTreeSet};
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::{fs, io};
+
+use bone_types::{SchemaHeader, SchemaVersion, SketchId};
+
+use crate::document::{Document, DocumentHeader, FeatureNode, SketchFile, sketch_filename};
+use crate::io::blob::{BlobHash, BlobKind};
+use crate::io::ron_io::{RonError, from_str, to_string};
+use crate::sketch::SketchEditError;
+
+pub const DOCUMENT_FILE: &str = "document.ron";
+pub const SKETCHES_DIR: &str = "sketches";
+pub const BLOBS_DIR: &str = "blobs";
+pub const CACHES_DIR: &str = "caches";
+
+const ROOT_GITIGNORE: &str = "caches/\n";
+const ROOT_GITATTRIBUTES: &str = "* text=auto eol=lf\n*.boneblob binary\n";
+const CACHES_GITIGNORE: &str = "*\n!.gitignore\n!CACHEDIR.TAG\n";
+const CACHEDIR_TAG: &str = concat!(
+ "Signature: 8a477f597d28d172789f06886806bc55\n",
+ "# This file is a cache directory tag automatically created by bone.\n",
+ "# For information about cache directory tags see https://bford.info/cachedir/\n",
+);
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub struct FolderError(Box);
+
+impl FolderError {
+ #[must_use]
+ pub fn kind(&self) -> &FolderErrorKind {
+ &self.0
+ }
+
+ #[must_use]
+ pub fn into_kind(self) -> FolderErrorKind {
+ *self.0
+ }
+}
+
+impl From for FolderError {
+ fn from(kind: FolderErrorKind) -> Self {
+ Self(Box::new(kind))
+ }
+}
+
+impl FolderErrorKind {
+ fn wrap(self) -> FolderError {
+ FolderError(Box::new(self))
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum FolderErrorKind {
+ #[error("io at {path}: {source}")]
+ Io {
+ path: PathBuf,
+ #[source]
+ source: io::Error,
+ },
+ #[error("ron at {path}: {source}")]
+ Ron {
+ path: PathBuf,
+ #[source]
+ source: RonError,
+ },
+ #[error("unknown schema {found} (expected name {expected_name})")]
+ UnknownSchema {
+ found: String,
+ expected_name: &'static str,
+ },
+ #[error("schema {name} major v{found} is unsupported (this build supports v{supported})")]
+ UnsupportedMajor {
+ name: String,
+ found: SchemaVersion,
+ supported: SchemaVersion,
+ },
+ #[error("registry references sketch {id:?} with no file on disk")]
+ MissingSketchFile { id: SketchId },
+ #[error("integrity of {path}: {source}")]
+ SketchIntegrity {
+ path: PathBuf,
+ #[source]
+ source: SketchEditError,
+ },
+ #[error("feature tree references sketch {id:?} not in registry")]
+ DanglingTreeSketch { id: SketchId },
+ #[error("registry has sketch {id:?} absent from feature tree")]
+ OrphanRegistered { id: SketchId },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct DocumentFolder {
+ path: PathBuf,
+}
+
+impl DocumentFolder {
+ #[must_use]
+ pub fn new(path: impl Into) -> Self {
+ Self { path: path.into() }
+ }
+
+ #[must_use]
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ #[must_use]
+ pub fn document_file(&self) -> PathBuf {
+ self.path.join(DOCUMENT_FILE)
+ }
+
+ #[must_use]
+ pub fn sketches_dir(&self) -> PathBuf {
+ self.path.join(SKETCHES_DIR)
+ }
+
+ #[must_use]
+ pub fn blobs_dir(&self) -> PathBuf {
+ self.path.join(BLOBS_DIR)
+ }
+
+ #[must_use]
+ pub fn caches_dir(&self) -> PathBuf {
+ self.path.join(CACHES_DIR)
+ }
+
+ #[must_use]
+ pub fn sketch_path(&self, id: SketchId) -> PathBuf {
+ self.sketches_dir().join(sketch_filename(id))
+ }
+
+ #[must_use]
+ pub fn blob_path(&self, hash: BlobHash, kind: BlobKind) -> PathBuf {
+ self.blobs_dir().join(hash.relative_path(kind))
+ }
+}
+
+pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> {
+ ensure_dir(folder.path())?;
+ ensure_dir(&folder.sketches_dir())?;
+ ensure_dir(&folder.blobs_dir())?;
+ ensure_dir(&folder.caches_dir())?;
+
+ write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?;
+ write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?;
+ write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?;
+ write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE)?;
+
+ document
+ .sketches()
+ .try_for_each(|(id, sketch)| -> Result<(), FolderError> {
+ let file = SketchFile::new(sketch.clone());
+ let ron = to_ron(&folder.sketch_path(id), &file)?;
+ atomic_write(&folder.sketch_path(id), &ron)
+ })?;
+
+ let document_ron = to_ron(&folder.document_file(), document.header())?;
+ atomic_write(&folder.document_file(), &document_ron)?;
+
+ let registry_ids: BTreeSet = document.registry().order().iter().copied().collect();
+ remove_stale_sketches(&folder.sketches_dir(), ®istry_ids)?;
+
+ Ok(())
+}
+
+pub fn load(folder: &DocumentFolder) -> Result {
+ let header_path = folder.document_file();
+ let header_text = read_to_string(&header_path)?;
+ let header: DocumentHeader = from_ron(&header_path, &header_text)?;
+ check_schema(&header.schema)?;
+ validate_header(&header)?;
+
+ let sketches =
+ header
+ .sketches
+ .order()
+ .iter()
+ .copied()
+ .try_fold(BTreeMap::new(), |mut acc, id| {
+ let path = folder.sketch_path(id);
+ let text = read_to_string(&path).map_err(|e| match e.into_kind() {
+ FolderErrorKind::Io { source, .. }
+ if source.kind() == io::ErrorKind::NotFound =>
+ {
+ FolderErrorKind::MissingSketchFile { id }.wrap()
+ }
+ other => other.wrap(),
+ })?;
+ let file: SketchFile = from_ron(&path, &text)?;
+ check_schema(&file.schema)?;
+ file.sketch.validate().map_err(|source| {
+ FolderErrorKind::SketchIntegrity {
+ path: path.clone(),
+ source,
+ }
+ .wrap()
+ })?;
+ acc.insert(id, file.sketch);
+ Ok::<_, FolderError>(acc)
+ })?;
+
+ Ok(Document::from_parts(header, sketches))
+}
+
+fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> {
+ let registered: BTreeSet = header.sketches.order().iter().copied().collect();
+ let tree_sketches: BTreeSet = header
+ .feature_tree
+ .iter()
+ .filter_map(|(_, node)| match node {
+ FeatureNode::Sketch(id) => Some(id),
+ FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => None,
+ })
+ .collect();
+ if let Some(&id) = tree_sketches.difference(®istered).next() {
+ return Err(FolderErrorKind::DanglingTreeSketch { id }.wrap());
+ }
+ if let Some(&id) = registered.difference(&tree_sketches).next() {
+ return Err(FolderErrorKind::OrphanRegistered { id }.wrap());
+ }
+ Ok(())
+}
+
+fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> {
+ if !schema.is_bone_document() {
+ return Err(FolderErrorKind::UnknownSchema {
+ found: schema.name.clone(),
+ expected_name: SchemaHeader::BONE_DOCUMENT_NAME,
+ }
+ .wrap());
+ }
+ let supported = SchemaVersion::new(
+ SchemaHeader::BONE_DOCUMENT_MAJOR,
+ SchemaHeader::BONE_DOCUMENT_MINOR,
+ );
+ if schema.version.major != SchemaHeader::BONE_DOCUMENT_MAJOR {
+ return Err(FolderErrorKind::UnsupportedMajor {
+ name: schema.name.clone(),
+ found: schema.version,
+ supported,
+ }
+ .wrap());
+ }
+ if schema.version.minor > SchemaHeader::BONE_DOCUMENT_MINOR {
+ tracing::info!(
+ name = %schema.name,
+ found = %schema.version,
+ supported = %supported,
+ "accepting newer minor schema version; unknown fields will be rejected by deny_unknown_fields"
+ );
+ }
+ Ok(())
+}
+
+fn ensure_dir(path: &Path) -> Result<(), FolderError> {
+ fs::create_dir_all(path).map_err(|source| {
+ FolderErrorKind::Io {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })
+}
+
+fn write_if_different(path: &Path, contents: &str) -> Result<(), FolderError> {
+ if let Ok(existing) = fs::read_to_string(path)
+ && existing == contents
+ {
+ return Ok(());
+ }
+ atomic_write(path, contents)
+}
+
+fn atomic_write(path: &Path, contents: &str) -> Result<(), FolderError> {
+ let parent = path.parent().unwrap_or_else(|| Path::new("."));
+ ensure_dir(parent)?;
+ let tmp = tmp_sibling(path);
+ write_and_sync(&tmp, contents)?;
+ fs::rename(&tmp, path).map_err(|source| {
+ let _ = fs::remove_file(&tmp);
+ FolderErrorKind::Io {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })?;
+ sync_dir(parent)
+}
+
+fn write_and_sync(path: &Path, contents: &str) -> Result<(), FolderError> {
+ let mut file = fs::File::create(path).map_err(|source| {
+ FolderErrorKind::Io {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })?;
+ file.write_all(contents.as_bytes()).map_err(|source| {
+ FolderErrorKind::Io {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })?;
+ file.sync_all().map_err(|source| {
+ FolderErrorKind::Io {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })
+}
+
+#[cfg(unix)]
+fn sync_dir(path: &Path) -> Result<(), FolderError> {
+ fs::File::open(path)
+ .and_then(|f| f.sync_all())
+ .map_err(|source| {
+ FolderErrorKind::Io {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })
+}
+
+#[cfg(not(unix))]
+fn sync_dir(_: &Path) -> Result<(), FolderError> {
+ Ok(())
+}
+
+fn tmp_sibling(path: &Path) -> PathBuf {
+ let file_name = path
+ .file_name()
+ .map(std::ffi::OsStr::to_os_string)
+ .unwrap_or_default();
+ let mut tmp_name = file_name;
+ tmp_name.push(".tmp");
+ path.with_file_name(tmp_name)
+}
+
+fn read_to_string(path: &Path) -> Result {
+ fs::read_to_string(path).map_err(|source| {
+ FolderErrorKind::Io {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })
+}
+
+fn to_ron(path: &Path, value: &T) -> Result {
+ to_string(value).map_err(|source| {
+ FolderErrorKind::Ron {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })
+}
+
+fn from_ron(path: &Path, text: &str) -> Result {
+ from_str(text).map_err(|source| {
+ FolderErrorKind::Ron {
+ path: path.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })
+}
+
+fn remove_stale_sketches(dir: &Path, live: &BTreeSet) -> Result<(), FolderError> {
+ let entries = match fs::read_dir(dir) {
+ Ok(iter) => iter,
+ Err(ref source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
+ Err(source) => {
+ return Err(FolderErrorKind::Io {
+ path: dir.to_path_buf(),
+ source,
+ }
+ .into());
+ }
+ };
+ let live_names: BTreeSet = live.iter().copied().map(sketch_filename).collect();
+ let modified = entries.into_iter().try_fold(false, |modified, entry| {
+ let entry = entry.map_err(|source| {
+ FolderErrorKind::Io {
+ path: dir.to_path_buf(),
+ source,
+ }
+ .wrap()
+ })?;
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let looks_like_ron = Path::new(&name)
+ .extension()
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("ron"));
+ if looks_like_ron && !live_names.contains(&name) {
+ let path = entry.path();
+ fs::remove_file(&path).map_err(|source| FolderErrorKind::Io { path, source }.wrap())?;
+ Ok::<_, FolderError>(true)
+ } else {
+ Ok(modified)
+ }
+ })?;
+ if modified {
+ sync_dir(dir)?;
+ }
+ Ok(())
+}
diff --git a/crates/bone-document/src/io/mod.rs b/crates/bone-document/src/io/mod.rs
new file mode 100644
index 0000000..e8de835
--- /dev/null
+++ b/crates/bone-document/src/io/mod.rs
@@ -0,0 +1,7 @@
+pub mod blob;
+pub mod folder;
+pub mod ron_io;
+
+pub use blob::{BlobHash, BlobKind};
+pub use folder::{DocumentFolder, FolderError, FolderErrorKind, load, save};
+pub use ron_io::{RonError, from_str, to_string};
diff --git a/crates/bone-document/src/io/ron_io.rs b/crates/bone-document/src/io/ron_io.rs
new file mode 100644
index 0000000..b870bb6
--- /dev/null
+++ b/crates/bone-document/src/io/ron_io.rs
@@ -0,0 +1,34 @@
+use ron::extensions::Extensions;
+use ron::ser::PrettyConfig;
+use serde::{Serialize, de::DeserializeOwned};
+
+#[derive(Debug, thiserror::Error)]
+pub enum RonError {
+ #[error("serialize: {0}")]
+ Serialize(#[from] ron::Error),
+ #[error("deserialize: {0}")]
+ Deserialize(#[from] ron::error::SpannedError),
+}
+
+#[must_use]
+pub fn pretty_config() -> PrettyConfig {
+ PrettyConfig::new()
+ .struct_names(true)
+ .separate_tuple_members(false)
+ .compact_arrays(true)
+ .depth_limit(usize::MAX)
+ .indentor(" ".to_owned())
+ .extensions(
+ Extensions::UNWRAP_NEWTYPES
+ | Extensions::IMPLICIT_SOME
+ | Extensions::EXPLICIT_STRUCT_NAMES,
+ )
+}
+
+pub fn to_string(value: &T) -> Result {
+ Ok(ron::ser::to_string_pretty(value, pretty_config())?)
+}
+
+pub fn from_str(input: &str) -> Result {
+ Ok(ron::de::from_str(input)?)
+}
diff --git a/crates/bone-document/src/lib.rs b/crates/bone-document/src/lib.rs
new file mode 100644
index 0000000..0c6357d
--- /dev/null
+++ b/crates/bone-document/src/lib.rs
@@ -0,0 +1,34 @@
+pub mod document;
+pub mod evaluator;
+pub mod io;
+pub mod sketch;
+pub mod undo;
+
+pub use document::{
+ Document, DocumentHeader, DocumentParameters, FeatureNode, FeatureTree, PrincipalPlane,
+ SketchFile, SketchRegistry, SketchRegistryEntry, UnitsPreference, sketch_filename,
+};
+pub use evaluator::{EvaluatedSketch, FeatureCache, evaluate_sketch};
+pub use io::{
+ BlobHash, BlobKind, DocumentFolder, FolderError, FolderErrorKind, RonError, from_str, load,
+ save, to_string,
+};
+pub use sketch::{
+ ArcData, CircleData, DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch,
+ EditOutcome, EntityRefs, LineData, PointData, RelationRefs, Sketch, SketchDimension,
+ SketchDofReport, SketchEdit, SketchEditError, SketchEntity, SketchEntityKind, SketchParameter,
+ SketchRelation,
+};
+pub use undo::UndoStack;
+
+#[derive(Debug, thiserror::Error)]
+pub enum DocumentError {
+ #[error(transparent)]
+ SketchEdit(#[from] SketchEditError),
+ #[error(transparent)]
+ Folder(#[from] FolderError),
+ #[error(transparent)]
+ Ron(#[from] RonError),
+}
+
+pub type Result = core::result::Result;
diff --git a/crates/bone-document/src/sketch/dimension.rs b/crates/bone-document/src/sketch/dimension.rs
new file mode 100644
index 0000000..c4777a7
--- /dev/null
+++ b/crates/bone-document/src/sketch/dimension.rs
@@ -0,0 +1,123 @@
+use bone_types::{Angle, Length, SketchEntityId, dimensioned_serde};
+use serde::{Deserialize, Serialize};
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum DimensionKind {
+ Driving,
+ Driven,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum DimensionValue {
+ #[serde(with = "dimensioned_serde::length_si")]
+ Length(Length),
+ #[serde(with = "dimensioned_serde::angle_si")]
+ Angle(Angle),
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub enum SketchDimension {
+ Linear {
+ a: SketchEntityId,
+ b: SketchEntityId,
+ #[serde(with = "dimensioned_serde::length_si")]
+ value: Length,
+ kind: DimensionKind,
+ },
+ Radius {
+ target: SketchEntityId,
+ #[serde(with = "dimensioned_serde::length_si")]
+ value: Length,
+ kind: DimensionKind,
+ },
+ Diameter {
+ target: SketchEntityId,
+ #[serde(with = "dimensioned_serde::length_si")]
+ value: Length,
+ kind: DimensionKind,
+ },
+ Angular {
+ a: SketchEntityId,
+ b: SketchEntityId,
+ #[serde(with = "dimensioned_serde::angle_si")]
+ value: Angle,
+ kind: DimensionKind,
+ },
+}
+
+impl SketchDimension {
+ #[must_use]
+ pub const fn kind(&self) -> DimensionKind {
+ match *self {
+ Self::Linear { kind, .. }
+ | Self::Radius { kind, .. }
+ | Self::Diameter { kind, .. }
+ | Self::Angular { kind, .. } => kind,
+ }
+ }
+
+ #[must_use]
+ pub const fn value(&self) -> DimensionValue {
+ match *self {
+ Self::Linear { value, .. }
+ | Self::Radius { value, .. }
+ | Self::Diameter { value, .. } => DimensionValue::Length(value),
+ Self::Angular { value, .. } => DimensionValue::Angle(value),
+ }
+ }
+
+ #[must_use]
+ pub const fn references(&self) -> DimensionRefs {
+ match *self {
+ Self::Linear { a, b, .. } | Self::Angular { a, b, .. } => {
+ DimensionRefs([Some(a), Some(b)])
+ }
+ Self::Radius { target, .. } | Self::Diameter { target, .. } => {
+ DimensionRefs([Some(target), None])
+ }
+ }
+ }
+
+ pub fn with_value(self, value: DimensionValue) -> Result {
+ match (self, value) {
+ (Self::Linear { a, b, kind, .. }, DimensionValue::Length(value)) => {
+ Ok(Self::Linear { a, b, value, kind })
+ }
+ (Self::Radius { target, kind, .. }, DimensionValue::Length(value)) => {
+ Ok(Self::Radius {
+ target,
+ value,
+ kind,
+ })
+ }
+ (Self::Diameter { target, kind, .. }, DimensionValue::Length(value)) => {
+ Ok(Self::Diameter {
+ target,
+ value,
+ kind,
+ })
+ }
+ (Self::Angular { a, b, kind, .. }, DimensionValue::Angle(value)) => {
+ Ok(Self::Angular { a, b, value, kind })
+ }
+ _ => Err(DimensionValueMismatch),
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct DimensionRefs([Option; 2]);
+
+impl IntoIterator for DimensionRefs {
+ type Item = SketchEntityId;
+ type IntoIter = core::iter::Flatten, 2>>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter().flatten()
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
+#[error("dimension value kind does not match variant")]
+pub struct DimensionValueMismatch;
diff --git a/crates/bone-document/src/sketch/edit.rs b/crates/bone-document/src/sketch/edit.rs
new file mode 100644
index 0000000..0398233
--- /dev/null
+++ b/crates/bone-document/src/sketch/edit.rs
@@ -0,0 +1,35 @@
+use bone_types::{SketchDimensionId, SketchEntityId, SketchParameterId, SketchRelationId};
+
+use super::dimension::{DimensionValue, SketchDimension};
+use super::entity::SketchEntity;
+use super::parameter::SketchParameter;
+use super::relation::SketchRelation;
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum SketchEdit {
+ AddEntity(SketchEntity),
+ AddRelation(SketchRelation),
+ AddDimension(SketchDimension),
+ AddParameter(SketchParameter),
+ DeleteEntity(SketchEntityId),
+ DeleteRelation(SketchRelationId),
+ DeleteDimension(SketchDimensionId),
+ DeleteParameter(SketchParameterId),
+ SetConstruction {
+ id: SketchEntityId,
+ for_construction: bool,
+ },
+ UpdateDimensionValue {
+ id: SketchDimensionId,
+ value: DimensionValue,
+ },
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum EditOutcome {
+ Entity(SketchEntityId),
+ Relation(SketchRelationId),
+ Dimension(SketchDimensionId),
+ Parameter(SketchParameterId),
+ None,
+}
diff --git a/crates/bone-document/src/sketch/entity.rs b/crates/bone-document/src/sketch/entity.rs
new file mode 100644
index 0000000..fde4095
--- /dev/null
+++ b/crates/bone-document/src/sketch/entity.rs
@@ -0,0 +1,254 @@
+use bone_types::{Length, Point2, SketchEntityId, dimensioned_serde};
+use serde::{Deserialize, Serialize};
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct PointData {
+ at: Point2,
+}
+
+impl PointData {
+ #[must_use]
+ pub const fn new(at: Point2) -> Self {
+ Self { at }
+ }
+
+ #[must_use]
+ pub const fn at(self) -> Point2 {
+ self.at
+ }
+
+ #[must_use]
+ pub const fn for_construction(self) -> bool {
+ true
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct LineData {
+ a: SketchEntityId,
+ b: SketchEntityId,
+ for_construction: bool,
+}
+
+impl LineData {
+ #[must_use]
+ pub const fn new(a: SketchEntityId, b: SketchEntityId, for_construction: bool) -> Self {
+ Self {
+ a,
+ b,
+ for_construction,
+ }
+ }
+
+ #[must_use]
+ pub const fn a(self) -> SketchEntityId {
+ self.a
+ }
+
+ #[must_use]
+ pub const fn b(self) -> SketchEntityId {
+ self.b
+ }
+
+ #[must_use]
+ pub const fn for_construction(self) -> bool {
+ self.for_construction
+ }
+
+ #[must_use]
+ pub const fn with_construction(self, for_construction: bool) -> Self {
+ Self {
+ for_construction,
+ ..self
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ArcData {
+ center: SketchEntityId,
+ start: SketchEntityId,
+ end: SketchEntityId,
+ for_construction: bool,
+}
+
+impl ArcData {
+ #[must_use]
+ pub const fn new(
+ center: SketchEntityId,
+ start: SketchEntityId,
+ end: SketchEntityId,
+ for_construction: bool,
+ ) -> Self {
+ Self {
+ center,
+ start,
+ end,
+ for_construction,
+ }
+ }
+
+ #[must_use]
+ pub const fn center(self) -> SketchEntityId {
+ self.center
+ }
+
+ #[must_use]
+ pub const fn start(self) -> SketchEntityId {
+ self.start
+ }
+
+ #[must_use]
+ pub const fn end(self) -> SketchEntityId {
+ self.end
+ }
+
+ #[must_use]
+ pub const fn for_construction(self) -> bool {
+ self.for_construction
+ }
+
+ #[must_use]
+ pub const fn with_construction(self, for_construction: bool) -> Self {
+ Self {
+ for_construction,
+ ..self
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct CircleData {
+ center: SketchEntityId,
+ #[serde(with = "dimensioned_serde::length_si")]
+ radius: Length,
+ for_construction: bool,
+}
+
+impl CircleData {
+ #[must_use]
+ pub const fn new(center: SketchEntityId, radius: Length, for_construction: bool) -> Self {
+ Self {
+ center,
+ radius,
+ for_construction,
+ }
+ }
+
+ #[must_use]
+ pub const fn center(self) -> SketchEntityId {
+ self.center
+ }
+
+ #[must_use]
+ pub const fn radius(self) -> Length {
+ self.radius
+ }
+
+ #[must_use]
+ pub const fn for_construction(self) -> bool {
+ self.for_construction
+ }
+
+ #[must_use]
+ pub const fn with_construction(self, for_construction: bool) -> Self {
+ Self {
+ for_construction,
+ ..self
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum SketchEntityKind {
+ Point,
+ Line,
+ Arc,
+ Circle,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum SketchEntity {
+ Point(PointData),
+ Line(LineData),
+ Arc(ArcData),
+ Circle(CircleData),
+}
+
+impl SketchEntity {
+ #[must_use]
+ pub const fn point(at: Point2) -> Self {
+ Self::Point(PointData::new(at))
+ }
+
+ #[must_use]
+ pub const fn line(a: SketchEntityId, b: SketchEntityId, for_construction: bool) -> Self {
+ Self::Line(LineData::new(a, b, for_construction))
+ }
+
+ #[must_use]
+ pub const fn arc(
+ center: SketchEntityId,
+ start: SketchEntityId,
+ end: SketchEntityId,
+ for_construction: bool,
+ ) -> Self {
+ Self::Arc(ArcData::new(center, start, end, for_construction))
+ }
+
+ #[must_use]
+ pub const fn circle(center: SketchEntityId, radius: Length, for_construction: bool) -> Self {
+ Self::Circle(CircleData::new(center, radius, for_construction))
+ }
+
+ #[must_use]
+ pub const fn for_construction(&self) -> bool {
+ match *self {
+ Self::Point(p) => p.for_construction(),
+ Self::Line(l) => l.for_construction(),
+ Self::Arc(a) => a.for_construction(),
+ Self::Circle(c) => c.for_construction(),
+ }
+ }
+
+ #[must_use]
+ pub const fn is_point(&self) -> bool {
+ matches!(self, Self::Point(_))
+ }
+
+ #[must_use]
+ pub const fn kind(&self) -> SketchEntityKind {
+ match *self {
+ Self::Point(_) => SketchEntityKind::Point,
+ Self::Line(_) => SketchEntityKind::Line,
+ Self::Arc(_) => SketchEntityKind::Arc,
+ Self::Circle(_) => SketchEntityKind::Circle,
+ }
+ }
+
+ #[must_use]
+ pub const fn references(&self) -> EntityRefs {
+ match *self {
+ Self::Point(_) => EntityRefs([None, None, None]),
+ Self::Line(l) => EntityRefs([Some(l.a()), Some(l.b()), None]),
+ Self::Arc(a) => EntityRefs([Some(a.center()), Some(a.start()), Some(a.end())]),
+ Self::Circle(c) => EntityRefs([Some(c.center()), None, None]),
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct EntityRefs([Option; 3]);
+
+impl IntoIterator for EntityRefs {
+ type Item = SketchEntityId;
+ type IntoIter = core::iter::Flatten, 3>>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter().flatten()
+ }
+}
diff --git a/crates/bone-document/src/sketch/mod.rs b/crates/bone-document/src/sketch/mod.rs
new file mode 100644
index 0000000..fd25516
--- /dev/null
+++ b/crates/bone-document/src/sketch/mod.rs
@@ -0,0 +1,1611 @@
+use std::collections::{BTreeSet, HashMap};
+use std::sync::Arc;
+
+use bone_types::{
+ Length, Point2, SketchDimensionId, SketchEntityId, SketchParameterId, SketchPlaneBasis,
+ SketchRelationId,
+};
+use serde::{Deserialize, Serialize};
+use slotmap::SlotMap;
+
+pub mod dimension;
+pub mod edit;
+pub mod entity;
+pub mod parameter;
+pub mod relation;
+pub mod solve;
+
+pub use dimension::{
+ DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch, SketchDimension,
+};
+pub use edit::{EditOutcome, SketchEdit};
+pub use entity::{
+ ArcData, CircleData, EntityRefs, LineData, PointData, SketchEntity, SketchEntityKind,
+};
+pub use parameter::SketchParameter;
+pub use relation::{RelationRefs, SketchRelation};
+pub use solve::{DragError, Mapping as SketchSolveMapping, SketchDofReport};
+
+type EntityMap = SlotMap;
+type RelationMap = SlotMap;
+type DimensionMap = SlotMap;
+type ParameterMap = SlotMap;
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Sketch {
+ plane: SketchPlaneBasis,
+ entities: Arc,
+ entity_order: Arc>,
+ relations: Arc,
+ relation_order: Arc>,
+ dimensions: Arc,
+ dimension_order: Arc>,
+ parameters: Arc,
+ parameter_order: Arc>,
+}
+
+impl Sketch {
+ #[must_use]
+ pub fn new(plane: SketchPlaneBasis) -> Self {
+ Self {
+ plane,
+ entities: Arc::new(SlotMap::with_key()),
+ entity_order: Arc::new(Vec::new()),
+ relations: Arc::new(SlotMap::with_key()),
+ relation_order: Arc::new(Vec::new()),
+ dimensions: Arc::new(SlotMap::with_key()),
+ dimension_order: Arc::new(Vec::new()),
+ parameters: Arc::new(SlotMap::with_key()),
+ parameter_order: Arc::new(Vec::new()),
+ }
+ }
+
+ #[must_use]
+ pub fn plane(&self) -> SketchPlaneBasis {
+ self.plane
+ }
+
+ #[must_use]
+ pub fn entities(&self) -> &EntityMap {
+ &self.entities
+ }
+
+ #[must_use]
+ pub fn relations(&self) -> &RelationMap {
+ &self.relations
+ }
+
+ #[must_use]
+ pub fn dimensions(&self) -> &DimensionMap {
+ &self.dimensions
+ }
+
+ #[must_use]
+ pub fn parameters(&self) -> &ParameterMap {
+ &self.parameters
+ }
+
+ #[must_use]
+ pub fn entity_order(&self) -> &[SketchEntityId] {
+ &self.entity_order
+ }
+
+ #[must_use]
+ pub fn relation_order(&self) -> &[SketchRelationId] {
+ &self.relation_order
+ }
+
+ #[must_use]
+ pub fn dimension_order(&self) -> &[SketchDimensionId] {
+ &self.dimension_order
+ }
+
+ #[must_use]
+ pub fn parameter_order(&self) -> &[SketchParameterId] {
+ &self.parameter_order
+ }
+
+ fn entries_equal(order: &[K], lhs: &SlotMap, rhs: &SlotMap) -> bool
+ where
+ K: slotmap::Key,
+ V: PartialEq,
+ {
+ order.iter().all(|id| lhs.get(*id) == rhs.get(*id))
+ }
+
+ pub fn validate(&self) -> Result<(), SketchEditError> {
+ ensure_order_matches("entities", &self.entity_order, &self.entities)?;
+ ensure_order_matches("relations", &self.relation_order, &self.relations)?;
+ ensure_order_matches("dimensions", &self.dimension_order, &self.dimensions)?;
+ ensure_order_matches("parameters", &self.parameter_order, &self.parameters)?;
+
+ self.entity_order.iter().copied().try_for_each(|id| {
+ let entity = *self.require_entity(id)?;
+ validate_entity_shape(entity)?;
+ entity
+ .references()
+ .into_iter()
+ .try_for_each(|reference| self.require_point(reference))
+ })?;
+
+ self.relation_order.iter().copied().try_for_each(|id| {
+ let rel = *self
+ .relations
+ .get(id)
+ .ok_or(SketchEditError::RelationNotFound(id))?;
+ self.validate_relation(rel)
+ })?;
+
+ self.dimension_order.iter().copied().try_for_each(|id| {
+ let dim = *self
+ .dimensions
+ .get(id)
+ .ok_or(SketchEditError::DimensionNotFound(id))?;
+ self.validate_dimension(dim)
+ })
+ }
+
+ pub fn apply(self, edit: SketchEdit) -> Result<(Self, EditOutcome), SketchEditError> {
+ let result = match edit {
+ SketchEdit::AddEntity(entity) => {
+ entity
+ .references()
+ .into_iter()
+ .try_for_each(|ref_id| self.require_point(ref_id))?;
+ validate_entity_shape(entity)?;
+ Ok(self.add_entity(entity))
+ }
+ SketchEdit::AddRelation(rel) => self.add_relation(rel),
+ SketchEdit::AddDimension(dim) => self.add_dimension(dim),
+ SketchEdit::AddParameter(p) => Ok(self.add_parameter(p)),
+ SketchEdit::DeleteEntity(id) => self.delete_entity(id),
+ SketchEdit::DeleteRelation(id) => self.delete_relation(id),
+ SketchEdit::DeleteDimension(id) => self.delete_dimension(id),
+ SketchEdit::DeleteParameter(id) => self.delete_parameter(id),
+ SketchEdit::SetConstruction {
+ id,
+ for_construction,
+ } => self.set_construction(id, for_construction),
+ SketchEdit::UpdateDimensionValue { id, value } => self.update_dimension(id, value),
+ };
+ if let Ok((ref sketch, _)) = result {
+ sketch.assert_invariants();
+ }
+ result
+ }
+
+ fn assert_invariants(&self) {
+ debug_assert_eq!(self.entity_order.len(), self.entities.len());
+ debug_assert_eq!(self.relation_order.len(), self.relations.len());
+ debug_assert_eq!(self.dimension_order.len(), self.dimensions.len());
+ debug_assert_eq!(self.parameter_order.len(), self.parameters.len());
+ debug_assert!(
+ self.entity_order
+ .iter()
+ .all(|id| self.entities.contains_key(*id))
+ );
+ debug_assert!(
+ self.relation_order
+ .iter()
+ .all(|id| self.relations.contains_key(*id))
+ );
+ debug_assert!(
+ self.dimension_order
+ .iter()
+ .all(|id| self.dimensions.contains_key(*id))
+ );
+ debug_assert!(
+ self.parameter_order
+ .iter()
+ .all(|id| self.parameters.contains_key(*id))
+ );
+ }
+
+ pub fn apply_all(&self, edits: I) -> Result<(Self, Vec), SketchEditError>
+ where
+ I: IntoIterator
- ,
+ {
+ edits.into_iter().try_fold(
+ (self.clone(), Vec::new()),
+ |(sketch, mut outcomes), edit| {
+ let (next, outcome) = sketch.apply(edit)?;
+ outcomes.push(outcome);
+ Ok((next, outcomes))
+ },
+ )
+ }
+
+ fn require_entity(&self, id: SketchEntityId) -> Result<&SketchEntity, SketchEditError> {
+ self.entities
+ .get(id)
+ .ok_or(SketchEditError::EntityNotFound(id))
+ }
+
+ fn require_point(&self, id: SketchEntityId) -> Result<(), SketchEditError> {
+ if self.require_entity(id)?.is_point() {
+ Ok(())
+ } else {
+ Err(SketchEditError::ExpectedPoint(id))
+ }
+ }
+
+ fn kind_of(&self, id: SketchEntityId) -> Result {
+ self.require_entity(id).map(SketchEntity::kind)
+ }
+
+ fn validate_relation(&self, rel: SketchRelation) -> Result<(), SketchEditError> {
+ use SketchEntityKind as K;
+ if let Some((a, b)) = rel.pair()
+ && a == b
+ {
+ return Err(SketchEditError::SelfReferencingRelation(rel));
+ }
+ let ok = match rel {
+ SketchRelation::Coincident(a, b) => {
+ matches!(
+ (self.kind_of(a)?, self.kind_of(b)?),
+ (K::Point, _) | (_, K::Point)
+ )
+ }
+ SketchRelation::Horizontal(a) | SketchRelation::Vertical(a) => {
+ self.kind_of(a)? == K::Line
+ }
+ SketchRelation::Parallel(a, b) | SketchRelation::Perpendicular(a, b) => {
+ self.kind_of(a)? == K::Line && self.kind_of(b)? == K::Line
+ }
+ SketchRelation::Tangent(a, b) => {
+ let (ka, kb) = (self.kind_of(a)?, self.kind_of(b)?);
+ let curve = |k| matches!(k, K::Line | K::Arc | K::Circle);
+ let round = |k| matches!(k, K::Arc | K::Circle);
+ curve(ka) && curve(kb) && (round(ka) || round(kb))
+ }
+ SketchRelation::Equal(a, b) => matches!(
+ (self.kind_of(a)?, self.kind_of(b)?),
+ (K::Line, K::Line) | (K::Arc | K::Circle, K::Arc | K::Circle)
+ ),
+ SketchRelation::Concentric(a, b) => {
+ matches!(self.kind_of(a)?, K::Arc | K::Circle)
+ && matches!(self.kind_of(b)?, K::Arc | K::Circle)
+ }
+ SketchRelation::Fix(a) => {
+ self.kind_of(a)?;
+ true
+ }
+ };
+ if ok {
+ Ok(())
+ } else {
+ Err(SketchEditError::InvalidRelationOperands(rel))
+ }
+ }
+
+ fn validate_dimension(&self, dim: SketchDimension) -> Result<(), SketchEditError> {
+ use SketchEntityKind as K;
+ let ok = match dim {
+ SketchDimension::Linear { a, b, .. } => {
+ self.kind_of(a)? == K::Point && self.kind_of(b)? == K::Point
+ }
+ SketchDimension::Radius { target, .. } | SketchDimension::Diameter { target, .. } => {
+ matches!(self.kind_of(target)?, K::Arc | K::Circle)
+ }
+ SketchDimension::Angular { a, b, .. } => {
+ self.kind_of(a)? == K::Line && self.kind_of(b)? == K::Line
+ }
+ };
+ if ok {
+ Ok(())
+ } else {
+ Err(SketchEditError::InvalidDimensionOperands(dim))
+ }
+ }
+
+ fn add_entity(self, entity: SketchEntity) -> (Self, EditOutcome) {
+ let (id, entities) = insert(self.entities, entity);
+ let entity_order = push(self.entity_order, id);
+ let next = Self {
+ entities,
+ entity_order,
+ ..self
+ };
+ (next, EditOutcome::Entity(id))
+ }
+
+ fn add_relation(self, rel: SketchRelation) -> Result<(Self, EditOutcome), SketchEditError> {
+ self.validate_relation(rel)?;
+ let (id, relations) = insert(self.relations, rel);
+ let relation_order = push(self.relation_order, id);
+ let next = Self {
+ relations,
+ relation_order,
+ ..self
+ };
+ Ok((next, EditOutcome::Relation(id)))
+ }
+
+ fn add_dimension(self, dim: SketchDimension) -> Result<(Self, EditOutcome), SketchEditError> {
+ self.validate_dimension(dim)?;
+ let (id, dimensions) = insert(self.dimensions, dim);
+ let dimension_order = push(self.dimension_order, id);
+ let next = Self {
+ dimensions,
+ dimension_order,
+ ..self
+ };
+ Ok((next, EditOutcome::Dimension(id)))
+ }
+
+ fn add_parameter(self, parameter: SketchParameter) -> (Self, EditOutcome) {
+ let (id, parameters) = insert(self.parameters, parameter);
+ let parameter_order = push(self.parameter_order, id);
+ let next = Self {
+ parameters,
+ parameter_order,
+ ..self
+ };
+ (next, EditOutcome::Parameter(id))
+ }
+
+ fn delete_entity(self, id: SketchEntityId) -> Result<(Self, EditOutcome), SketchEditError> {
+ if !self.entities.contains_key(id) {
+ return Err(SketchEditError::EntityNotFound(id));
+ }
+ let closure = entity_closure(&self.entities, id);
+ let entities = remove_many(self.entities, closure.iter().copied());
+ let entity_order = retain(&self.entity_order, |eid| !closure.contains(eid));
+ let touches_closure_rel =
+ |rel: &SketchRelation| rel.references().into_iter().any(|e| closure.contains(&e));
+ let touches_closure_dim =
+ |dim: &SketchDimension| dim.references().into_iter().any(|e| closure.contains(&e));
+ let (relations, dropped_relations) = remove_where(self.relations, touches_closure_rel);
+ let relation_order = retain(&self.relation_order, |rid| !dropped_relations.contains(rid));
+ let (dimensions, dropped_dimensions) = remove_where(self.dimensions, touches_closure_dim);
+ let dimension_order = retain(&self.dimension_order, |did| {
+ !dropped_dimensions.contains(did)
+ });
+ let next = Self {
+ entities,
+ entity_order,
+ relations,
+ relation_order,
+ dimensions,
+ dimension_order,
+ ..self
+ };
+ Ok((next, EditOutcome::None))
+ }
+
+ fn delete_relation(self, id: SketchRelationId) -> Result<(Self, EditOutcome), SketchEditError> {
+ if !self.relations.contains_key(id) {
+ return Err(SketchEditError::RelationNotFound(id));
+ }
+ let relations = remove_one(self.relations, id);
+ let relation_order = retain(&self.relation_order, |rid| *rid != id);
+ let next = Self {
+ relations,
+ relation_order,
+ ..self
+ };
+ Ok((next, EditOutcome::None))
+ }
+
+ fn delete_dimension(
+ self,
+ id: SketchDimensionId,
+ ) -> Result<(Self, EditOutcome), SketchEditError> {
+ if !self.dimensions.contains_key(id) {
+ return Err(SketchEditError::DimensionNotFound(id));
+ }
+ let dimensions = remove_one(self.dimensions, id);
+ let dimension_order = retain(&self.dimension_order, |did| *did != id);
+ let next = Self {
+ dimensions,
+ dimension_order,
+ ..self
+ };
+ Ok((next, EditOutcome::None))
+ }
+
+ fn delete_parameter(
+ self,
+ id: SketchParameterId,
+ ) -> Result<(Self, EditOutcome), SketchEditError> {
+ if !self.parameters.contains_key(id) {
+ return Err(SketchEditError::ParameterNotFound(id));
+ }
+ let parameters = remove_one(self.parameters, id);
+ let parameter_order = retain(&self.parameter_order, |pid| *pid != id);
+ let next = Self {
+ parameters,
+ parameter_order,
+ ..self
+ };
+ Ok((next, EditOutcome::None))
+ }
+
+ fn set_construction(
+ self,
+ id: SketchEntityId,
+ for_construction: bool,
+ ) -> Result<(Self, EditOutcome), SketchEditError> {
+ let entity = *self.require_entity(id)?;
+ let updated = match entity {
+ SketchEntity::Point(_) => return Err(SketchEditError::PointAlwaysConstruction),
+ SketchEntity::Line(l) => SketchEntity::Line(l.with_construction(for_construction)),
+ SketchEntity::Arc(a) => SketchEntity::Arc(a.with_construction(for_construction)),
+ SketchEntity::Circle(c) => SketchEntity::Circle(c.with_construction(for_construction)),
+ };
+ if updated == entity {
+ return Ok((self, EditOutcome::None));
+ }
+ let entities = replace(self.entities, id, updated);
+ let next = Self { entities, ..self };
+ Ok((next, EditOutcome::None))
+ }
+
+ fn update_dimension(
+ self,
+ id: SketchDimensionId,
+ value: DimensionValue,
+ ) -> Result<(Self, EditOutcome), SketchEditError> {
+ let existing = *self
+ .dimensions
+ .get(id)
+ .ok_or(SketchEditError::DimensionNotFound(id))?;
+ if existing.kind() == DimensionKind::Driven {
+ return Err(SketchEditError::DimensionIsDriven(id));
+ }
+ let updated = existing
+ .with_value(value)
+ .map_err(|_| SketchEditError::DimensionValueMismatch { id })?;
+ if updated == existing {
+ return Ok((self, EditOutcome::None));
+ }
+ let dimensions = replace(self.dimensions, id, updated);
+ let next = Self { dimensions, ..self };
+ Ok((next, EditOutcome::None))
+ }
+
+ #[must_use]
+ pub(crate) fn with_point_positions(self, updates: &HashMap) -> Self {
+ if updates.is_empty() {
+ return self;
+ }
+ let entities = updates
+ .iter()
+ .fold(unwrap_arc(self.entities), |mut acc, (id, pt)| {
+ if let Some(slot @ SketchEntity::Point(_)) = acc.get_mut(*id) {
+ *slot = SketchEntity::Point(PointData::new(*pt));
+ }
+ acc
+ });
+ Self {
+ entities: Arc::new(entities),
+ ..self
+ }
+ }
+
+ #[must_use]
+ pub(crate) fn with_circle_radii(self, updates: &HashMap) -> Self {
+ if updates.is_empty() {
+ return self;
+ }
+ let entities = updates
+ .iter()
+ .fold(unwrap_arc(self.entities), |mut acc, (id, r)| {
+ if let Some(SketchEntity::Circle(existing)) = acc.get(*id).copied()
+ && let Some(slot) = acc.get_mut(*id)
+ {
+ *slot = SketchEntity::Circle(CircleData::new(
+ existing.center(),
+ *r,
+ existing.for_construction(),
+ ));
+ }
+ acc
+ });
+ Self {
+ entities: Arc::new(entities),
+ ..self
+ }
+ }
+
+ #[must_use]
+ pub(crate) fn with_driven_dimension_values(
+ self,
+ updates: &[(SketchDimensionId, DimensionValue)],
+ ) -> Self {
+ if updates.is_empty() {
+ return self;
+ }
+ let dimensions =
+ updates
+ .iter()
+ .copied()
+ .fold(unwrap_arc(self.dimensions), |mut acc, (id, v)| {
+ let existing = acc.get(id).copied();
+ if let Some(dim) = existing
+ && dim.kind() == DimensionKind::Driven
+ && let Ok(next) = dim.with_value(v)
+ && let Some(slot) = acc.get_mut(id)
+ {
+ *slot = next;
+ }
+ acc
+ });
+ Self {
+ dimensions: Arc::new(dimensions),
+ ..self
+ }
+ }
+}
+
+impl PartialEq for Sketch {
+ fn eq(&self, other: &Self) -> bool {
+ self.plane == other.plane
+ && *self.entity_order == *other.entity_order
+ && Self::entries_equal(&self.entity_order, &self.entities, &other.entities)
+ && *self.relation_order == *other.relation_order
+ && Self::entries_equal(&self.relation_order, &self.relations, &other.relations)
+ && *self.dimension_order == *other.dimension_order
+ && Self::entries_equal(&self.dimension_order, &self.dimensions, &other.dimensions)
+ && *self.parameter_order == *other.parameter_order
+ && Self::entries_equal(&self.parameter_order, &self.parameters, &other.parameters)
+ }
+}
+
+fn ensure_order_matches(
+ container: &'static str,
+ order: &[K],
+ map: &SlotMap,
+) -> Result<(), SketchEditError>
+where
+ K: slotmap::Key + Ord,
+{
+ let order_set: BTreeSet = order.iter().copied().collect();
+ let has_duplicates = order_set.len() != order.len();
+ let same_size = order.len() == map.len();
+ let covers_map = order_set.iter().all(|id| map.contains_key(*id));
+ if has_duplicates || !same_size || !covers_map {
+ Err(SketchEditError::OrderMismatch { container })
+ } else {
+ Ok(())
+ }
+}
+
+fn validate_entity_shape(entity: SketchEntity) -> Result<(), SketchEditError> {
+ let ok = match entity {
+ SketchEntity::Point(_) => true,
+ SketchEntity::Line(l) => l.a() != l.b(),
+ SketchEntity::Arc(a) => {
+ a.center() != a.start() && a.start() != a.end() && a.center() != a.end()
+ }
+ SketchEntity::Circle(c) => c.radius() > Length::default(),
+ };
+ if ok {
+ Ok(())
+ } else {
+ Err(SketchEditError::DegenerateEntity(entity))
+ }
+}
+
+fn insert(map: Arc>, value: V) -> (K, Arc>)
+where
+ K: slotmap::Key,
+ V: Clone,
+{
+ let mut owned = unwrap_arc(map);
+ let id = owned.insert(value);
+ (id, Arc::new(owned))
+}
+
+fn push(list: Arc>, value: T) -> Arc> {
+ let mut owned = unwrap_arc(list);
+ owned.push(value);
+ Arc::new(owned)
+}
+
+fn retain(list: &Arc>, keep: F) -> Arc>
+where
+ T: Clone,
+ F: Fn(&T) -> bool,
+{
+ Arc::new(list.iter().filter(|t| keep(t)).cloned().collect())
+}
+
+fn remove_one(map: Arc>, id: K) -> Arc>
+where
+ K: slotmap::Key,
+ V: Clone,
+{
+ let mut owned = unwrap_arc(map);
+ owned.remove(id);
+ Arc::new(owned)
+}
+
+fn remove_many(map: Arc>, ids: I) -> Arc>
+where
+ K: slotmap::Key,
+ V: Clone,
+ I: IntoIterator
- ,
+{
+ let owned = ids.into_iter().fold(unwrap_arc(map), |mut acc, id| {
+ acc.remove(id);
+ acc
+ });
+ Arc::new(owned)
+}
+
+fn remove_where(map: Arc>, predicate: F) -> (Arc>, BTreeSet)
+where
+ K: slotmap::Key + Ord,
+ V: Clone,
+ F: Fn(&V) -> bool,
+{
+ let dropped: BTreeSet = map
+ .iter()
+ .filter_map(|(k, v)| predicate(v).then_some(k))
+ .collect();
+ let next = remove_many(map, dropped.iter().copied());
+ (next, dropped)
+}
+
+fn replace(map: Arc>, id: K, value: V) -> Arc>
+where
+ K: slotmap::Key,
+ V: Clone,
+{
+ let mut owned = unwrap_arc(map);
+ if let Some(slot) = owned.get_mut(id) {
+ *slot = value;
+ }
+ Arc::new(owned)
+}
+
+fn unwrap_arc(arc: Arc) -> T {
+ Arc::try_unwrap(arc).unwrap_or_else(|shared| (*shared).clone())
+}
+
+fn entity_closure(entities: &EntityMap, seed: SketchEntityId) -> BTreeSet {
+ grow_closure(entities, BTreeSet::from([seed]))
+}
+
+fn grow_closure(
+ entities: &EntityMap,
+ closure: BTreeSet,
+) -> BTreeSet {
+ let grown: BTreeSet = entities
+ .iter()
+ .filter_map(|(id, entity)| {
+ entity
+ .references()
+ .into_iter()
+ .any(|r| closure.contains(&r))
+ .then_some(id)
+ })
+ .chain(closure.iter().copied())
+ .collect();
+ if grown == closure {
+ closure
+ } else {
+ grow_closure(entities, grown)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+pub enum SketchEditError {
+ #[error("entity not found: {0:?}")]
+ EntityNotFound(SketchEntityId),
+ #[error("relation not found: {0:?}")]
+ RelationNotFound(SketchRelationId),
+ #[error("dimension not found: {0:?}")]
+ DimensionNotFound(SketchDimensionId),
+ #[error("parameter not found: {0:?}")]
+ ParameterNotFound(SketchParameterId),
+ #[error("expected point entity: {0:?}")]
+ ExpectedPoint(SketchEntityId),
+ #[error("cannot toggle construction on a point")]
+ PointAlwaysConstruction,
+ #[error("dimension {id:?} value kind does not match variant")]
+ DimensionValueMismatch { id: SketchDimensionId },
+ #[error("cannot update driven dimension: {0:?}")]
+ DimensionIsDriven(SketchDimensionId),
+ #[error("invalid relation operands: {0:?}")]
+ InvalidRelationOperands(SketchRelation),
+ #[error("invalid dimension operands: {0:?}")]
+ InvalidDimensionOperands(SketchDimension),
+ #[error("entity is degenerate: {0:?}")]
+ DegenerateEntity(SketchEntity),
+ #[error("self-referencing relation: {0:?}")]
+ SelfReferencingRelation(SketchRelation),
+ #[error("{container} order list is out of step with its slotmap")]
+ OrderMismatch { container: &'static str },
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bone_types::{
+ Angle, Length, Parameter, Point2, Point3, Tolerance, UnitVec3, degree, millimeter,
+ };
+
+ fn plane() -> SketchPlaneBasis {
+ let Ok(basis) = SketchPlaneBasis::new(
+ Point3::origin(),
+ UnitVec3::x_axis(),
+ UnitVec3::y_axis(),
+ Tolerance::new(1e-9),
+ ) else {
+ panic!("xy plane basis is orthogonal");
+ };
+ basis
+ }
+
+ fn len_mm(v: f64) -> Length {
+ Length::new::(v)
+ }
+
+ fn angle_deg(v: f64) -> Angle {
+ Angle::new::(v)
+ }
+
+ fn rectangle_script() -> Vec {
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))),
+ ]
+ }
+
+ fn apply_script(sketch: &Sketch, edits: Vec) -> (Sketch, Vec) {
+ let Ok(pair) = sketch.apply_all(edits) else {
+ panic!("fixture edits apply cleanly");
+ };
+ pair
+ }
+
+ #[test]
+ fn point_is_always_construction() {
+ let p = SketchEntity::point(Point2::from_mm(0.0, 0.0));
+ assert!(p.for_construction());
+ }
+
+ #[test]
+ fn new_sketch_is_empty() {
+ let s = Sketch::new(plane());
+ assert!(s.entities().is_empty());
+ assert!(s.relations().is_empty());
+ assert!(s.dimensions().is_empty());
+ assert!(s.parameters().is_empty());
+ assert!(s.entity_order().is_empty());
+ }
+
+ #[test]
+ fn add_point_records_insertion_order() {
+ let (sketch, outcomes) = apply_script(&Sketch::new(plane()), rectangle_script());
+ let ids: Vec<_> = outcomes
+ .iter()
+ .map(|o| match *o {
+ EditOutcome::Entity(id) => id,
+ _ => panic!("expected entity outcomes"),
+ })
+ .collect();
+ assert_eq!(sketch.entity_order(), ids.as_slice());
+ assert_eq!(sketch.entities().len(), 4);
+ }
+
+ #[test]
+ fn add_line_requires_point_endpoints() {
+ let mut sketch = Sketch::new(plane());
+ let Ok((s1, EditOutcome::Entity(a))) = sketch.apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("add point");
+ };
+ let Ok((s2, EditOutcome::Entity(b))) = s1.apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(1.0, 0.0)),
+ )) else {
+ panic!("add point");
+ };
+ let Ok((s3, EditOutcome::Entity(_))) =
+ s2.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("add line");
+ };
+ sketch = s3;
+ let bad = sketch.apply(SketchEdit::AddEntity(SketchEntity::line(
+ a,
+ SketchEntityId::default(),
+ false,
+ )));
+ assert!(matches!(bad, Err(SketchEditError::EntityNotFound(_))));
+ }
+
+ #[test]
+ fn add_line_rejects_non_point_endpoint() {
+ let Ok((s1, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("add point");
+ };
+ let Ok((s2, EditOutcome::Entity(center))) = s1.apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(2.0, 0.0)),
+ )) else {
+ panic!("add center");
+ };
+ let Ok((s3, EditOutcome::Entity(circle))) = s2.apply(SketchEdit::AddEntity(
+ SketchEntity::circle(center, len_mm(1.0), false),
+ )) else {
+ panic!("add circle");
+ };
+ let bad = s3.apply(SketchEdit::AddEntity(SketchEntity::line(a, circle, false)));
+ assert!(matches!(bad, Err(SketchEditError::ExpectedPoint(_))));
+ }
+
+ #[test]
+ fn delete_entity_cascades_dependents() {
+ let s = Sketch::new(plane());
+ let Ok((s, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(0.0, 0.0),
+ ))) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("line");
+ };
+ let Ok((s, EditOutcome::Relation(_))) =
+ s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line)))
+ else {
+ panic!("rel");
+ };
+ let Ok((s, EditOutcome::Dimension(_))) =
+ s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: len_mm(10.0),
+ kind: DimensionKind::Driving,
+ }))
+ else {
+ panic!("dim");
+ };
+ let Ok((after, EditOutcome::None)) = s.apply(SketchEdit::DeleteEntity(a)) else {
+ panic!("delete");
+ };
+ assert_eq!(after.entities().len(), 1);
+ assert!(after.entities().contains_key(b));
+ assert!(!after.entities().contains_key(line));
+ assert!(after.relations().is_empty());
+ assert!(after.dimensions().is_empty());
+ assert_eq!(after.entity_order(), [b]);
+ assert!(after.relation_order().is_empty());
+ assert!(after.dimension_order().is_empty());
+ }
+
+ #[test]
+ fn delete_entity_errors_on_unknown() {
+ let s = Sketch::new(plane());
+ let bad = s.apply(SketchEdit::DeleteEntity(SketchEntityId::default()));
+ assert!(matches!(bad, Err(SketchEditError::EntityNotFound(_))));
+ }
+
+ #[test]
+ fn set_construction_refuses_point() {
+ let Ok((s, EditOutcome::Entity(p))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("p");
+ };
+ let bad = s.apply(SketchEdit::SetConstruction {
+ id: p,
+ for_construction: false,
+ });
+ assert!(matches!(bad, Err(SketchEditError::PointAlwaysConstruction)));
+ }
+
+ #[test]
+ fn set_construction_toggles_line() {
+ let s = Sketch::new(plane());
+ let Ok((s, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(0.0, 0.0),
+ ))) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("line");
+ };
+ assert!(!s.entities()[line].for_construction());
+ let Ok((s, _)) = s.apply(SketchEdit::SetConstruction {
+ id: line,
+ for_construction: true,
+ }) else {
+ panic!("toggle");
+ };
+ assert!(s.entities()[line].for_construction());
+ }
+
+ #[test]
+ fn update_dimension_value_rejects_unit_mismatch() {
+ let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ let Ok((s, EditOutcome::Dimension(id))) =
+ s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: len_mm(1.0),
+ kind: DimensionKind::Driving,
+ }))
+ else {
+ panic!("dim");
+ };
+ let bad = s.apply(SketchEdit::UpdateDimensionValue {
+ id,
+ value: DimensionValue::Angle(angle_deg(90.0)),
+ });
+ assert!(matches!(
+ bad,
+ Err(SketchEditError::DimensionValueMismatch { .. })
+ ));
+ }
+
+ #[test]
+ fn update_dimension_value_replaces_length() {
+ let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ let Ok((s, EditOutcome::Dimension(id))) =
+ s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: len_mm(1.0),
+ kind: DimensionKind::Driving,
+ }))
+ else {
+ panic!("dim");
+ };
+ let Ok((s, _)) = s.apply(SketchEdit::UpdateDimensionValue {
+ id,
+ value: DimensionValue::Length(len_mm(12.5)),
+ }) else {
+ panic!("update");
+ };
+ let SketchDimension::Linear { value, .. } = s.dimensions()[id] else {
+ panic!("variant unchanged");
+ };
+ assert!((value.get::() - 12.5).abs() < 1e-12);
+ }
+
+ #[test]
+ fn identical_scripts_produce_equal_sketches() {
+ let script = rich_script();
+ let a = Sketch::new(plane())
+ .apply_all(script.clone())
+ .map(|(s, _)| s);
+ let b = Sketch::new(plane()).apply_all(script).map(|(s, _)| s);
+ let (Ok(a), Ok(b)) = (a, b) else {
+ panic!("scripts apply cleanly");
+ };
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn relation_edit_shares_entity_storage() {
+ let (base, _) = apply_script(&Sketch::new(plane()), rectangle_script());
+ let before = base.clone();
+ let Ok((base_with_rel, _)) = base.apply(SketchEdit::AddRelation(SketchRelation::Fix(
+ before.entity_order()[0],
+ ))) else {
+ panic!("relation");
+ };
+ assert!(Arc::ptr_eq(&before.entities, &base_with_rel.entities));
+ assert!(Arc::ptr_eq(
+ &before.entity_order,
+ &base_with_rel.entity_order
+ ));
+ assert!(Arc::ptr_eq(&before.dimensions, &base_with_rel.dimensions));
+ assert!(!Arc::ptr_eq(&before.relations, &base_with_rel.relations));
+ }
+
+ #[test]
+ fn add_parameter_roundtrips() {
+ let s = Sketch::new(plane());
+ let Ok((s, EditOutcome::Parameter(id))) = s.apply(SketchEdit::AddParameter(
+ SketchParameter::new(Parameter::new(0.5)),
+ )) else {
+ panic!("param");
+ };
+ assert!((s.parameters()[id].value().value() - 0.5).abs() < f64::EPSILON);
+ let Ok((s, _)) = s.apply(SketchEdit::DeleteParameter(id)) else {
+ panic!("delete param");
+ };
+ assert!(s.parameters().is_empty());
+ assert!(s.parameter_order().is_empty());
+ }
+
+ fn rich_script() -> Vec {
+ let mut edits = rectangle_script();
+ edits.extend([
+ SketchEdit::AddParameter(SketchParameter::new(Parameter::new(0.0))),
+ SketchEdit::AddParameter(SketchParameter::new(Parameter::new(1.0))),
+ ]);
+ edits
+ }
+
+ fn two_points() -> (Sketch, SketchEntityId, SketchEntityId) {
+ let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ (s, a, b)
+ }
+
+ #[test]
+ fn horizontal_on_point_is_rejected() {
+ let (s, a, _) = two_points();
+ let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(a)));
+ assert!(matches!(
+ bad,
+ Err(SketchEditError::InvalidRelationOperands(_))
+ ));
+ }
+
+ #[test]
+ fn tangent_between_two_lines_is_rejected() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Entity(c))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(0.0, 1.0),
+ ))) else {
+ panic!("c");
+ };
+ let Ok((s, EditOutcome::Entity(l1))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("l1");
+ };
+ let Ok((s, EditOutcome::Entity(l2))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, c, false)))
+ else {
+ panic!("l2");
+ };
+ let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Tangent(l1, l2)));
+ assert!(matches!(
+ bad,
+ Err(SketchEditError::InvalidRelationOperands(_))
+ ));
+ }
+
+ #[test]
+ fn equal_cross_kind_is_rejected() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("line");
+ };
+ let Ok((s, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity(
+ SketchEntity::circle(a, len_mm(1.0), false),
+ )) else {
+ panic!("circle");
+ };
+ let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Equal(line, circle)));
+ assert!(matches!(
+ bad,
+ Err(SketchEditError::InvalidRelationOperands(_))
+ ));
+ }
+
+ #[test]
+ fn coincident_point_on_line_is_accepted() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("line");
+ };
+ let Ok((s2, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Coincident(a, line)))
+ else {
+ panic!("coincident");
+ };
+ assert_eq!(s2.relations().len(), 1);
+ }
+
+ #[test]
+ fn linear_dimension_on_non_point_is_rejected() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("line");
+ };
+ let bad = s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a: line,
+ b: a,
+ value: len_mm(1.0),
+ kind: DimensionKind::Driving,
+ }));
+ assert!(matches!(
+ bad,
+ Err(SketchEditError::InvalidDimensionOperands(_))
+ ));
+ }
+
+ #[test]
+ fn driven_dimension_cannot_be_updated() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Dimension(id))) =
+ s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: len_mm(1.0),
+ kind: DimensionKind::Driven,
+ }))
+ else {
+ panic!("dim");
+ };
+ let bad = s.apply(SketchEdit::UpdateDimensionValue {
+ id,
+ value: DimensionValue::Length(len_mm(2.0)),
+ });
+ assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_))));
+ }
+
+ #[test]
+ fn apply_all_leaves_caller_unchanged_on_error() {
+ let (base, _) = apply_script(&Sketch::new(plane()), rectangle_script());
+ let snapshot = base.clone();
+ let bad_edit = SketchEdit::AddRelation(SketchRelation::Horizontal(base.entity_order()[0]));
+ let result = base.apply_all([bad_edit]);
+ assert!(matches!(
+ result,
+ Err(SketchEditError::InvalidRelationOperands(_))
+ ));
+ assert_eq!(base, snapshot);
+ }
+
+ #[test]
+ fn line_with_same_endpoints_is_rejected() {
+ let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("a");
+ };
+ let bad = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, a, false)));
+ assert!(matches!(bad, Err(SketchEditError::DegenerateEntity(_))));
+ }
+
+ #[test]
+ fn arc_with_repeated_endpoints_is_rejected() {
+ let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ let bad = s.apply(SketchEdit::AddEntity(SketchEntity::arc(a, b, a, false)));
+ assert!(matches!(bad, Err(SketchEditError::DegenerateEntity(_))));
+ }
+
+ #[test]
+ fn self_referencing_relation_is_rejected() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("line");
+ };
+ let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Parallel(
+ line, line,
+ )));
+ assert!(matches!(
+ bad,
+ Err(SketchEditError::SelfReferencingRelation(_))
+ ));
+ }
+
+ #[test]
+ fn coincident_self_reference_is_rejected() {
+ let (s, a, _) = two_points();
+ let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Coincident(a, a)));
+ assert!(matches!(
+ bad,
+ Err(SketchEditError::SelfReferencingRelation(_))
+ ));
+ }
+
+ #[test]
+ fn set_construction_noop_shares_storage() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, true)))
+ else {
+ panic!("line");
+ };
+ let before = s.clone();
+ let Ok((after, _)) = s.apply(SketchEdit::SetConstruction {
+ id: line,
+ for_construction: true,
+ }) else {
+ panic!("set");
+ };
+ assert!(Arc::ptr_eq(&before.entities, &after.entities));
+ }
+
+ #[test]
+ fn circle_with_non_positive_radius_is_rejected() {
+ let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply(
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ ) else {
+ panic!("center");
+ };
+ let zero = s.clone().apply(SketchEdit::AddEntity(SketchEntity::circle(
+ center,
+ len_mm(0.0),
+ false,
+ )));
+ assert!(matches!(zero, Err(SketchEditError::DegenerateEntity(_))));
+ let negative = s.apply(SketchEdit::AddEntity(SketchEntity::circle(
+ center,
+ len_mm(-1.0),
+ false,
+ )));
+ assert!(matches!(
+ negative,
+ Err(SketchEditError::DegenerateEntity(_))
+ ));
+ }
+
+ #[test]
+ fn delete_relation_errors_on_unknown() {
+ let s = Sketch::new(plane());
+ let bad = s.apply(SketchEdit::DeleteRelation(SketchRelationId::default()));
+ assert!(matches!(bad, Err(SketchEditError::RelationNotFound(_))));
+ }
+
+ #[test]
+ fn delete_dimension_errors_on_unknown() {
+ let s = Sketch::new(plane());
+ let bad = s.apply(SketchEdit::DeleteDimension(SketchDimensionId::default()));
+ assert!(matches!(bad, Err(SketchEditError::DimensionNotFound(_))));
+ }
+
+ #[test]
+ fn delete_parameter_errors_on_unknown() {
+ let s = Sketch::new(plane());
+ let bad = s.apply(SketchEdit::DeleteParameter(SketchParameterId::default()));
+ assert!(matches!(bad, Err(SketchEditError::ParameterNotFound(_))));
+ }
+
+ #[test]
+ fn update_dimension_errors_on_unknown() {
+ let s = Sketch::new(plane());
+ let bad = s.apply(SketchEdit::UpdateDimensionValue {
+ id: SketchDimensionId::default(),
+ value: DimensionValue::Length(len_mm(1.0)),
+ });
+ assert!(matches!(bad, Err(SketchEditError::DimensionNotFound(_))));
+ }
+
+ #[test]
+ fn driven_radius_cannot_be_updated() {
+ let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply(
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ ) else {
+ panic!("center");
+ };
+ let Ok((s, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity(
+ SketchEntity::circle(center, len_mm(1.0), false),
+ )) else {
+ panic!("circle");
+ };
+ let Ok((s, EditOutcome::Dimension(id))) =
+ s.apply(SketchEdit::AddDimension(SketchDimension::Radius {
+ target: circle,
+ value: len_mm(1.0),
+ kind: DimensionKind::Driven,
+ }))
+ else {
+ panic!("dim");
+ };
+ let bad = s.apply(SketchEdit::UpdateDimensionValue {
+ id,
+ value: DimensionValue::Length(len_mm(2.0)),
+ });
+ assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_))));
+ }
+
+ #[test]
+ fn driven_diameter_cannot_be_updated() {
+ let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply(
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ ) else {
+ panic!("center");
+ };
+ let Ok((s, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity(
+ SketchEntity::circle(center, len_mm(1.0), false),
+ )) else {
+ panic!("circle");
+ };
+ let Ok((s, EditOutcome::Dimension(id))) =
+ s.apply(SketchEdit::AddDimension(SketchDimension::Diameter {
+ target: circle,
+ value: len_mm(2.0),
+ kind: DimensionKind::Driven,
+ }))
+ else {
+ panic!("dim");
+ };
+ let bad = s.apply(SketchEdit::UpdateDimensionValue {
+ id,
+ value: DimensionValue::Length(len_mm(4.0)),
+ });
+ assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_))));
+ }
+
+ #[test]
+ fn driven_angular_cannot_be_updated() {
+ let (s, a, b) = two_points();
+ let Ok((s, EditOutcome::Entity(c))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(0.0, 1.0),
+ ))) else {
+ panic!("c");
+ };
+ let Ok((s, EditOutcome::Entity(l1))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("l1");
+ };
+ let Ok((s, EditOutcome::Entity(l2))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, c, false)))
+ else {
+ panic!("l2");
+ };
+ let Ok((s, EditOutcome::Dimension(id))) =
+ s.apply(SketchEdit::AddDimension(SketchDimension::Angular {
+ a: l1,
+ b: l2,
+ value: angle_deg(90.0),
+ kind: DimensionKind::Driven,
+ }))
+ else {
+ panic!("dim");
+ };
+ let bad = s.apply(SketchEdit::UpdateDimensionValue {
+ id,
+ value: DimensionValue::Angle(angle_deg(45.0)),
+ });
+ assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_))));
+ }
+
+ mod properties {
+ use super::*;
+ use proptest::prelude::*;
+
+ #[derive(Copy, Clone, Debug)]
+ enum Step {
+ Point(i16, i16),
+ Parameter(i16),
+ Line {
+ ai: u8,
+ bi: u8,
+ },
+ Circle {
+ ci: u8,
+ r: u16,
+ },
+ Horizontal {
+ li: u8,
+ },
+ LinearDim {
+ ai: u8,
+ bi: u8,
+ v: u16,
+ driven: bool,
+ },
+ UpdateDim {
+ di: u8,
+ v: u16,
+ },
+ Toggle {
+ ei: u8,
+ },
+ DelEntity {
+ ei: u8,
+ },
+ DelParam {
+ pi: u8,
+ },
+ }
+
+ fn arb_step() -> impl Strategy {
+ prop_oneof![
+ (any::(), any::()).prop_map(|(x, y)| Step::Point(x, y)),
+ any::().prop_map(Step::Parameter),
+ (any::(), any::()).prop_map(|(ai, bi)| Step::Line { ai, bi }),
+ (any::(), any::()).prop_map(|(ci, r)| Step::Circle { ci, r: r % 100 + 1 }),
+ any::().prop_map(|li| Step::Horizontal { li }),
+ (any::(), any::(), any::(), any::())
+ .prop_map(|(ai, bi, v, driven)| Step::LinearDim { ai, bi, v, driven }),
+ (any::(), any::()).prop_map(|(di, v)| Step::UpdateDim { di, v }),
+ any::().prop_map(|ei| Step::Toggle { ei }),
+ any::().prop_map(|ei| Step::DelEntity { ei }),
+ any::().prop_map(|pi| Step::DelParam { pi }),
+ ]
+ }
+
+ fn entities_of_kind(s: &Sketch, kind: SketchEntityKind) -> Vec {
+ s.entity_order()
+ .iter()
+ .copied()
+ .filter(|id| s.entities()[*id].kind() == kind)
+ .collect()
+ }
+
+ fn pick(xs: &[T], i: u8) -> Option {
+ if xs.is_empty() {
+ None
+ } else {
+ Some(xs[usize::from(i) % xs.len()])
+ }
+ }
+
+ fn pick_two_distinct(xs: &[T], ai: u8, bi: u8) -> Option<(T, T)> {
+ if xs.len() < 2 {
+ return None;
+ }
+ let ai = usize::from(ai) % xs.len();
+ let offset = usize::from(bi) % (xs.len() - 1) + 1;
+ let bi = (ai + offset) % xs.len();
+ Some((xs[ai], xs[bi]))
+ }
+
+ fn resolve_step(s: &Sketch, step: Step) -> Option {
+ match step {
+ Step::Point(x, y) => Some(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(f64::from(x), f64::from(y)),
+ ))),
+ Step::Parameter(v) => Some(SketchEdit::AddParameter(SketchParameter::new(
+ Parameter::new(f64::from(v)),
+ ))),
+ Step::Line { ai, bi } => {
+ let (a, b) =
+ pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?;
+ Some(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ }
+ Step::Circle { ci, r } => {
+ let c = pick(&entities_of_kind(s, SketchEntityKind::Point), ci)?;
+ Some(SketchEdit::AddEntity(SketchEntity::circle(
+ c,
+ Length::new::(f64::from(r)),
+ false,
+ )))
+ }
+ Step::Horizontal { li } => {
+ let l = pick(&entities_of_kind(s, SketchEntityKind::Line), li)?;
+ Some(SketchEdit::AddRelation(SketchRelation::Horizontal(l)))
+ }
+ Step::LinearDim { ai, bi, v, driven } => {
+ let (a, b) =
+ pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?;
+ let kind = if driven {
+ DimensionKind::Driven
+ } else {
+ DimensionKind::Driving
+ };
+ Some(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: Length::new::(f64::from(v)),
+ kind,
+ }))
+ }
+ Step::UpdateDim { di, v } => {
+ let id = pick(s.dimension_order(), di)?;
+ Some(SketchEdit::UpdateDimensionValue {
+ id,
+ value: DimensionValue::Length(Length::new::(f64::from(v))),
+ })
+ }
+ Step::Toggle { ei } => {
+ let id = pick(s.entity_order(), ei)?;
+ Some(SketchEdit::SetConstruction {
+ id,
+ for_construction: true,
+ })
+ }
+ Step::DelEntity { ei } => {
+ let id = pick(s.entity_order(), ei)?;
+ Some(SketchEdit::DeleteEntity(id))
+ }
+ Step::DelParam { pi } => {
+ let id = pick(s.parameter_order(), pi)?;
+ Some(SketchEdit::DeleteParameter(id))
+ }
+ }
+ }
+
+ fn build_script(steps: Vec) -> Vec {
+ steps
+ .into_iter()
+ .fold(
+ (Sketch::new(plane()), Vec::new()),
+ |(sk, mut acc), step| match resolve_step(&sk, step) {
+ Some(edit) => match sk.clone().apply(edit) {
+ Ok((next, _)) => {
+ acc.push(edit);
+ (next, acc)
+ }
+ Err(_) => (sk, acc),
+ },
+ None => (sk, acc),
+ },
+ )
+ .1
+ }
+
+ proptest! {
+ #[test]
+ fn apply_all_is_deterministic(steps in prop::collection::vec(arb_step(), 0..40)) {
+ let script = build_script(steps);
+ let Ok((a, _)) = Sketch::new(plane()).apply_all(script.clone()) else {
+ unreachable!("build_script only emits successfully-applied edits");
+ };
+ let Ok((b, _)) = Sketch::new(plane()).apply_all(script) else {
+ unreachable!();
+ };
+ prop_assert_eq!(a, b);
+ }
+
+ #[test]
+ fn apply_all_matches_apply_fold(steps in prop::collection::vec(arb_step(), 0..40)) {
+ let script = build_script(steps);
+ let Ok((via_all, _)) = Sketch::new(plane()).apply_all(script.clone()) else {
+ unreachable!();
+ };
+ let via_fold = script.into_iter().try_fold(Sketch::new(plane()), |acc, e| {
+ acc.apply(e).map(|(s, _)| s)
+ });
+ let Ok(via_fold) = via_fold else { unreachable!(); };
+ prop_assert_eq!(via_all, via_fold);
+ }
+ }
+ }
+}
diff --git a/crates/bone-document/src/sketch/parameter.rs b/crates/bone-document/src/sketch/parameter.rs
new file mode 100644
index 0000000..1f7c80b
--- /dev/null
+++ b/crates/bone-document/src/sketch/parameter.rs
@@ -0,0 +1,25 @@
+use bone_types::Parameter;
+use serde::{Deserialize, Serialize};
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct SketchParameter {
+ value: Parameter,
+}
+
+impl SketchParameter {
+ #[must_use]
+ pub const fn new(value: Parameter) -> Self {
+ Self { value }
+ }
+
+ #[must_use]
+ pub const fn value(self) -> Parameter {
+ self.value
+ }
+
+ #[must_use]
+ pub const fn with_value(self, value: Parameter) -> Self {
+ Self { value }
+ }
+}
diff --git a/crates/bone-document/src/sketch/relation.rs b/crates/bone-document/src/sketch/relation.rs
new file mode 100644
index 0000000..389f1f6
--- /dev/null
+++ b/crates/bone-document/src/sketch/relation.rs
@@ -0,0 +1,55 @@
+use bone_types::SketchEntityId;
+use serde::{Deserialize, Serialize};
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum SketchRelation {
+ Coincident(SketchEntityId, SketchEntityId),
+ Horizontal(SketchEntityId),
+ Vertical(SketchEntityId),
+ Parallel(SketchEntityId, SketchEntityId),
+ Perpendicular(SketchEntityId, SketchEntityId),
+ Tangent(SketchEntityId, SketchEntityId),
+ Equal(SketchEntityId, SketchEntityId),
+ Concentric(SketchEntityId, SketchEntityId),
+ Fix(SketchEntityId),
+}
+
+impl SketchRelation {
+ #[must_use]
+ pub const fn references(&self) -> RelationRefs {
+ match *self {
+ Self::Coincident(a, b)
+ | Self::Parallel(a, b)
+ | Self::Perpendicular(a, b)
+ | Self::Tangent(a, b)
+ | Self::Equal(a, b)
+ | Self::Concentric(a, b) => RelationRefs([Some(a), Some(b)]),
+ Self::Horizontal(a) | Self::Vertical(a) | Self::Fix(a) => RelationRefs([Some(a), None]),
+ }
+ }
+
+ #[must_use]
+ pub const fn pair(&self) -> Option<(SketchEntityId, SketchEntityId)> {
+ match *self {
+ Self::Coincident(a, b)
+ | Self::Parallel(a, b)
+ | Self::Perpendicular(a, b)
+ | Self::Tangent(a, b)
+ | Self::Equal(a, b)
+ | Self::Concentric(a, b) => Some((a, b)),
+ Self::Horizontal(_) | Self::Vertical(_) | Self::Fix(_) => None,
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct RelationRefs([Option; 2]);
+
+impl IntoIterator for RelationRefs {
+ type Item = SketchEntityId;
+ type IntoIter = core::iter::Flatten, 2>>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter().flatten()
+ }
+}
diff --git a/crates/bone-document/src/sketch/snapshots/bone_document__sketch__solve__tests__rectangle_sparsity.snap b/crates/bone-document/src/sketch/snapshots/bone_document__sketch__solve__tests__rectangle_sparsity.snap
new file mode 100644
index 0000000..771fa04
--- /dev/null
+++ b/crates/bone-document/src/sketch/snapshots/bone_document__sketch__solve__tests__rectangle_sparsity.snap
@@ -0,0 +1,13 @@
+---
+source: crates/bone-document/src/sketch/solve.rs
+assertion_line: 692
+expression: pattern.display()
+---
+.X.X....
+.....X.X
+..X.X...
+X.....X.
+X.......
+.X......
+XXXX....
+..XXXX..
diff --git a/crates/bone-document/src/sketch/solve.rs b/crates/bone-document/src/sketch/solve.rs
new file mode 100644
index 0000000..5fa794b
--- /dev/null
+++ b/crates/bone-document/src/sketch/solve.rs
@@ -0,0 +1,1913 @@
+use std::collections::{BTreeMap, HashMap};
+use std::time::Instant;
+
+use bone_solver::{
+ ConstraintSystem, CurveRadius, DofConfig, DofReport, LineHandle, NewtonConfig, PointHandle,
+ Residual, SolverError, analyze_dof, analyze_dof_at, decompose, dedup_preserving_order,
+ minimal_unsatisfiable_subset, solve_newton_decomposed,
+};
+use bone_types::{
+ Angle, BudgetCeiling, DegreesOfFreedom, Length, Parameter, ParameterIndex, Point2,
+ ResidualIndex, SketchDimensionId, SketchEntityId, SketchItemId, SketchRelationId, millimeter,
+ radian,
+};
+
+use super::{
+ DimensionKind, DimensionValue, Sketch, SketchDimension, SketchEntity, SketchEntityKind,
+ SketchRelation,
+};
+
+#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+pub enum DragError {
+ #[error("dragged entity {0:?} is not a Point")]
+ NotAPoint(SketchEntityId),
+ #[error("dragged entity {0:?} not found in sketch")]
+ NotFound(SketchEntityId),
+ #[error(transparent)]
+ Solver(#[from] SolverError),
+}
+
+#[derive(Clone, Debug)]
+pub struct Mapping {
+ points: BTreeMap,
+ radii: BTreeMap,
+ residual_origin: Vec,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+enum Origin {
+ Relation(SketchRelationId),
+ Dimension(SketchDimensionId),
+ Intrinsic(SketchEntityId),
+}
+
+impl Mapping {
+ #[must_use]
+ pub fn point(&self, id: SketchEntityId) -> Option {
+ self.points.get(&id).copied()
+ }
+
+ #[must_use]
+ pub fn radius(&self, id: SketchEntityId) -> Option {
+ self.radii.get(&id).copied()
+ }
+
+ #[must_use]
+ pub fn item_of_residual(&self, idx: ResidualIndex) -> Option {
+ self.residual_origin.get(idx.as_usize()).map(|o| match *o {
+ Origin::Relation(id) => SketchItemId::Relation(id),
+ Origin::Dimension(id) => SketchItemId::Dimension(id),
+ Origin::Intrinsic(id) => SketchItemId::Entity(id),
+ })
+ }
+
+ #[must_use]
+ pub fn items(&self, indices: impl IntoIterator
- ) -> Vec {
+ indices
+ .into_iter()
+ .filter_map(|i| self.item_of_residual(i))
+ .collect()
+ }
+
+ #[must_use]
+ pub fn entity_of_parameter(&self, idx: ParameterIndex) -> Option {
+ self.points
+ .iter()
+ .find_map(|(id, h)| (h.x == idx || h.y == idx).then_some(*id))
+ .or_else(|| {
+ self.radii
+ .iter()
+ .find_map(|(id, p)| (*p == idx).then_some(*id))
+ })
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SketchDofReport {
+ dof: DegreesOfFreedom,
+ under_constrained: Vec,
+ over_constrained: Vec,
+ redundant_consistent: Vec,
+}
+
+impl SketchDofReport {
+ #[must_use]
+ pub fn dof(&self) -> DegreesOfFreedom {
+ self.dof
+ }
+
+ #[must_use]
+ pub fn under_constrained(&self) -> &[SketchEntityId] {
+ &self.under_constrained
+ }
+
+ #[must_use]
+ pub fn over_constrained(&self) -> &[SketchItemId] {
+ &self.over_constrained
+ }
+
+ #[must_use]
+ pub fn redundant_consistent(&self) -> &[SketchItemId] {
+ &self.redundant_consistent
+ }
+
+ fn from_solver_report(report: &DofReport, mapping: &Mapping) -> Self {
+ Self {
+ dof: report.dof(),
+ under_constrained: dedup_preserving_order(
+ report
+ .under_constrained()
+ .iter()
+ .filter_map(|p| mapping.entity_of_parameter(*p)),
+ ),
+ over_constrained: dedup_preserving_order(
+ mapping.items(report.over_constrained().iter().copied()),
+ ),
+ redundant_consistent: dedup_preserving_order(
+ mapping.items(report.redundant_consistent().iter().copied()),
+ ),
+ }
+ }
+}
+
+impl Sketch {
+ #[must_use]
+ pub fn lower(&self) -> (ConstraintSystem, Mapping) {
+ let (parameters, points, radii) = self.entity_order().iter().fold(
+ (
+ Vec::::new(),
+ BTreeMap::::new(),
+ BTreeMap::::new(),
+ ),
+ |(mut parameters, mut points, mut radii), id| {
+ match self.entities()[*id] {
+ SketchEntity::Point(p) => {
+ let (x, y) = p.at().coords_mm();
+ let handle = PointHandle {
+ x: push_parameter(&mut parameters, x),
+ y: push_parameter(&mut parameters, y),
+ };
+ points.insert(*id, handle);
+ }
+ SketchEntity::Circle(c) => {
+ let idx = push_parameter(&mut parameters, c.radius().get::());
+ radii.insert(*id, idx);
+ }
+ SketchEntity::Line(_) | SketchEntity::Arc(_) => {}
+ }
+ (parameters, points, radii)
+ },
+ );
+
+ let intrinsic_rows: Vec<(Residual, Origin)> = self
+ .entity_order()
+ .iter()
+ .flat_map(|eid| {
+ intrinsic_residuals(self, *eid, &points)
+ .into_iter()
+ .map(move |r| (r, Origin::Intrinsic(*eid)))
+ })
+ .collect();
+
+ let relation_rows: Vec<(Residual, Origin)> = self
+ .relation_order()
+ .iter()
+ .flat_map(|rid| {
+ lower_relation(self, self.relations()[*rid], &points, &radii, ¶meters)
+ .into_iter()
+ .map(move |r| (r, Origin::Relation(*rid)))
+ })
+ .collect();
+
+ let dimension_rows: Vec<(Residual, Origin)> = self
+ .dimension_order()
+ .iter()
+ .filter(|did| self.dimensions()[**did].kind() != DimensionKind::Driven)
+ .flat_map(|did| {
+ lower_dimension(self, self.dimensions()[*did], &points, &radii)
+ .into_iter()
+ .map(move |r| (r, Origin::Dimension(*did)))
+ })
+ .collect();
+
+ let combined: Vec<(Residual, Origin)> = intrinsic_rows
+ .into_iter()
+ .chain(relation_rows)
+ .chain(dimension_rows)
+ .collect();
+ let residual_origin: Vec = combined
+ .iter()
+ .flat_map(|(r, o)| std::iter::repeat_n(*o, r.rows()))
+ .collect();
+ let residuals: Vec = combined.into_iter().map(|(r, _)| r).collect();
+ let system = ConstraintSystem::new(parameters, residuals);
+ let mapping = Mapping {
+ points,
+ radii,
+ residual_origin,
+ };
+ (system, mapping)
+ }
+
+ pub fn solve(&self) -> Result {
+ let (system, mapping) = self.lower();
+ let decomposition = decompose(&system);
+ match solve_newton_decomposed(&system, &decomposition, NewtonConfig::DEFAULT) {
+ Ok(parameters) => {
+ let report = analyze_dof_at(&system, ¶meters, DofConfig::DEFAULT);
+ if report.over_constrained().is_empty() {
+ Ok(self.clone().apply_solution(¶meters, &mapping))
+ } else {
+ Err(narrow_over_defined(
+ &system,
+ &mapping,
+ report.over_constrained(),
+ ))
+ }
+ }
+ Err(SolverError::NoSolutionFound { last }) => {
+ let report = analyze_dof(&system, DofConfig::DEFAULT);
+ if report.over_constrained().is_empty() {
+ Err(SolverError::NoSolutionFound { last })
+ } else {
+ Err(narrow_over_defined(
+ &system,
+ &mapping,
+ report.over_constrained(),
+ ))
+ }
+ }
+ Err(other) => Err(other),
+ }
+ }
+
+ pub fn solve_with_drag(
+ &self,
+ dragged: SketchEntityId,
+ target: Point2,
+ budget: BudgetCeiling,
+ ) -> Result {
+ let started = Instant::now();
+ let entity = self
+ .entities()
+ .get(dragged)
+ .copied()
+ .ok_or(DragError::NotFound(dragged))?;
+ if !matches!(entity, SketchEntity::Point(_)) {
+ return Err(DragError::NotAPoint(dragged));
+ }
+ let warm = self
+ .clone()
+ .with_point_positions(&HashMap::from([(dragged, target)]));
+ let (system, mapping) = warm.lower();
+ let Some(handle) = mapping.point(dragged) else {
+ unreachable!("Point entity must lower to a PointHandle in the mapping")
+ };
+ let (tx, ty) = target.coords_mm();
+ let system = system.with_extra_residuals(vec![
+ Residual::Pin {
+ param: handle.x,
+ target: tx,
+ },
+ Residual::Pin {
+ param: handle.y,
+ target: ty,
+ },
+ ]);
+ let decomposition = decompose(&system);
+ let remaining = BudgetCeiling::new(budget.duration().saturating_sub(started.elapsed()));
+ let cfg = NewtonConfig {
+ budget: Some(remaining),
+ ..NewtonConfig::DEFAULT
+ };
+ let parameters = solve_newton_decomposed(&system, &decomposition, cfg)?;
+ Ok(warm.apply_solution(¶meters, &mapping))
+ }
+
+ #[must_use]
+ pub fn analyze_dof(&self) -> SketchDofReport {
+ let (system, mapping) = self.lower();
+ let decomposition = decompose(&system);
+ let report = match solve_newton_decomposed(&system, &decomposition, NewtonConfig::DEFAULT) {
+ Ok(parameters) => analyze_dof_at(&system, ¶meters, DofConfig::DEFAULT),
+ Err(_) => analyze_dof(&system, DofConfig::DEFAULT),
+ };
+ SketchDofReport::from_solver_report(&report, &mapping)
+ }
+
+ #[must_use]
+ pub fn apply_solution(self, parameters: &[Parameter], mapping: &Mapping) -> Self {
+ let point_updates: HashMap = mapping
+ .points
+ .iter()
+ .map(|(id, h)| {
+ let p = bone_types::Point2::from_mm(
+ parameters[h.x.as_usize()].value(),
+ parameters[h.y.as_usize()].value(),
+ );
+ (*id, p)
+ })
+ .collect();
+ let radius_updates: HashMap = mapping
+ .radii
+ .iter()
+ .map(|(id, idx)| {
+ (
+ *id,
+ Length::new::(parameters[idx.as_usize()].value()),
+ )
+ })
+ .collect();
+ let next = self
+ .with_point_positions(&point_updates)
+ .with_circle_radii(&radius_updates);
+ recompute_driven(next)
+ }
+}
+
+fn narrow_over_defined(
+ system: &ConstraintSystem,
+ mapping: &Mapping,
+ over_flagged: &[ResidualIndex],
+) -> SolverError {
+ let mus = minimal_unsatisfiable_subset(system, over_flagged, DofConfig::DEFAULT);
+ let rows = if mus.is_empty() { over_flagged } else { &mus };
+ SolverError::OverDefined {
+ conflicts: dedup_preserving_order(mapping.items(rows.iter().copied())),
+ }
+}
+
+fn push_parameter(out: &mut Vec, value: f64) -> ParameterIndex {
+ let idx = ParameterIndex::new(u32::try_from(out.len()).unwrap_or(u32::MAX));
+ out.push(Parameter::new(value));
+ idx
+}
+
+fn intrinsic_residuals(
+ sketch: &Sketch,
+ id: SketchEntityId,
+ points: &BTreeMap,
+) -> Vec {
+ match sketch.entities().get(id) {
+ Some(SketchEntity::Arc(a)) => match (
+ points.get(&a.center()).copied(),
+ points.get(&a.start()).copied(),
+ points.get(&a.end()).copied(),
+ ) {
+ (Some(center), Some(spoke), Some(end)) => vec![Residual::CoincidentPointCurve {
+ point: end,
+ curve: CurveRadius::FromSpoke { center, spoke },
+ }],
+ _ => Vec::new(),
+ },
+ _ => Vec::new(),
+ }
+}
+
+fn lower_relation(
+ sketch: &Sketch,
+ relation: SketchRelation,
+ points: &BTreeMap,
+ radii: &BTreeMap,
+ parameters: &[Parameter],
+) -> Vec {
+ match relation {
+ SketchRelation::Coincident(a, b) => lower_coincident(sketch, a, b, points, radii),
+ SketchRelation::Horizontal(id) => line_handle(sketch, id, points)
+ .map(Residual::Horizontal)
+ .into_iter()
+ .collect(),
+ SketchRelation::Vertical(id) => line_handle(sketch, id, points)
+ .map(Residual::Vertical)
+ .into_iter()
+ .collect(),
+ SketchRelation::Parallel(a, b) => combine_lines(sketch, a, b, points, Residual::Parallel),
+ SketchRelation::Perpendicular(a, b) => {
+ combine_lines(sketch, a, b, points, Residual::Perpendicular)
+ }
+ SketchRelation::Tangent(a, b) => lower_tangent(sketch, a, b, points, radii),
+ SketchRelation::Equal(a, b) => lower_equal(sketch, a, b, points, radii),
+ SketchRelation::Concentric(a, b) => lower_concentric(sketch, a, b, points),
+ SketchRelation::Fix(id) => lower_fix(sketch, id, points, radii, parameters),
+ }
+}
+
+fn lower_coincident(
+ sketch: &Sketch,
+ a: SketchEntityId,
+ b: SketchEntityId,
+ points: &BTreeMap,
+ radii: &BTreeMap,
+) -> Vec {
+ let ka = kind(sketch, a);
+ let kb = kind(sketch, b);
+ let (point_id, other_id, other_kind) = match (ka, kb) {
+ (SketchEntityKind::Point, _) => (a, b, kb),
+ (_, SketchEntityKind::Point) => (b, a, ka),
+ _ => unreachable!("Coincident validation guarantees at least one point operand"),
+ };
+ let Some(point) = points.get(&point_id).copied() else {
+ return Vec::new();
+ };
+ match other_kind {
+ SketchEntityKind::Point => points
+ .get(&other_id)
+ .copied()
+ .map(|q| vec![Residual::CoincidentPointPoint(point, q)])
+ .unwrap_or_default(),
+ SketchEntityKind::Line => line_handle(sketch, other_id, points)
+ .map(|line| vec![Residual::CoincidentPointLine { point, line }])
+ .unwrap_or_default(),
+ SketchEntityKind::Circle | SketchEntityKind::Arc => {
+ curve_radius(sketch, other_id, points, radii)
+ .map(|curve| vec![Residual::CoincidentPointCurve { point, curve }])
+ .unwrap_or_default()
+ }
+ }
+}
+
+fn combine_lines(
+ sketch: &Sketch,
+ a: SketchEntityId,
+ b: SketchEntityId,
+ points: &BTreeMap,
+ build: impl Fn(LineHandle, LineHandle) -> Residual,
+) -> Vec {
+ match (
+ line_handle(sketch, a, points),
+ line_handle(sketch, b, points),
+ ) {
+ (Some(la), Some(lb)) => vec![build(la, lb)],
+ _ => Vec::new(),
+ }
+}
+
+fn lower_tangent(
+ sketch: &Sketch,
+ a: SketchEntityId,
+ b: SketchEntityId,
+ points: &BTreeMap,
+ radii: &BTreeMap,
+) -> Vec {
+ let ka = kind(sketch, a);
+ let kb = kind(sketch, b);
+ let line_id;
+ let curve_id;
+ match (ka, kb) {
+ (SketchEntityKind::Line, _) => {
+ line_id = Some(a);
+ curve_id = b;
+ }
+ (_, SketchEntityKind::Line) => {
+ line_id = Some(b);
+ curve_id = a;
+ }
+ _ => {
+ line_id = None;
+ curve_id = a;
+ }
+ }
+ if let Some(lid) = line_id {
+ return match (
+ line_handle(sketch, lid, points),
+ curve_radius(sketch, curve_id, points, radii),
+ ) {
+ (Some(line), Some(curve)) => vec![Residual::TangentLineCurve { line, curve }],
+ _ => Vec::new(),
+ };
+ }
+ match (
+ curve_radius(sketch, a, points, radii),
+ curve_radius(sketch, b, points, radii),
+ ) {
+ (Some(ca), Some(cb)) => vec![Residual::TangentCurveCurve { a: ca, b: cb }],
+ _ => Vec::new(),
+ }
+}
+
+fn lower_equal(
+ sketch: &Sketch,
+ a: SketchEntityId,
+ b: SketchEntityId,
+ points: &BTreeMap,
+ radii: &BTreeMap,
+) -> Vec {
+ match (kind(sketch, a), kind(sketch, b)) {
+ (SketchEntityKind::Line, SketchEntityKind::Line) => {
+ combine_lines(sketch, a, b, points, Residual::EqualLength)
+ }
+ (ka, kb) if is_curve(ka) && is_curve(kb) => match (
+ curve_radius(sketch, a, points, radii),
+ curve_radius(sketch, b, points, radii),
+ ) {
+ (Some(ca), Some(cb)) => vec![Residual::EqualRadius(ca, cb)],
+ _ => Vec::new(),
+ },
+ _ => unreachable!("Equal validation guarantees matching-kind operands"),
+ }
+}
+
+fn lower_concentric(
+ sketch: &Sketch,
+ a: SketchEntityId,
+ b: SketchEntityId,
+ points: &BTreeMap,
+) -> Vec {
+ let ca = curve_center(sketch, a).and_then(|c| points.get(&c).copied());
+ let cb = curve_center(sketch, b).and_then(|c| points.get(&c).copied());
+ match (ca, cb) {
+ (Some(p), Some(q)) => vec![Residual::CoincidentPointPoint(p, q)],
+ _ => Vec::new(),
+ }
+}
+
+fn lower_fix(
+ sketch: &Sketch,
+ id: SketchEntityId,
+ points: &BTreeMap,
+ radii: &BTreeMap,
+ parameters: &[Parameter],
+) -> Vec {
+ let Some(entity) = sketch.entities().get(id).copied() else {
+ return Vec::new();
+ };
+ let point_ids: Vec = match entity {
+ SketchEntity::Point(_) => vec![id],
+ _ => entity.references().into_iter().collect(),
+ };
+ let pins_from_points = point_ids
+ .into_iter()
+ .filter_map(|pid| points.get(&pid).copied())
+ .flat_map(|h| {
+ [
+ Residual::Pin {
+ param: h.x,
+ target: parameters[h.x.as_usize()].value(),
+ },
+ Residual::Pin {
+ param: h.y,
+ target: parameters[h.y.as_usize()].value(),
+ },
+ ]
+ });
+ let radius_pin = matches!(entity, SketchEntity::Circle(_))
+ .then(|| radii.get(&id).copied())
+ .flatten()
+ .map(|idx| Residual::Pin {
+ param: idx,
+ target: parameters[idx.as_usize()].value(),
+ });
+ pins_from_points.chain(radius_pin).collect()
+}
+
+fn lower_dimension(
+ sketch: &Sketch,
+ dim: SketchDimension,
+ points: &BTreeMap,
+ radii: &BTreeMap,
+) -> Vec {
+ match dim {
+ SketchDimension::Linear { a, b, value, .. } => match (points.get(&a), points.get(&b)) {
+ (Some(pa), Some(pb)) => vec![Residual::LinearDistance {
+ a: *pa,
+ b: *pb,
+ value_mm: value.get::(),
+ }],
+ _ => Vec::new(),
+ },
+ SketchDimension::Radius { target, value, .. } => {
+ curve_radius(sketch, target, points, radii)
+ .map(|curve| {
+ vec![Residual::RadiusCurve {
+ curve,
+ value_mm: value.get::(),
+ }]
+ })
+ .unwrap_or_default()
+ }
+ SketchDimension::Diameter { target, value, .. } => {
+ curve_radius(sketch, target, points, radii)
+ .map(|curve| {
+ vec![Residual::RadiusCurve {
+ curve,
+ value_mm: value.get::() * 0.5,
+ }]
+ })
+ .unwrap_or_default()
+ }
+ SketchDimension::Angular { a, b, value, .. } => {
+ combine_lines(sketch, a, b, points, move |la, lb| {
+ Residual::AngularBetweenLines {
+ a: la,
+ b: lb,
+ angle_rad: value.get::(),
+ }
+ })
+ }
+ }
+}
+
+fn kind(sketch: &Sketch, id: SketchEntityId) -> SketchEntityKind {
+ sketch
+ .entities()
+ .get(id)
+ .map_or(SketchEntityKind::Point, SketchEntity::kind)
+}
+
+fn is_curve(k: SketchEntityKind) -> bool {
+ matches!(k, SketchEntityKind::Circle | SketchEntityKind::Arc)
+}
+
+fn line_handle(
+ sketch: &Sketch,
+ id: SketchEntityId,
+ points: &BTreeMap,
+) -> Option {
+ let entity = sketch.entities().get(id)?;
+ let SketchEntity::Line(l) = entity else {
+ return None;
+ };
+ Some(LineHandle {
+ a: *points.get(&l.a())?,
+ b: *points.get(&l.b())?,
+ })
+}
+
+fn curve_radius(
+ sketch: &Sketch,
+ id: SketchEntityId,
+ points: &BTreeMap,
+ radii: &BTreeMap,
+) -> Option {
+ match sketch.entities().get(id)? {
+ SketchEntity::Circle(c) => {
+ let center = *points.get(&c.center())?;
+ let radius = *radii.get(&id)?;
+ Some(CurveRadius::Explicit { center, radius })
+ }
+ SketchEntity::Arc(a) => {
+ let center = *points.get(&a.center())?;
+ let spoke = *points.get(&a.start())?;
+ Some(CurveRadius::FromSpoke { center, spoke })
+ }
+ _ => None,
+ }
+}
+
+fn curve_center(sketch: &Sketch, id: SketchEntityId) -> Option {
+ match sketch.entities().get(id)? {
+ SketchEntity::Circle(c) => Some(c.center()),
+ SketchEntity::Arc(a) => Some(a.center()),
+ _ => None,
+ }
+}
+
+fn recompute_driven(sketch: Sketch) -> Sketch {
+ let driven_updates: Vec<(SketchDimensionId, DimensionValue)> = sketch
+ .dimension_order()
+ .iter()
+ .filter_map(|did| {
+ let dim = *sketch.dimensions().get(*did)?;
+ if dim.kind() != DimensionKind::Driven {
+ return None;
+ }
+ let value = measure_dimension(&sketch, dim)?;
+ Some((*did, value))
+ })
+ .collect();
+ sketch.with_driven_dimension_values(&driven_updates)
+}
+
+fn measure_dimension(sketch: &Sketch, dim: SketchDimension) -> Option {
+ match dim {
+ SketchDimension::Linear { a, b, .. } => {
+ let pa = point_position(sketch, a)?;
+ let pb = point_position(sketch, b)?;
+ let (ax, ay) = pa.coords_mm();
+ let (bx, by) = pb.coords_mm();
+ let dx = bx - ax;
+ let dy = by - ay;
+ Some(DimensionValue::Length(Length::new::(
+ (dx * dx + dy * dy).sqrt(),
+ )))
+ }
+ SketchDimension::Radius { target, .. } => measure_radius(sketch, target)
+ .map(|r| DimensionValue::Length(Length::new::(r))),
+ SketchDimension::Diameter { target, .. } => measure_radius(sketch, target)
+ .map(|r| DimensionValue::Length(Length::new::(2.0 * r))),
+ SketchDimension::Angular { a, b, .. } => {
+ measure_angle(sketch, a, b).map(|r| DimensionValue::Angle(Angle::new::(r)))
+ }
+ }
+}
+
+fn point_position(sketch: &Sketch, id: SketchEntityId) -> Option {
+ match sketch.entities().get(id)? {
+ SketchEntity::Point(p) => Some(p.at()),
+ _ => None,
+ }
+}
+
+fn measure_radius(sketch: &Sketch, id: SketchEntityId) -> Option {
+ match sketch.entities().get(id)? {
+ SketchEntity::Circle(c) => Some(c.radius().get::()),
+ SketchEntity::Arc(a) => {
+ let center = point_position(sketch, a.center())?;
+ let start = point_position(sketch, a.start())?;
+ let (cx, cy) = center.coords_mm();
+ let (sx, sy) = start.coords_mm();
+ let dx = sx - cx;
+ let dy = sy - cy;
+ Some((dx * dx + dy * dy).sqrt())
+ }
+ _ => None,
+ }
+}
+
+fn measure_angle(sketch: &Sketch, a: SketchEntityId, b: SketchEntityId) -> Option {
+ let la = line_endpoints(sketch, a)?;
+ let lb = line_endpoints(sketch, b)?;
+ let (d1x, d1y) = (la.1.0 - la.0.0, la.1.1 - la.0.1);
+ let (d2x, d2y) = (lb.1.0 - lb.0.0, lb.1.1 - lb.0.1);
+ let cross = d1x * d2y - d1y * d2x;
+ let dot = d1x * d2x + d1y * d2y;
+ Some(cross.atan2(dot))
+}
+
+fn line_endpoints(sketch: &Sketch, id: SketchEntityId) -> Option<((f64, f64), (f64, f64))> {
+ match sketch.entities().get(id)? {
+ SketchEntity::Line(l) => {
+ let a = point_position(sketch, l.a())?.coords_mm();
+ let b = point_position(sketch, l.b())?.coords_mm();
+ Some((a, b))
+ }
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeSet;
+
+ use super::*;
+ use crate::sketch::{EditOutcome, SketchEdit};
+ use bone_solver::{assemble_jacobian, evaluate_residuals, sparsity_pattern};
+ use bone_types::{Point2, Point3, SketchPlaneBasis, Tolerance, UnitVec3};
+ use nalgebra::DMatrix;
+
+ fn xy_plane() -> SketchPlaneBasis {
+ let Ok(basis) = SketchPlaneBasis::new(
+ Point3::origin(),
+ UnitVec3::x_axis(),
+ UnitVec3::y_axis(),
+ Tolerance::new(1e-9),
+ ) else {
+ panic!("xy axes are orthogonal");
+ };
+ basis
+ }
+
+ fn apply(sketch: &Sketch, edits: Vec) -> (Sketch, Vec) {
+ let Ok(pair) = sketch.apply_all(edits) else {
+ panic!("fixture edits apply cleanly");
+ };
+ pair
+ }
+
+ fn mm_val(v: f64) -> Length {
+ Length::new::(v)
+ }
+
+ fn entity_id(outcome: &EditOutcome) -> SketchEntityId {
+ let EditOutcome::Entity(id) = outcome else {
+ panic!("expected entity outcome");
+ };
+ *id
+ }
+
+ fn generous_budget() -> BudgetCeiling {
+ BudgetCeiling::new(core::time::Duration::from_secs(5))
+ }
+
+ fn rectangle_sketch() -> (Sketch, [SketchEntityId; 4]) {
+ let base = Sketch::new(xy_plane());
+ let (after, out) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.0, 0.5))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.5, 3.2))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.3, 2.9))),
+ ],
+ );
+ let ids = [0_usize, 1, 2, 3].map(|i| entity_id(&out[i]));
+ (after, ids)
+ }
+
+ fn constrained_rectangle() -> Sketch {
+ let (base, corners) = rectangle_sketch();
+ let [corner_0, corner_1, corner_2, corner_3] = corners;
+ let (with_edges, out) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::line(corner_0, corner_1, false)),
+ SketchEdit::AddEntity(SketchEntity::line(corner_1, corner_2, false)),
+ SketchEdit::AddEntity(SketchEntity::line(corner_2, corner_3, false)),
+ SketchEdit::AddEntity(SketchEntity::line(corner_3, corner_0, false)),
+ ],
+ );
+ let [edge_bottom, edge_right, edge_top, edge_left] =
+ [0_usize, 1, 2, 3].map(|i| entity_id(&out[i]));
+ apply(
+ &with_edges,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Horizontal(edge_bottom)),
+ SketchEdit::AddRelation(SketchRelation::Horizontal(edge_top)),
+ SketchEdit::AddRelation(SketchRelation::Vertical(edge_right)),
+ SketchEdit::AddRelation(SketchRelation::Vertical(edge_left)),
+ SketchEdit::AddRelation(SketchRelation::Fix(corner_0)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a: corner_0,
+ b: corner_1,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a: corner_1,
+ b: corner_2,
+ value: mm_val(5.0),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ )
+ .0
+ }
+
+ fn finite_difference_jacobian(
+ system: &ConstraintSystem,
+ params: &[f64],
+ eps: f64,
+ ) -> DMatrix {
+ let rows = system.row_count();
+ let cols = params.len();
+ (0..cols).fold(DMatrix::::zeros(rows, cols), |mut m, col| {
+ let mut plus = params.to_vec();
+ plus[col] += eps;
+ let mut minus = params.to_vec();
+ minus[col] -= eps;
+ let rp = evaluate_residuals(system, &plus);
+ let rn = evaluate_residuals(system, &minus);
+ (0..rows).for_each(|row| {
+ m[(row, col)] = (rp[row] - rn[row]) / (2.0 * eps);
+ });
+ m
+ })
+ }
+
+ fn point_coords(sketch: &Sketch) -> Vec<(f64, f64)> {
+ sketch
+ .entity_order()
+ .iter()
+ .filter_map(|id| match sketch.entities().get(*id)? {
+ SketchEntity::Point(p) => Some(p.at().coords_mm()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ #[test]
+ fn rectangle_solves_to_exact_dimensions() {
+ let Ok(solved) = constrained_rectangle().solve() else {
+ panic!("rectangle solves");
+ };
+ let coords = point_coords(&solved);
+ let expected = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)];
+ expected.iter().zip(coords.iter()).for_each(|(want, got)| {
+ assert!((want.0 - got.0).abs() < 1e-6, "x: {want:?} vs {got:?}");
+ assert!((want.1 - got.1).abs() < 1e-6, "y: {want:?} vs {got:?}");
+ });
+ }
+
+ #[test]
+ fn rectangle_analytic_jacobian_matches_finite_difference() {
+ let sketch = constrained_rectangle();
+ let (system, _) = sketch.lower();
+ let params: Vec = system.parameters().iter().map(|p| p.value()).collect();
+ let analytic = assemble_jacobian(&system, ¶ms);
+ let fd = finite_difference_jacobian(&system, ¶ms, 1e-6);
+ let diff = (&analytic - &fd).abs().max();
+ assert!(diff < 1e-4, "jacobian mismatch (max diff {diff})");
+ }
+
+ #[test]
+ fn rectangle_sparsity_pattern_is_stable() {
+ let (system, _) = constrained_rectangle().lower();
+ let pattern = sparsity_pattern(&system);
+ insta::assert_snapshot!("rectangle_sparsity", pattern.display());
+ }
+
+ #[test]
+ fn tangent_line_to_circle_solves() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, out) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(3.0, 3.5))),
+ ],
+ );
+ let [line_start, line_end, circle_center] = [0_usize, 1, 2].map(|i| entity_id(&out[i]));
+ let (with_shapes, out2) = apply(
+ &with_points,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::line(line_start, line_end, false)),
+ SketchEdit::AddEntity(SketchEntity::circle(circle_center, mm_val(2.0), false)),
+ ],
+ );
+ let [line_id, circle_id] = [0_usize, 1].map(|i| entity_id(&out2[i]));
+ let sketch = apply(
+ &with_shapes,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(line_start)),
+ SketchEdit::AddRelation(SketchRelation::Fix(line_end)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a: line_start,
+ b: circle_center,
+ value: mm_val(5.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Radius {
+ target: circle_id,
+ value: mm_val(2.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddRelation(SketchRelation::Tangent(line_id, circle_id)),
+ ],
+ )
+ .0;
+ let Ok(solved) = sketch.solve() else {
+ panic!("tangent line-circle solves");
+ };
+ let coords = point_coords(&solved);
+ let (start_x, start_y) = coords[0];
+ let (end_x, end_y) = coords[1];
+ let (center_x, center_y) = coords[2];
+ assert!(start_x.abs() < 1e-6 && start_y.abs() < 1e-6);
+ assert!((end_x - 10.0).abs() < 1e-6 && end_y.abs() < 1e-6);
+ assert!(
+ (center_y.abs() - 2.0).abs() < 1e-6,
+ "center-to-line {center_y}"
+ );
+ let radial = (center_x * center_x + center_y * center_y).sqrt();
+ assert!((radial - 5.0).abs() < 1e-6, "|c| {radial}");
+ }
+
+ #[test]
+ fn item_of_residual_tracks_rows_across_multi_row_residuals() {
+ let base = Sketch::new(xy_plane());
+ let (s, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.5, 0.5))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.5, 0.5))),
+ ],
+ );
+ let [p0, p1, p2, p3] = [0_usize, 1, 2, 3].map(|i| entity_id(&outs[i]));
+ let (sketch, rel_outs) = apply(
+ &s,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Coincident(p0, p1)),
+ SketchEdit::AddRelation(SketchRelation::Fix(p2)),
+ SketchEdit::AddRelation(SketchRelation::Coincident(p2, p3)),
+ ],
+ );
+ let relation_ids: Vec = rel_outs
+ .iter()
+ .map(|o| match *o {
+ EditOutcome::Relation(id) => id,
+ _ => panic!("expected relation outcome"),
+ })
+ .collect();
+ let (system, mapping) = sketch.lower();
+ assert_eq!(system.row_count(), 6);
+ let expected: [SketchItemId; 6] = [
+ SketchItemId::Relation(relation_ids[0]),
+ SketchItemId::Relation(relation_ids[0]),
+ SketchItemId::Relation(relation_ids[1]),
+ SketchItemId::Relation(relation_ids[1]),
+ SketchItemId::Relation(relation_ids[2]),
+ SketchItemId::Relation(relation_ids[2]),
+ ];
+ expected.iter().enumerate().for_each(|(i, want)| {
+ let Ok(row) = u32::try_from(i) else {
+ unreachable!("fixture row count fits in u32")
+ };
+ let got = mapping.item_of_residual(ResidualIndex::new(row));
+ assert_eq!(got, Some(*want), "row {i}");
+ });
+ }
+
+ #[test]
+ fn under_constrained_horizontal_line_solves() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 3.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (with_line, out_line) = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
+ );
+ let line = entity_id(&out_line[0]);
+ let sketch = apply(
+ &with_line,
+ vec![SketchEdit::AddRelation(SketchRelation::Horizontal(line))],
+ )
+ .0;
+ let Ok(solved) = sketch.solve() else {
+ panic!("under-constrained Horizontal should solve, not fail")
+ };
+ let coords = point_coords(&solved);
+ assert!(
+ (coords[0].1 - coords[1].1).abs() < 1e-6,
+ "y-values should match after Horizontal: {coords:?}"
+ );
+ }
+
+ #[test]
+ fn driven_dimension_is_recomputed_post_solve() {
+ let (base, corners) = rectangle_sketch();
+ let [corner_0, corner_1, _corner_2, _corner_3] = corners;
+ let sketch = apply(
+ &base,
+ vec![SketchEdit::AddDimension(SketchDimension::Linear {
+ a: corner_0,
+ b: corner_1,
+ value: mm_val(0.0),
+ kind: DimensionKind::Driven,
+ })],
+ )
+ .0;
+ let Ok(solved) = sketch.solve() else {
+ panic!("solve with driven dim");
+ };
+ let driven_id = solved.dimension_order()[0];
+ let SketchDimension::Linear { value, .. } = solved.dimensions()[driven_id] else {
+ panic!("driven should remain Linear");
+ };
+ let expected = (7.0_f64.powi(2) + 0.5_f64.powi(2)).sqrt();
+ assert!((value.get::() - expected).abs() < 1e-9);
+ }
+
+ #[test]
+ fn arc_end_stays_on_implicit_circle_after_solve() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(2.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.3, 2.5))),
+ ],
+ );
+ let [center, start, end] = [0_usize, 1, 2].map(|i| entity_id(&outs[i]));
+ let (with_arc, out_arc) = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::arc(
+ center, start, end, false,
+ ))],
+ );
+ let _arc = entity_id(&out_arc[0]);
+ let sketch = apply(
+ &with_arc,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(center)),
+ SketchEdit::AddRelation(SketchRelation::Fix(start)),
+ ],
+ )
+ .0;
+ let Ok(solved) = sketch.solve() else {
+ panic!("arc coherence solve");
+ };
+ let coords = point_coords(&solved);
+ let (cx, cy) = coords[0];
+ let (sx, sy) = coords[1];
+ let (ex, ey) = coords[2];
+ let r_start = ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt();
+ let r_end = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt();
+ assert!(
+ (r_start - r_end).abs() < 1e-6,
+ "arc end must lie on implicit circle: r_start={r_start} r_end={r_end}"
+ );
+ }
+
+ fn horizontal_line_fixture(p0: (f64, f64), p1: (f64, f64), length_mm: f64) -> Sketch {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(p0.0, p0.1))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(p1.0, p1.1))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (with_line, line_outs) = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
+ );
+ let line = entity_id(&line_outs[0]);
+ apply(
+ &with_line,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddRelation(SketchRelation::Horizontal(line)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(length_mm),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ )
+ .0
+ }
+
+ fn disjoint_pair_fixture() -> Sketch {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.0, 0.3))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(100.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(107.0, 0.3))),
+ ],
+ );
+ let [a0, a1, b0, b1] = [0_usize, 1, 2, 3].map(|i| entity_id(&outs[i]));
+ let (with_lines, line_outs) = apply(
+ &with_points,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::line(a0, a1, false)),
+ SketchEdit::AddEntity(SketchEntity::line(b0, b1, false)),
+ ],
+ );
+ let [line_a, line_b] = [0_usize, 1].map(|i| entity_id(&line_outs[i]));
+ apply(
+ &with_lines,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a0)),
+ SketchEdit::AddRelation(SketchRelation::Horizontal(line_a)),
+ SketchEdit::AddRelation(SketchRelation::Fix(b0)),
+ SketchEdit::AddRelation(SketchRelation::Horizontal(line_b)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a: a0,
+ b: a1,
+ value: mm_val(5.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a: b0,
+ b: b1,
+ value: mm_val(9.0),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ )
+ .0
+ }
+
+ #[test]
+ fn disjoint_sketches_solve_per_component() {
+ let Ok(solved_a) = horizontal_line_fixture((0.0, 0.0), (7.0, 0.3), 5.0).solve() else {
+ panic!("component A solves alone");
+ };
+ let Ok(solved_b) = horizontal_line_fixture((100.0, 0.0), (107.0, 0.3), 9.0).solve() else {
+ panic!("component B solves alone");
+ };
+ let Ok(joint_solved) = disjoint_pair_fixture().solve() else {
+ panic!("joint disjoint-component solve");
+ };
+ let joint_coords = point_coords(&joint_solved);
+ let expected_joint: Vec<(f64, f64)> = point_coords(&solved_a)
+ .into_iter()
+ .chain(point_coords(&solved_b))
+ .collect();
+ expected_joint
+ .iter()
+ .zip(joint_coords.iter())
+ .for_each(|(want, got)| {
+ assert!(
+ (want.0 - got.0).abs() < 1e-9,
+ "x alone-vs-joint {want:?} vs {got:?}"
+ );
+ assert!(
+ (want.1 - got.1).abs() < 1e-9,
+ "y alone-vs-joint {want:?} vs {got:?}"
+ );
+ });
+ let expected_absolute = [(0.0, 0.0), (5.0, 0.0), (100.0, 0.0), (109.0, 0.0)];
+ expected_absolute
+ .iter()
+ .zip(joint_coords.iter())
+ .for_each(|(want, got)| {
+ assert!((want.0 - got.0).abs() < 1e-6, "x {want:?} vs {got:?}");
+ assert!((want.1 - got.1).abs() < 1e-6, "y {want:?} vs {got:?}");
+ });
+ }
+
+ #[test]
+ fn conflicting_dimensions_report_over_defined() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (sketch, dim_outs) = apply(
+ &with_points,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(3.0),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ );
+ let dim_ids: Vec = dim_outs
+ .iter()
+ .filter_map(|o| match *o {
+ EditOutcome::Dimension(id) => Some(id),
+ _ => None,
+ })
+ .collect();
+ let Err(err) = sketch.solve() else {
+ panic!("conflicting dimensions must surface OverDefined");
+ };
+ let SolverError::OverDefined { conflicts } = err else {
+ panic!("expected OverDefined, got {err:?}");
+ };
+ assert!(!conflicts.is_empty(), "conflicts populated");
+ let has_dim = conflicts
+ .iter()
+ .any(|c| matches!(*c, SketchItemId::Dimension(id) if dim_ids.contains(&id)));
+ assert!(has_dim, "at least one of the conflicting dimensions names");
+ }
+
+ #[test]
+ fn redundant_dimension_does_not_block_solve() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let sketch = apply(
+ &with_points,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ )
+ .0;
+ let report = sketch.analyze_dof();
+ assert_eq!(report.dof().value(), 1);
+ assert!(report.over_constrained().is_empty());
+ assert!(!report.redundant_consistent().is_empty());
+ let Ok(_) = sketch.solve() else {
+ panic!("redundant-consistent system must solve");
+ };
+ }
+
+ #[test]
+ fn redundant_dimension_at_unsatisfied_seed_still_solves() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let sketch = apply(
+ &with_points,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ )
+ .0;
+ let report = sketch.analyze_dof();
+ assert_eq!(report.dof().value(), 1);
+ assert!(
+ report.over_constrained().is_empty(),
+ "redundant-consistent dims at unsatisfied seed must not be flagged as conflicts"
+ );
+ assert!(!report.redundant_consistent().is_empty());
+ let Ok(solved) = sketch.solve() else {
+ panic!("redundant-consistent dims at unsatisfied seed must solve");
+ };
+ let coords = point_coords(&solved);
+ let (bx, by) = coords[1];
+ let radial = (bx * bx + by * by).sqrt();
+ assert!(
+ (radial - 10.0).abs() < 1e-6,
+ "free endpoint should move to |b|=10mm, got {radial}"
+ );
+ }
+
+ #[test]
+ fn analyze_dof_reports_fully_constrained_rectangle() {
+ let sketch = constrained_rectangle();
+ let report = sketch.analyze_dof();
+ assert_eq!(report.dof().value(), 0);
+ assert!(report.over_constrained().is_empty());
+ assert!(report.redundant_consistent().is_empty());
+ assert!(report.under_constrained().is_empty());
+ }
+
+ #[test]
+ fn analyze_dof_reports_free_line() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 3.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (with_line, line_outs) = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
+ );
+ let line = entity_id(&line_outs[0]);
+ let sketch = apply(
+ &with_line,
+ vec![SketchEdit::AddRelation(SketchRelation::Horizontal(line))],
+ )
+ .0;
+ let report = sketch.analyze_dof();
+ assert_eq!(report.dof().value(), 3);
+ assert!(report.over_constrained().is_empty());
+ assert!(!report.under_constrained().is_empty());
+ }
+
+ fn three_conflicting_linear_dims_fixture() -> (Sketch, Vec) {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (sketch, dim_outs) = apply(
+ &with_points,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(7.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ );
+ let dim_ids: Vec = dim_outs
+ .iter()
+ .filter_map(|o| match *o {
+ EditOutcome::Dimension(id) => Some(id),
+ _ => None,
+ })
+ .collect();
+ (sketch, dim_ids)
+ }
+
+ #[test]
+ fn mus_narrows_three_conflicting_dims_to_odd_one_out() {
+ let (sketch, dim_ids) = three_conflicting_linear_dims_fixture();
+ let Err(err) = sketch.solve() else {
+ panic!("three conflicting dims must surface OverDefined");
+ };
+ let SolverError::OverDefined { conflicts } = err else {
+ panic!("expected OverDefined, got {err:?}");
+ };
+ let added: BTreeSet = dim_ids
+ .iter()
+ .copied()
+ .map(SketchItemId::Dimension)
+ .collect();
+ assert!(
+ !conflicts.is_empty() && conflicts.len() < dim_ids.len(),
+ "MUS must narrow {{10, 7, 10}} strictly below the full dim set: \
+ conflicts={conflicts:?} dim_ids={dim_ids:?}",
+ );
+ if let Some(stray) = conflicts.iter().find(|c| !added.contains(c)) {
+ panic!("unexpected conflict: {stray:?} not in {dim_ids:?}");
+ }
+ assert!(
+ conflicts.contains(&SketchItemId::Dimension(dim_ids[1])),
+ "MUS must flag the 7mm outlier (dim_ids[1]): \
+ conflicts={conflicts:?} dim_ids={dim_ids:?}",
+ );
+ }
+
+ #[test]
+ fn mus_on_three_distinct_conflicting_dims_returns_minimal_conflict() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (sketch, dim_outs) = apply(
+ &with_points,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(10.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(7.0),
+ kind: DimensionKind::Driving,
+ }),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(4.0),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ );
+ let dim_ids: Vec = dim_outs
+ .iter()
+ .filter_map(|o| match *o {
+ EditOutcome::Dimension(id) => Some(id),
+ _ => None,
+ })
+ .collect();
+ let Err(SolverError::OverDefined { conflicts }) = sketch.solve() else {
+ panic!("three distinct-value conflicting dims must surface OverDefined");
+ };
+ assert!(!conflicts.is_empty(), "MUS must be non-empty");
+ assert!(
+ conflicts.len() <= dim_ids.len(),
+ "MUS must not exceed the added-dim count: conflicts={conflicts:?}",
+ );
+ if let Some(bad) = conflicts
+ .iter()
+ .find(|c| !matches!(c, SketchItemId::Dimension(id) if dim_ids.contains(id)))
+ {
+ panic!("unexpected conflict: {bad:?} not in {dim_ids:?}");
+ }
+ assert_eq!(
+ conflicts.iter().collect::>().len(),
+ conflicts.len(),
+ "conflicts must be deduped: {conflicts:?}",
+ );
+ }
+
+ #[test]
+ fn narrow_over_defined_dedups_multi_row_parent() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 1.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (sketch, rel_outs) = apply(
+ &with_points,
+ vec![SketchEdit::AddRelation(SketchRelation::Coincident(a, b))],
+ );
+ let EditOutcome::Relation(coincident_id) = rel_outs[0] else {
+ panic!("expected relation outcome");
+ };
+ let (system, mapping) = sketch.lower();
+ let rows: Vec = (0..system.row_count())
+ .map(|i| {
+ let Ok(iv) = u32::try_from(i) else {
+ unreachable!("fixture row count fits in u32")
+ };
+ ResidualIndex::new(iv)
+ })
+ .collect();
+ assert_eq!(rows.len(), 2, "Coincident is a two-row residual");
+ let err = narrow_over_defined(&system, &mapping, &rows);
+ let SolverError::OverDefined { conflicts } = err else {
+ panic!("narrow_over_defined must surface OverDefined");
+ };
+ assert_eq!(
+ conflicts,
+ vec![SketchItemId::Relation(coincident_id)],
+ "two rows of one Coincident must collapse to a single conflict: {conflicts:?}",
+ );
+ }
+
+ fn draggable_horizontal_line() -> (Sketch, SketchEntityId, SketchEntityId) {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (with_line, line_outs) = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
+ );
+ let line = entity_id(&line_outs[0]);
+ let sketch = apply(
+ &with_line,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddRelation(SketchRelation::Horizontal(line)),
+ ],
+ )
+ .0;
+ (sketch, a, b)
+ }
+
+ #[test]
+ fn solve_with_drag_along_horizontal_lands_on_target() {
+ let (sketch, a, b) = draggable_horizontal_line();
+ let target = Point2::from_mm(8.0, 0.0);
+ let Ok(dragged) = sketch.solve_with_drag(b, target, generous_budget()) else {
+ panic!("feasible drag along Horizontal must converge");
+ };
+ let coords = point_coords(&dragged);
+ let (bx, by) = coords[1];
+ assert!(
+ (bx - 8.0).abs() < 1e-6,
+ "dragged x must land at target.x: {bx}",
+ );
+ assert!(
+ by.abs() < 1e-6,
+ "Horizontal holds: a.y={} b.y={by}",
+ coords[0].1,
+ );
+ let (ax, ay) = coords[0];
+ assert!(
+ ax.abs() < 1e-9 && ay.abs() < 1e-9,
+ "Fix on a holds: ({ax}, {ay})",
+ );
+ let _ = a;
+ }
+
+ #[test]
+ fn solve_with_drag_rejects_non_point() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 0.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (sketch, line_outs) = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
+ );
+ let line = entity_id(&line_outs[0]);
+ let result = sketch.solve_with_drag(line, Point2::from_mm(0.0, 0.0), generous_budget());
+ let Err(err) = result else {
+ panic!("dragging a Line must reject, got {result:?}");
+ };
+ assert!(matches!(err, DragError::NotAPoint(id) if id == line));
+ }
+
+ #[test]
+ fn solve_with_drag_rejects_unknown_entity_id() {
+ let sketch = constrained_rectangle();
+ let stranger = SketchEntityId::default();
+ let result = sketch.solve_with_drag(stranger, Point2::from_mm(0.0, 0.0), generous_budget());
+ let Err(err) = result else {
+ panic!("unknown entity id must reject, got {result:?}");
+ };
+ assert!(matches!(err, DragError::NotFound(id) if id == stranger));
+ }
+
+ #[test]
+ fn solve_with_drag_returns_budget_when_ceiling_is_zero() {
+ let sketch = constrained_rectangle();
+ let target = Point2::from_mm(20.0, 20.0);
+ let drag_id = sketch.entity_order()[1];
+ let result = sketch.solve_with_drag(
+ drag_id,
+ target,
+ BudgetCeiling::new(core::time::Duration::ZERO),
+ );
+ match result {
+ Err(DragError::Solver(SolverError::Budget { .. })) => {}
+ other => panic!("expected Budget, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn solve_with_drag_on_fixed_point_reanchors_to_target() {
+ let base = Sketch::new(xy_plane());
+ let (with_point, outs) = apply(
+ &base,
+ vec![SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(
+ 0.0, 0.0,
+ )))],
+ );
+ let p = entity_id(&outs[0]);
+ let sketch = apply(
+ &with_point,
+ vec![SketchEdit::AddRelation(SketchRelation::Fix(p))],
+ )
+ .0;
+ let target = Point2::from_mm(8.0, 5.0);
+ let Ok(dragged) = sketch.solve_with_drag(p, target, generous_budget()) else {
+ panic!("Fix'd drag re-anchors via warm-start and must converge");
+ };
+ let (x, y) = point_coords(&dragged)[0];
+ assert!(
+ (x - 8.0).abs() < 1e-9,
+ "warm-start re-anchors Fix's x pin to target: {x}",
+ );
+ assert!(
+ (y - 5.0).abs() < 1e-9,
+ "warm-start re-anchors Fix's y pin to target: {y}",
+ );
+ }
+
+ #[test]
+ fn solve_is_bit_identical_across_repeated_invocations() {
+ let sketch = constrained_rectangle();
+ let Ok(first) = sketch.solve() else {
+ panic!("rectangle solves once");
+ };
+ let Ok(second) = sketch.solve() else {
+ panic!("rectangle solves twice");
+ };
+ let lhs = point_coords(&first);
+ let rhs = point_coords(&second);
+ lhs.iter().zip(rhs.iter()).for_each(|(a, b)| {
+ assert_eq!(
+ a.0.to_bits(),
+ b.0.to_bits(),
+ "x must match bit-for-bit: {a:?} vs {b:?}",
+ );
+ assert_eq!(
+ a.1.to_bits(),
+ b.1.to_bits(),
+ "y must match bit-for-bit: {a:?} vs {b:?}",
+ );
+ });
+ }
+
+ #[test]
+ fn over_defined_conflicts_are_identical_across_repeated_invocations() {
+ let (sketch, _) = three_conflicting_linear_dims_fixture();
+ let Err(SolverError::OverDefined { conflicts: first }) = sketch.solve() else {
+ panic!("conflicting fixture must surface OverDefined on first solve");
+ };
+ let Err(SolverError::OverDefined { conflicts: second }) = sketch.solve() else {
+ panic!("conflicting fixture must surface OverDefined on second solve");
+ };
+ assert_eq!(
+ first, second,
+ "MUS conflict ordering must be identical across runs",
+ );
+ }
+
+ #[test]
+ fn drag_is_bit_identical_across_repeated_invocations() {
+ let (sketch, _a, b) = draggable_horizontal_line();
+ let target = Point2::from_mm(13.5, 0.0);
+ let Ok(first) = sketch.solve_with_drag(b, target, generous_budget()) else {
+ panic!("first drag solves");
+ };
+ let Ok(second) = sketch.solve_with_drag(b, target, generous_budget()) else {
+ panic!("second drag solves");
+ };
+ let lhs = point_coords(&first);
+ let rhs = point_coords(&second);
+ lhs.iter().zip(rhs.iter()).for_each(|(a, b)| {
+ assert_eq!(a.0.to_bits(), b.0.to_bits());
+ assert_eq!(a.1.to_bits(), b.1.to_bits());
+ });
+ }
+
+ #[cfg(not(debug_assertions))]
+ #[test]
+ fn drag_resolve_completes_inside_frame_budget() {
+ use std::time::Instant;
+ let (sketch, _a, b) = draggable_horizontal_line();
+ (0..16).for_each(|i| {
+ let f = f64::from(i);
+ let target = Point2::from_mm(8.0 + 0.1 * f, 0.0);
+ let started = Instant::now();
+ let Ok(_) = sketch.solve_with_drag(b, target, BudgetCeiling::FRAME_16MS) else {
+ panic!("drag solve must converge inside budget at step {i}");
+ };
+ let elapsed = started.elapsed();
+ assert!(
+ elapsed <= core::time::Duration::from_millis(16),
+ "drag step {i} took {elapsed:?}, exceeding 16 ms budget",
+ );
+ });
+ }
+
+ mod determinism {
+ use super::*;
+ use proptest::prelude::*;
+
+ #[derive(Copy, Clone, Debug)]
+ struct DragSeed {
+ base_x: i32,
+ base_y: i32,
+ target_dx: i32,
+ target_dy: i32,
+ length: u32,
+ }
+
+ fn arb_drag_seed() -> impl Strategy {
+ (
+ -10_000_i32..10_000,
+ -10_000_i32..10_000,
+ -20_000_i32..20_000,
+ -20_000_i32..20_000,
+ 500_u32..50_000,
+ )
+ .prop_map(|(base_x, base_y, target_dx, target_dy, length)| DragSeed {
+ base_x,
+ base_y,
+ target_dx,
+ target_dy,
+ length,
+ })
+ }
+
+ fn build_horizontal_line(seed: DragSeed) -> (Sketch, SketchEntityId) {
+ let bx = f64::from(seed.base_x) * 1e-3;
+ let by = f64::from(seed.base_y) * 1e-3;
+ let length_mm_value = f64::from(seed.length) * 1e-3;
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(bx, by))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(bx + 1.0, by + 1.0))),
+ ],
+ );
+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
+ let (with_line, line_outs) = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
+ );
+ let line = entity_id(&line_outs[0]);
+ let sketch = apply(
+ &with_line,
+ vec![
+ SketchEdit::AddRelation(SketchRelation::Fix(a)),
+ SketchEdit::AddRelation(SketchRelation::Horizontal(line)),
+ SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm_val(length_mm_value),
+ kind: DimensionKind::Driving,
+ }),
+ ],
+ )
+ .0;
+ (sketch, b)
+ }
+
+ fn assert_coord_pairs_bit_identical(
+ lhs: &[(f64, f64)],
+ rhs: &[(f64, f64)],
+ ) -> Result<(), TestCaseError> {
+ lhs.iter().zip(rhs.iter()).try_for_each(|(a, b)| {
+ prop_assert_eq!(a.0.to_bits(), b.0.to_bits());
+ prop_assert_eq!(a.1.to_bits(), b.1.to_bits());
+ Ok(())
+ })
+ }
+
+ proptest! {
+ #[test]
+ fn solve_is_bit_identical_for_random_horizontal_lines(seed in arb_drag_seed()) {
+ let (sketch, _b) = build_horizontal_line(seed);
+ let Ok(first) = sketch.solve() else {
+ return Ok(());
+ };
+ let Ok(second) = sketch.solve() else {
+ unreachable!("first solve succeeded; second cannot diverge under determinism");
+ };
+ assert_coord_pairs_bit_identical(&point_coords(&first), &point_coords(&second))?;
+ }
+
+ #[test]
+ fn drag_is_bit_identical_for_random_horizontal_lines(seed in arb_drag_seed()) {
+ let (sketch, drag_id) = build_horizontal_line(seed);
+ let tx = f64::from(seed.base_x + seed.target_dx) * 1e-3;
+ let ty = f64::from(seed.base_y + seed.target_dy) * 1e-3;
+ let target = Point2::from_mm(tx, ty);
+ let Ok(first) = sketch.solve_with_drag(drag_id, target, generous_budget()) else {
+ return Ok(());
+ };
+ let Ok(second) = sketch.solve_with_drag(drag_id, target, generous_budget()) else {
+ unreachable!("first drag succeeded; second must too under determinism");
+ };
+ assert_coord_pairs_bit_identical(&point_coords(&first), &point_coords(&second))?;
+ }
+ }
+ }
+
+ #[test]
+ fn arc_intrinsic_residual_tags_entity_origin() {
+ let base = Sketch::new(xy_plane());
+ let (with_points, outs) = apply(
+ &base,
+ vec![
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(2.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 2.0))),
+ ],
+ );
+ let [c, s, e] = [0_usize, 1, 2].map(|i| entity_id(&outs[i]));
+ let sketch = apply(
+ &with_points,
+ vec![SketchEdit::AddEntity(SketchEntity::arc(c, s, e, false))],
+ )
+ .0;
+ let arc_id = sketch.entity_order()[3];
+ let (system, mapping) = sketch.lower();
+ assert_eq!(system.row_count(), 1, "one intrinsic row per arc");
+ let got = mapping.item_of_residual(ResidualIndex::new(0));
+ assert_eq!(got, Some(SketchItemId::Entity(arc_id)));
+ }
+}
diff --git a/crates/bone-document/src/undo.rs b/crates/bone-document/src/undo.rs
new file mode 100644
index 0000000..6193fae
--- /dev/null
+++ b/crates/bone-document/src/undo.rs
@@ -0,0 +1,74 @@
+use std::collections::VecDeque;
+use std::mem;
+use std::num::NonZeroUsize;
+
+use crate::Document;
+
+#[derive(Clone, Debug)]
+pub struct UndoStack {
+ past: VecDeque,
+ future: VecDeque,
+ capacity: NonZeroUsize,
+}
+
+impl UndoStack {
+ #[must_use]
+ pub fn with_capacity(capacity: NonZeroUsize) -> Self {
+ Self {
+ past: VecDeque::new(),
+ future: VecDeque::new(),
+ capacity,
+ }
+ }
+
+ pub fn record(&mut self, previous: Document) {
+ self.future.clear();
+ if self.past.len() == self.capacity.get() {
+ self.past.pop_front();
+ }
+ self.past.push_back(previous);
+ }
+
+ pub fn undo(&mut self, current: &mut Document) -> bool {
+ let Some(previous) = self.past.pop_back() else {
+ return false;
+ };
+ let old = mem::replace(current, previous);
+ self.future.push_back(old);
+ true
+ }
+
+ pub fn redo(&mut self, current: &mut Document) -> bool {
+ let Some(next) = self.future.pop_back() else {
+ return false;
+ };
+ let old = mem::replace(current, next);
+ self.past.push_back(old);
+ true
+ }
+
+ #[must_use]
+ pub fn can_undo(&self) -> bool {
+ !self.past.is_empty()
+ }
+
+ #[must_use]
+ pub fn can_redo(&self) -> bool {
+ !self.future.is_empty()
+ }
+
+ #[must_use]
+ pub fn capacity(&self) -> NonZeroUsize {
+ self.capacity
+ }
+
+ #[must_use]
+ pub fn past_len(&self) -> usize {
+ self.past.len()
+ }
+
+ #[must_use]
+ pub fn future_len(&self) -> usize {
+ self.future.len()
+ }
+}
diff --git a/crates/bone-document/tests/evaluator.rs b/crates/bone-document/tests/evaluator.rs
new file mode 100644
index 0000000..d61c724
--- /dev/null
+++ b/crates/bone-document/tests/evaluator.rs
@@ -0,0 +1,254 @@
+use bone_document::{
+ DimensionKind, Document, EditOutcome, EvaluatedSketch, FeatureCache, FeatureNode, Sketch,
+ SketchDimension, SketchEdit, SketchEntity, SketchRelation, evaluate_sketch,
+};
+use bone_types::{
+ DocumentId, FeatureId, Length, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3,
+ millimeter,
+};
+use slotmap::KeyData;
+
+fn plane() -> SketchPlaneBasis {
+ let Ok(basis) = SketchPlaneBasis::new(
+ Point3::origin(),
+ UnitVec3::x_axis(),
+ UnitVec3::y_axis(),
+ Tolerance::new(1e-9),
+ ) else {
+ panic!("xy plane");
+ };
+ basis
+}
+
+fn mm(v: f64) -> Length {
+ Length::new::(v)
+}
+
+fn sketch_id(idx: u32) -> SketchId {
+ SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+fn document_id(idx: u32) -> DocumentId {
+ DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+fn feature_id(idx: u32) -> FeatureId {
+ FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+fn horizontal_line_sketch(dim_mm: f64) -> Sketch {
+ let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ let Ok((s, EditOutcome::Entity(line))) =
+ s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
+ else {
+ panic!("line");
+ };
+ let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Fix(a))) else {
+ panic!("fix");
+ };
+ let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line))) else {
+ panic!("horizontal");
+ };
+ let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm(dim_mm),
+ kind: DimensionKind::Driving,
+ })) else {
+ panic!("dim");
+ };
+ s
+}
+
+fn conflicting_sketch() -> Sketch {
+ let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
+ SketchEntity::point(Point2::from_mm(0.0, 0.0)),
+ )) else {
+ panic!("a");
+ };
+ let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
+ Point2::from_mm(1.0, 0.0),
+ ))) else {
+ panic!("b");
+ };
+ let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Fix(a))) else {
+ panic!("fix");
+ };
+ let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm(5.0),
+ kind: DimensionKind::Driving,
+ })) else {
+ panic!("dim1");
+ };
+ let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm(10.0),
+ kind: DimensionKind::Driving,
+ })) else {
+ panic!("dim2");
+ };
+ s
+}
+
+#[test]
+fn evaluate_sketch_returns_solved_variant_on_success() {
+ let sketch = horizontal_line_sketch(10.0);
+ let output = evaluate_sketch(&sketch);
+ let EvaluatedSketch::Solved(solved) = output else {
+ panic!("expected Solved, got {output:?}");
+ };
+ let order = solved.entity_order();
+ let b = order[1];
+ let SketchEntity::Point(p) = solved.entities()[b] else {
+ panic!("b is point");
+ };
+ let (x, y) = p.at().coords_mm();
+ assert!((x - 10.0).abs() < 1e-9, "x = {x}");
+ assert!(y.abs() < 1e-9, "y = {y}");
+}
+
+#[test]
+fn evaluate_sketch_returns_failed_variant_on_conflict() {
+ let sketch = conflicting_sketch();
+ let output = evaluate_sketch(&sketch);
+ assert!(
+ matches!(output, EvaluatedSketch::Failed(_)),
+ "expected Failed, got {output:?}"
+ );
+}
+
+#[test]
+fn feature_cache_reuses_solution_for_identical_input() {
+ let sketch = horizontal_line_sketch(10.0);
+ let mut cache = FeatureCache::new();
+ let fid = feature_id(7);
+ let first = cache.evaluate(fid, &sketch).clone();
+ let second = cache.evaluate(fid, &sketch).clone();
+ assert_eq!(first, second);
+ assert_eq!(cache.len(), 1);
+ assert!(cache.lookup(fid).is_some());
+}
+
+#[test]
+fn feature_cache_refreshes_on_input_change() {
+ let mut cache = FeatureCache::new();
+ let fid = feature_id(7);
+ let first = cache.evaluate(fid, &horizontal_line_sketch(10.0)).clone();
+ let second = cache.evaluate(fid, &horizontal_line_sketch(20.0)).clone();
+ let EvaluatedSketch::Solved(first_solved) = first else {
+ panic!("first Solved");
+ };
+ let EvaluatedSketch::Solved(second_solved) = second else {
+ panic!("second Solved");
+ };
+ assert_ne!(first_solved, second_solved);
+ assert_eq!(cache.len(), 1);
+}
+
+#[test]
+fn feature_cache_keeps_entries_per_feature() {
+ let mut cache = FeatureCache::new();
+ let a = feature_id(1);
+ let b = feature_id(2);
+ cache.evaluate(a, &horizontal_line_sketch(10.0));
+ cache.evaluate(b, &horizontal_line_sketch(20.0));
+ assert_eq!(cache.len(), 2);
+ assert!(cache.lookup(a).is_some());
+ assert!(cache.lookup(b).is_some());
+}
+
+#[test]
+fn feature_cache_invalidate_drops_entry() {
+ let mut cache = FeatureCache::new();
+ let fid = feature_id(7);
+ cache.evaluate(fid, &horizontal_line_sketch(10.0));
+ assert!(cache.invalidate(fid));
+ assert!(cache.lookup(fid).is_none());
+ assert!(!cache.invalidate(fid));
+}
+
+#[test]
+fn feature_cache_retain_drops_absent_features() {
+ let mut cache = FeatureCache::new();
+ let keep = feature_id(1);
+ let drop = feature_id(2);
+ cache.evaluate(keep, &horizontal_line_sketch(10.0));
+ cache.evaluate(drop, &horizontal_line_sketch(20.0));
+ cache.retain([keep]);
+ assert!(cache.lookup(keep).is_some());
+ assert!(cache.lookup(drop).is_none());
+ assert_eq!(cache.len(), 1);
+}
+
+#[test]
+fn document_resolves_feature_id_back_to_sketch() {
+ let mut doc = Document::new(document_id(1), "d".to_owned());
+ let sid = sketch_id(7);
+ doc.insert_sketch(sid, "S".to_owned(), horizontal_line_sketch(10.0));
+ let feature = doc.feature_tree().iter().find_map(|(fid, node)| {
+ matches!(node, FeatureNode::Sketch(id) if id == sid).then_some(fid)
+ });
+ let Some(fid) = feature else {
+ panic!("sketch feature present");
+ };
+ let Some(sketch) = doc.sketch_of_feature(fid) else {
+ panic!("sketch addressable through feature id");
+ };
+ let Some(direct) = doc.sketch(sid) else {
+ panic!("sketch in map");
+ };
+ assert_eq!(sketch, direct);
+}
+
+#[test]
+fn feature_cache_refreshes_when_feature_id_is_reused() {
+ let mut doc = Document::new(document_id(1), "d".to_owned());
+ let sid_a = sketch_id(10);
+ let sid_b = sketch_id(11);
+ doc.insert_sketch(sid_a, "A".to_owned(), horizontal_line_sketch(10.0));
+ let Some(fid_a) = doc.feature_tree().iter().find_map(|(fid, node)| {
+ matches!(node, FeatureNode::Sketch(id) if id == sid_a).then_some(fid)
+ }) else {
+ panic!("A feature present");
+ };
+
+ let mut cache = FeatureCache::new();
+ let Some(sketch_a) = doc.sketch(sid_a) else {
+ panic!("A in map");
+ };
+ let EvaluatedSketch::Solved(solved_a) = cache.evaluate(fid_a, sketch_a).clone() else {
+ panic!("A solves");
+ };
+
+ doc.remove_sketch(sid_a);
+ doc.insert_sketch(sid_b, "B".to_owned(), horizontal_line_sketch(20.0));
+ let Some(fid_b) = doc.feature_tree().iter().find_map(|(fid, node)| {
+ matches!(node, FeatureNode::Sketch(id) if id == sid_b).then_some(fid)
+ }) else {
+ panic!("B feature present");
+ };
+ assert_eq!(
+ fid_a, fid_b,
+ "precondition: FeatureTree reuses the id of a removed feature"
+ );
+
+ let Some(sketch_b) = doc.sketch(sid_b) else {
+ panic!("B in map");
+ };
+ let EvaluatedSketch::Solved(solved_b) = cache.evaluate(fid_b, sketch_b).clone() else {
+ panic!("B solves");
+ };
+ assert_ne!(solved_a, solved_b, "cache must refresh on reused FeatureId");
+}
diff --git a/crates/bone-document/tests/folder_jj.rs b/crates/bone-document/tests/folder_jj.rs
new file mode 100644
index 0000000..cd4db41
--- /dev/null
+++ b/crates/bone-document/tests/folder_jj.rs
@@ -0,0 +1,178 @@
+use std::process::Command;
+
+use bone_document::{
+ DimensionKind, Document, DocumentFolder, EditOutcome, Sketch, SketchDimension, SketchEdit,
+ SketchEntity, load, save,
+};
+use bone_types::{
+ DocumentId, Length, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter,
+};
+use slotmap::KeyData;
+use tempfile::{TempDir, tempdir};
+
+fn plane() -> SketchPlaneBasis {
+ let Ok(basis) = SketchPlaneBasis::new(
+ Point3::origin(),
+ UnitVec3::x_axis(),
+ UnitVec3::y_axis(),
+ Tolerance::new(1e-9),
+ ) else {
+ panic!("xy plane");
+ };
+ basis
+}
+
+fn mm(v: f64) -> Length {
+ Length::new::(v)
+}
+
+fn ok_dir() -> TempDir {
+ let Ok(dir) = tempdir() else {
+ panic!("tempdir");
+ };
+ dir
+}
+
+fn sketch_id(idx: u32) -> SketchId {
+ SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+fn document_id(idx: u32) -> DocumentId {
+ DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+fn jj_available() -> bool {
+ let Ok(out) = Command::new("jj").arg("--version").output() else {
+ return false;
+ };
+ out.status.success()
+}
+
+fn run(cmd: &mut Command) -> String {
+ let Ok(out) = cmd.output() else {
+ panic!("{cmd:?}");
+ };
+ assert!(
+ out.status.success(),
+ "{cmd:?} failed: {}\n{}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr),
+ );
+ String::from_utf8_lossy(&out.stdout).into_owned()
+}
+
+fn rectangle() -> Sketch {
+ let script = [
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))),
+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))),
+ ];
+ let Ok((s, _)) = Sketch::new(plane()).apply_all(script) else {
+ panic!("rectangle");
+ };
+ s
+}
+
+fn assert_save(doc: &Document, folder: &DocumentFolder) {
+ let Ok(()) = save(doc, folder) else {
+ panic!("save");
+ };
+}
+
+fn assert_load(folder: &DocumentFolder) -> Document {
+ let Ok(doc) = load(folder) else {
+ panic!("load");
+ };
+ doc
+}
+
+#[test]
+fn jj_diff_on_dimension_edit_is_text_only() {
+ if !jj_available() {
+ eprintln!("skip: jj not on PATH");
+ return;
+ }
+
+ let dir = ok_dir();
+ let folder = DocumentFolder::new(dir.path().join("demo.bone"));
+
+ let mut doc = Document::new(document_id(1), "demo".to_owned());
+ let sid = sketch_id(1);
+ let rect = rectangle();
+ let Some(&a) = rect.entity_order().first() else {
+ panic!("rect has points");
+ };
+ let Some(&b) = rect.entity_order().get(1) else {
+ panic!("rect has points");
+ };
+ let Ok((rect, EditOutcome::Dimension(dim_id))) =
+ rect.apply(SketchEdit::AddDimension(SketchDimension::Linear {
+ a,
+ b,
+ value: mm(10.0),
+ kind: DimensionKind::Driving,
+ }))
+ else {
+ panic!("dim");
+ };
+ doc.insert_sketch(sid, "Sketch1".to_owned(), rect.clone());
+ assert_save(&doc, &folder);
+
+ run(Command::new("jj")
+ .arg("git")
+ .arg("init")
+ .arg("--colocate")
+ .current_dir(folder.path()));
+ run(Command::new("jj")
+ .arg("config")
+ .arg("set")
+ .arg("--repo")
+ .arg("user.name")
+ .arg("test")
+ .current_dir(folder.path()));
+ run(Command::new("jj")
+ .arg("config")
+ .arg("set")
+ .arg("--repo")
+ .arg("user.email")
+ .arg("test@example.com")
+ .current_dir(folder.path()));
+ run(Command::new("jj")
+ .arg("describe")
+ .arg("--message")
+ .arg("initial")
+ .current_dir(folder.path()));
+ run(Command::new("jj").arg("new").current_dir(folder.path()));
+
+ let reopened = assert_load(&folder);
+ let Some(sketch) = reopened.sketch(sid) else {
+ panic!("sketch missing");
+ };
+ let Ok((updated_sketch, _)) = sketch.clone().apply(SketchEdit::UpdateDimensionValue {
+ id: dim_id,
+ value: bone_document::DimensionValue::Length(mm(12.5)),
+ }) else {
+ panic!("update dim");
+ };
+ let mut next_doc = reopened.clone();
+ next_doc.replace_sketch(sid, updated_sketch);
+ assert_save(&next_doc, &folder);
+
+ let diff = run(Command::new("jj")
+ .arg("diff")
+ .arg("--git")
+ .current_dir(folder.path()));
+ assert!(
+ diff.contains("sketches/"),
+ "expected sketch file in diff:\n{diff}"
+ );
+ assert!(
+ diff.contains('+') && diff.contains('-'),
+ "expected text diff markers:\n{diff}"
+ );
+ assert!(
+ !diff.contains("Binary files"),
+ "expected no binary diff:\n{diff}"
+ );
+}
diff --git a/crates/bone-document/tests/folder_property.proptest-regressions b/crates/bone-document/tests/folder_property.proptest-regressions
new file mode 100644
index 0000000..ea91ddc
--- /dev/null
+++ b/crates/bone-document/tests/folder_property.proptest-regressions
@@ -0,0 +1,7 @@
+# Seeds for failure cases proptest has generated in the past. It is
+# automatically read and these particular cases re-run before any
+# novel cases are generated.
+#
+# It is recommended to check this file in to source control so that
+# everyone who runs the test benefits from these saved cases.
+cc b546c8b5dd00350941227512211737f6516fcf6278d102fa85ee6aa30bb41584 # shrinks to steps = [Point(0, 0), Point(0, 0), LinearDim { ai: 0, bi: 0, v: 56114, driven: false }]
diff --git a/crates/bone-document/tests/folder_property.rs b/crates/bone-document/tests/folder_property.rs
new file mode 100644
index 0000000..454b810
--- /dev/null
+++ b/crates/bone-document/tests/folder_property.rs
@@ -0,0 +1,428 @@
+use bone_document::{
+ DimensionKind, Document, DocumentFolder, Sketch, SketchDimension, SketchEdit, SketchEntity,
+ SketchEntityKind, SketchParameter, SketchRelation, load, save,
+};
+use bone_types::{
+ Angle, DocumentId, Length, Parameter, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance,
+ UnitVec3, degree, millimeter,
+};
+use proptest::prelude::*;
+use slotmap::KeyData;
+use tempfile::tempdir;
+
+fn plane() -> SketchPlaneBasis {
+ let Ok(basis) = SketchPlaneBasis::new(
+ Point3::origin(),
+ UnitVec3::x_axis(),
+ UnitVec3::y_axis(),
+ Tolerance::new(1e-9),
+ ) else {
+ panic!("xy plane");
+ };
+ basis
+}
+
+fn mm(v: f64) -> Length {
+ Length::new::(v)
+}
+
+fn deg(v: f64) -> Angle {
+ Angle::new::(v)
+}
+
+fn sketch_id(idx: u32) -> SketchId {
+ SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+fn document_id(idx: u32) -> DocumentId {
+ DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
+}
+
+#[derive(Copy, Clone, Debug)]
+enum Step {
+ Point(i16, i16),
+ Parameter(i16),
+ Line {
+ ai: u8,
+ bi: u8,
+ cons: bool,
+ },
+ Circle {
+ ci: u8,
+ r: u16,
+ cons: bool,
+ },
+ Arc {
+ ci: u8,
+ si: u8,
+ ei: u8,
+ },
+ Coincident {
+ pi: u8,
+ xi: u8,
+ },
+ Horizontal {
+ li: u8,
+ },
+ Vertical {
+ li: u8,
+ },
+ Parallel {
+ ai: u8,
+ bi: u8,
+ },
+ Perpendicular {
+ ai: u8,
+ bi: u8,
+ },
+ Tangent {
+ ai: u8,
+ bi: u8,
+ },
+ EqualLines {
+ ai: u8,
+ bi: u8,
+ },
+ EqualRounds {
+ ai: u8,
+ bi: u8,
+ },
+ Concentric {
+ ai: u8,
+ bi: u8,
+ },
+ Fix {
+ ei: u8,
+ },
+ LinearDim {
+ ai: u8,
+ bi: u8,
+ v: u16,
+ driven: bool,
+ },
+ RadiusDim {
+ ti: u8,
+ v: u16,
+ driven: bool,
+ },
+ DiameterDim {
+ ti: u8,
+ v: u16,
+ driven: bool,
+ },
+ AngularDim {
+ ai: u8,
+ bi: u8,
+ deg: u16,
+ },
+ ToggleConstruction {
+ ei: u8,
+ },
+}
+
+fn arb_step() -> impl Strategy {
+ prop_oneof![
+ (any::(), any::()).prop_map(|(x, y)| Step::Point(x, y)),
+ any::().prop_map(Step::Parameter),
+ (any::(), any::(), any::()).prop_map(|(ai, bi, cons)| Step::Line {
+ ai,
+ bi,
+ cons
+ }),
+ (any::(), any::(), any::()).prop_map(|(ci, r, cons)| Step::Circle {
+ ci,
+ r: r % 200 + 1,
+ cons
+ }),
+ (any::(), any::(), any::()).prop_map(|(ci, si, ei)| Step::Arc { ci, si, ei }),
+ (any::(), any::