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::()).prop_map(|(pi, xi)| Step::Coincident { pi, xi }), + any::().prop_map(|li| Step::Horizontal { li }), + any::().prop_map(|li| Step::Vertical { li }), + (any::(), any::()).prop_map(|(ai, bi)| Step::Parallel { ai, bi }), + (any::(), any::()).prop_map(|(ai, bi)| Step::Perpendicular { ai, bi }), + (any::(), any::()).prop_map(|(ai, bi)| Step::Tangent { ai, bi }), + (any::(), any::()).prop_map(|(ai, bi)| Step::EqualLines { ai, bi }), + (any::(), any::()).prop_map(|(ai, bi)| Step::EqualRounds { ai, bi }), + (any::(), any::()).prop_map(|(ai, bi)| Step::Concentric { ai, bi }), + any::().prop_map(|ei| Step::Fix { ei }), + (any::(), any::(), any::(), any::()) + .prop_map(|(ai, bi, v, driven)| Step::LinearDim { ai, bi, v, driven }), + (any::(), any::(), any::()).prop_map(|(ti, v, driven)| Step::RadiusDim { + ti, + v: v % 200 + 1, + driven + }), + (any::(), any::(), any::()).prop_map(|(ti, v, driven)| Step::DiameterDim { + ti, + v: v % 200 + 1, + driven + }), + (any::(), any::(), any::()).prop_map(|(ai, bi, deg)| Step::AngularDim { + ai, + bi, + deg: deg % 359 + 1 + }), + any::().prop_map(|ei| Step::ToggleConstruction { ei }), + ] +} + +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 a = usize::from(ai) % xs.len(); + let offset = usize::from(bi) % (xs.len() - 1) + 1; + let b = (a + offset) % xs.len(); + Some((xs[a], xs[b])) +} + +fn pick_three_distinct(xs: &[T], ai: u8, bi: u8, ci: u8) -> Option<(T, T, T)> { + if xs.len() < 3 { + return None; + } + let a = usize::from(ai) % xs.len(); + let b_off = usize::from(bi) % (xs.len() - 1) + 1; + let b = (a + b_off) % xs.len(); + let remaining: Vec<_> = (0..xs.len()).filter(|&i| i != a && i != b).collect(); + let c = remaining[usize::from(ci) % remaining.len()]; + Some((xs[a], xs[b], xs[c])) +} + +fn dim_kind(driven: bool) -> DimensionKind { + if driven { + DimensionKind::Driven + } else { + DimensionKind::Driving + } +} + +fn rounds(s: &Sketch) -> Vec { + entities_of_kind(s, SketchEntityKind::Arc) + .into_iter() + .chain(entities_of_kind(s, SketchEntityKind::Circle)) + .collect() +} + +fn resolve(s: &Sketch, step: Step) -> Option { + match step { + Step::Point(..) | Step::Parameter(..) => Some(resolve_atom(step)), + Step::Line { .. } | Step::Circle { .. } | Step::Arc { .. } => resolve_entity(s, step), + Step::Coincident { .. } + | Step::Horizontal { .. } + | Step::Vertical { .. } + | Step::Parallel { .. } + | Step::Perpendicular { .. } + | Step::Tangent { .. } + | Step::EqualLines { .. } + | Step::EqualRounds { .. } + | Step::Concentric { .. } + | Step::Fix { .. } => resolve_relation(s, step), + Step::LinearDim { .. } + | Step::RadiusDim { .. } + | Step::DiameterDim { .. } + | Step::AngularDim { .. } => resolve_dimension(s, step), + Step::ToggleConstruction { ei } => { + let id = pick(s.entity_order(), ei)?; + (!s.entities()[id].is_point()).then_some(SketchEdit::SetConstruction { + id, + for_construction: true, + }) + } + } +} + +fn resolve_atom(step: Step) -> SketchEdit { + match step { + Step::Point(x, y) => SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm( + f64::from(x) / 100.0, + f64::from(y) / 100.0, + ))), + Step::Parameter(v) => { + SketchEdit::AddParameter(SketchParameter::new(Parameter::new(f64::from(v) / 100.0))) + } + _ => unreachable!("caller routes only Point and Parameter here"), + } +} + +fn resolve_entity(s: &Sketch, step: Step) -> Option { + match step { + Step::Line { ai, bi, cons } => { + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?; + Some(SketchEdit::AddEntity(SketchEntity::line(a, b, cons))) + } + Step::Circle { ci, r, cons } => { + let c = pick(&entities_of_kind(s, SketchEntityKind::Point), ci)?; + Some(SketchEdit::AddEntity(SketchEntity::circle( + c, + mm(f64::from(r)), + cons, + ))) + } + Step::Arc { ci, si, ei } => { + let (c, start, end) = + pick_three_distinct(&entities_of_kind(s, SketchEntityKind::Point), ci, si, ei)?; + Some(SketchEdit::AddEntity(SketchEntity::arc( + c, start, end, false, + ))) + } + _ => unreachable!("caller routes only Line, Circle, Arc here"), + } +} + +fn resolve_relation(s: &Sketch, step: Step) -> Option { + let rel = match step { + Step::Coincident { pi, xi } => { + let p = pick(&entities_of_kind(s, SketchEntityKind::Point), pi)?; + let others: Vec<_> = s + .entity_order() + .iter() + .copied() + .filter(|id| *id != p) + .collect(); + SketchRelation::Coincident(p, pick(&others, xi)?) + } + Step::Horizontal { li } => { + SketchRelation::Horizontal(pick(&entities_of_kind(s, SketchEntityKind::Line), li)?) + } + Step::Vertical { li } => { + SketchRelation::Vertical(pick(&entities_of_kind(s, SketchEntityKind::Line), li)?) + } + Step::Parallel { ai, bi } => { + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; + SketchRelation::Parallel(a, b) + } + Step::Perpendicular { ai, bi } => { + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; + SketchRelation::Perpendicular(a, b) + } + Step::Tangent { ai, bi } => { + let round = rounds(s); + let lines = entities_of_kind(s, SketchEntityKind::Line); + let a = pick(&round, ai)?; + let b = if lines.is_empty() { + let remaining: Vec<_> = round.iter().copied().filter(|id| *id != a).collect(); + pick(&remaining, bi)? + } else { + pick(&lines, bi)? + }; + SketchRelation::Tangent(a, b) + } + Step::EqualLines { ai, bi } => { + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; + SketchRelation::Equal(a, b) + } + Step::EqualRounds { ai, bi } => { + let (a, b) = pick_two_distinct(&rounds(s), ai, bi)?; + SketchRelation::Equal(a, b) + } + Step::Concentric { ai, bi } => { + let (a, b) = pick_two_distinct(&rounds(s), ai, bi)?; + SketchRelation::Concentric(a, b) + } + Step::Fix { ei } => SketchRelation::Fix(pick(s.entity_order(), ei)?), + _ => unreachable!("caller routes only relations here"), + }; + Some(SketchEdit::AddRelation(rel)) +} + +fn resolve_dimension(s: &Sketch, step: Step) -> Option { + let dim = match step { + Step::LinearDim { ai, bi, v, driven } => { + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?; + SketchDimension::Linear { + a, + b, + value: mm(f64::from(v)), + kind: dim_kind(driven), + } + } + Step::RadiusDim { ti, v, driven } => SketchDimension::Radius { + target: pick(&rounds(s), ti)?, + value: mm(f64::from(v)), + kind: dim_kind(driven), + }, + Step::DiameterDim { ti, v, driven } => SketchDimension::Diameter { + target: pick(&rounds(s), ti)?, + value: mm(f64::from(v)), + kind: dim_kind(driven), + }, + Step::AngularDim { ai, bi, deg: d } => { + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; + SketchDimension::Angular { + a, + b, + value: deg(f64::from(d)), + kind: DimensionKind::Driving, + } + } + _ => unreachable!("caller routes only dimensions here"), + }; + Some(SketchEdit::AddDimension(dim)) +} + +fn build(steps: Vec) -> Sketch { + steps.into_iter().fold(Sketch::new(plane()), |sk, step| { + resolve(&sk, step) + .and_then(|edit| sk.clone().apply(edit).ok().map(|(s, _)| s)) + .unwrap_or(sk) + }) +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 32, + .. ProptestConfig::default() + })] + + #[test] + fn load_save_roundtrip_preserves_sketch(steps in prop::collection::vec(arb_step(), 0..30)) { + let sketch = build(steps); + let dir = tempdir().map_err(|e| TestCaseError::Fail(format!("tempdir: {e}").into()))?; + let folder = DocumentFolder::new(dir.path().join("fuzz.bone")); + let mut doc = Document::new(document_id(1), "fuzz".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch.clone()); + save(&doc, &folder).map_err(|e| TestCaseError::Fail(format!("save: {e}").into()))?; + let loaded = load(&folder).map_err(|e| TestCaseError::Fail(format!("load: {e}").into()))?; + let Some(round) = loaded.sketch(sketch_id(1)) else { + return Err(TestCaseError::Fail("sketch missing".into())); + }; + prop_assert_eq!(round, &sketch); + } + + #[test] + fn double_save_is_bit_identical(steps in prop::collection::vec(arb_step(), 0..30)) { + let sketch = build(steps); + let dir = tempdir().map_err(|e| TestCaseError::Fail(format!("tempdir: {e}").into()))?; + let folder = DocumentFolder::new(dir.path().join("det.bone")); + let mut doc = Document::new(document_id(1), "det".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch); + save(&doc, &folder).map_err(|e| TestCaseError::Fail(format!("save: {e}").into()))?; + let first_doc = std::fs::read(folder.document_file()) + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; + let first_sketch = std::fs::read(folder.sketch_path(sketch_id(1))) + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; + save(&doc, &folder).map_err(|e| TestCaseError::Fail(format!("save2: {e}").into()))?; + let second_doc = std::fs::read(folder.document_file()) + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; + let second_sketch = std::fs::read(folder.sketch_path(sketch_id(1))) + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; + prop_assert_eq!(first_doc, second_doc); + prop_assert_eq!(first_sketch, second_sketch); + } +} diff --git a/crates/bone-document/tests/folder_roundtrip.rs b/crates/bone-document/tests/folder_roundtrip.rs new file mode 100644 index 0000000..bedff61 --- /dev/null +++ b/crates/bone-document/tests/folder_roundtrip.rs @@ -0,0 +1,610 @@ +use bone_document::{ + BlobHash, BlobKind, Document, DocumentFolder, DocumentHeader, EditOutcome, Sketch, + SketchDimension, SketchEdit, SketchEntity, SketchRegistry, SketchRegistryEntry, SketchRelation, + from_str, load, save, to_string, +}; +use bone_types::{ + Angle, DocumentId, Length, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, + degree, millimeter, +}; +use slotmap::{Key, 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 is orthogonal"); + }; + basis +} + +fn mm(v: f64) -> Length { + Length::new::(v) +} + +fn deg(v: f64) -> Angle { + Angle::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 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 clean"); + }; +} + +fn assert_load(folder: &DocumentFolder) -> Document { + let Ok(doc) = load(folder) else { + panic!("load clean"); + }; + doc +} + +fn read_file(path: &std::path::Path) -> String { + let Ok(text) = std::fs::read_to_string(path) else { + panic!("read {}", path.display()); + }; + text +} + +fn write_file(path: &std::path::Path, contents: &str) { + let Ok(()) = std::fs::write(path, contents) else { + panic!("write {}", path.display()); + }; +} + +#[test] +fn rectangle_roundtrips_through_folder() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("part.bone")); + + let mut doc = Document::new(document_id(1), "part".to_owned()); + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); + doc.set_parameter("width".to_owned(), 10.0); + + assert_save(&doc, &folder); + let loaded = assert_load(&folder); + + assert_eq!(loaded.name(), doc.name()); + assert_eq!(loaded.registry().order(), doc.registry().order()); + assert_eq!(loaded.feature_tree(), doc.feature_tree()); + assert_eq!(loaded.sketches_map(), doc.sketches_map()); + assert_eq!(loaded.parameters().get("width"), Some(10.0)); +} + +#[test] +fn rich_sketch_with_every_variant_roundtrips() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("rich.bone")); + + let mut s = Sketch::new(plane()); + let Ok((s_next, EditOutcome::Entity(p0))) = s.apply(SketchEdit::AddEntity( + SketchEntity::point(Point2::from_mm(0.0, 0.0)), + )) else { + panic!("p0"); + }; + s = s_next; + let Ok((s_next, EditOutcome::Entity(p1))) = s.apply(SketchEdit::AddEntity( + SketchEntity::point(Point2::from_mm(10.0, 0.0)), + )) else { + panic!("p1"); + }; + s = s_next; + let Ok((s_next, EditOutcome::Entity(p2))) = s.apply(SketchEdit::AddEntity( + SketchEntity::point(Point2::from_mm(5.0, 6.0)), + )) else { + panic!("p2"); + }; + s = s_next; + + let Ok((s_next, EditOutcome::Entity(line))) = + s.apply(SketchEdit::AddEntity(SketchEntity::line(p0, p1, false))) + else { + panic!("line"); + }; + s = s_next; + let Ok((s_next, EditOutcome::Entity(line2))) = + s.apply(SketchEdit::AddEntity(SketchEntity::line(p0, p2, true))) + else { + panic!("line2"); + }; + s = s_next; + let Ok((s_next, EditOutcome::Entity(arc))) = + s.apply(SketchEdit::AddEntity(SketchEntity::arc(p0, p1, p2, false))) + else { + panic!("arc"); + }; + s = s_next; + let Ok((s_next, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity( + SketchEntity::circle(p0, mm(3.0), false), + )) else { + panic!("circle"); + }; + s = s_next; + + for relation in [ + SketchRelation::Coincident(p0, line), + SketchRelation::Horizontal(line), + SketchRelation::Vertical(line2), + SketchRelation::Parallel(line, line2), + SketchRelation::Perpendicular(line, line2), + SketchRelation::Tangent(line, circle), + SketchRelation::Equal(line, line2), + SketchRelation::Concentric(arc, circle), + SketchRelation::Fix(p0), + ] { + let Ok((next, _)) = s.apply(SketchEdit::AddRelation(relation)) else { + panic!("relation {relation:?}"); + }; + s = next; + } + + for dim in [ + SketchDimension::Linear { + a: p0, + b: p1, + value: mm(10.0), + kind: bone_document::DimensionKind::Driving, + }, + SketchDimension::Radius { + target: circle, + value: mm(3.0), + kind: bone_document::DimensionKind::Driven, + }, + SketchDimension::Diameter { + target: circle, + value: mm(6.0), + kind: bone_document::DimensionKind::Driving, + }, + SketchDimension::Angular { + a: line, + b: line2, + value: deg(45.0), + kind: bone_document::DimensionKind::Driving, + }, + ] { + let Ok((next, _)) = s.apply(SketchEdit::AddDimension(dim)) else { + panic!("dim {dim:?}"); + }; + s = next; + } + + let mut doc = Document::new(document_id(1), "rich".to_owned()); + doc.insert_sketch(sketch_id(1), "Rich".to_owned(), s.clone()); + + assert_save(&doc, &folder); + let loaded = assert_load(&folder); + let Some(loaded_sketch) = loaded.sketch(sketch_id(1)) else { + panic!("sketch missing after load"); + }; + assert_eq!(loaded_sketch, &s); +} + +#[test] +fn load_refuses_unknown_schema_major() { + use bone_types::{SchemaHeader, SchemaVersion}; + + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("future.bone")); + let mut doc = Document::new(document_id(1), "future".to_owned()); + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); + assert_save(&doc, &folder); + + let header_path = folder.document_file(); + let text = read_file(&header_path); + write_file(&header_path, &text.replace("major: 1,", "major: 9999,")); + + let load_result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::UnsupportedMajor { + found, supported, .. + }) = load_result + else { + panic!("expected UnsupportedMajor"); + }; + assert_eq!(found, SchemaVersion::new(9999, 0)); + assert_eq!( + supported, + SchemaVersion::new( + SchemaHeader::BONE_DOCUMENT_MAJOR, + SchemaHeader::BONE_DOCUMENT_MINOR + ) + ); +} + +#[test] +fn load_refuses_unknown_schema_name() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("foreign.bone")); + let mut doc = Document::new(document_id(1), "foreign".to_owned()); + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); + assert_save(&doc, &folder); + + let header_path = folder.document_file(); + let text = read_file(&header_path); + write_file( + &header_path, + &text.replace("\"bone-document\"", "\"kicad-pcb\""), + ); + + let load_result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::UnknownSchema { found, .. }) = load_result else { + panic!("expected UnknownSchema"); + }; + assert_eq!(found, "kicad-pcb"); +} + +#[test] +fn save_ships_vcs_files() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("vcs.bone")); + let doc = Document::new(document_id(1), "vcs".to_owned()); + assert_save(&doc, &folder); + + let gitignore = read_file(&folder.path().join(".gitignore")); + assert!(gitignore.contains("caches/")); + let gitattributes = read_file(&folder.path().join(".gitattributes")); + assert!(gitattributes.contains("* text=auto eol=lf")); + assert!(gitattributes.contains("*.boneblob binary")); + let caches_tag = read_file(&folder.caches_dir().join("CACHEDIR.TAG")); + assert!(caches_tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55")); + let caches_gitignore = read_file(&folder.caches_dir().join(".gitignore")); + assert_eq!(caches_gitignore, "*\n!.gitignore\n!CACHEDIR.TAG\n"); +} + +#[test] +fn save_is_idempotent_byte_for_byte() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("idem.bone")); + let mut doc = Document::new(document_id(1), "idem".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + assert_save(&doc, &folder); + let Ok(first) = std::fs::read(folder.document_file()) else { + panic!("read first"); + }; + assert_save(&doc, &folder); + let Ok(second) = std::fs::read(folder.document_file()) else { + panic!("read second"); + }; + assert_eq!(first, second, "document.ron drifted between saves"); +} + +#[test] +fn loaded_document_preserves_slotmap_keys() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("keys.bone")); + + let mut s = Sketch::new(plane()); + let Ok((s_next, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( + Point2::from_mm(0.0, 0.0), + ))) else { + panic!("a"); + }; + s = s_next; + let Ok((s_next, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( + Point2::from_mm(1.0, 0.0), + ))) else { + panic!("b"); + }; + s = s_next; + let Ok((s_next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { + panic!("line"); + }; + s = s_next; + + let mut doc = Document::new(document_id(1), "keys".to_owned()); + let id = sketch_id(1); + doc.insert_sketch(id, "S".to_owned(), s.clone()); + assert_save(&doc, &folder); + + let loaded = assert_load(&folder); + let Some(loaded_sketch) = loaded.sketch(id) else { + panic!("sketch"); + }; + let first_id = loaded_sketch.entity_order()[0]; + assert_eq!(first_id.data(), a.data()); + let entries: Vec<(SketchId, SketchRegistryEntry)> = loaded + .registry() + .iter() + .map(|(id, e)| (id, SketchRegistryEntry::clone(e))) + .collect(); + assert_eq!(entries.len(), 1); + let (_, entry) = &entries[0]; + assert_eq!(entry.label, "S"); + assert!( + std::path::Path::new(&entry.filename) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("ron")) + ); +} + +#[test] +fn feature_tree_bytes_survive_insert_remove_insert_cycle() { + let dir = ok_dir(); + let with_history = DocumentFolder::new(dir.path().join("hist.bone")); + let fresh = DocumentFolder::new(dir.path().join("fresh.bone")); + + let rect = rectangle(); + let mut doc_a = Document::new(document_id(1), "doc".to_owned()); + doc_a.insert_sketch(sketch_id(1), "S".to_owned(), rect.clone()); + doc_a.insert_sketch(sketch_id(2), "T".to_owned(), rect.clone()); + doc_a.remove_sketch(sketch_id(2)); + assert_save(&doc_a, &with_history); + + let mut doc_b = Document::new(document_id(1), "doc".to_owned()); + doc_b.insert_sketch(sketch_id(1), "S".to_owned(), rect); + assert_save(&doc_b, &fresh); + + let bytes_a = read_file(&with_history.document_file()); + let bytes_b = read_file(&fresh.document_file()); + assert_eq!( + bytes_a, bytes_b, + "remove then re-insert leaks tombstones into document.ron" + ); +} + +#[test] +fn push_sketch_is_idempotent_on_duplicate_id() { + let mut doc = Document::new(document_id(1), "dup".to_owned()); + let id = sketch_id(1); + doc.insert_sketch(id, "S".to_owned(), rectangle()); + doc.insert_sketch(id, "S-renamed".to_owned(), rectangle()); + let sketch_nodes = doc + .feature_tree() + .iter() + .filter(|(_, n)| matches!(n, bone_document::FeatureNode::Sketch(_))) + .count(); + assert_eq!( + sketch_nodes, 1, + "duplicate sketch id must not spawn a second feature node" + ); +} + +#[test] +fn removing_sketch_drops_stale_file() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("drop.bone")); + let mut doc = Document::new(document_id(1), "drop".to_owned()); + let id = sketch_id(1); + doc.insert_sketch(id, "S".to_owned(), rectangle()); + assert_save(&doc, &folder); + assert!(folder.sketch_path(id).exists()); + doc.remove_sketch(id); + assert_save(&doc, &folder); + assert!(!folder.sketch_path(id).exists()); +} + +#[test] +fn registry_kept_sorted_by_order_across_roundtrip() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("order.bone")); + let mut doc = Document::new(document_id(1), "order".to_owned()); + let rect = rectangle(); + let a = sketch_id(1); + let b = sketch_id(2); + let c = sketch_id(3); + doc.insert_sketch(a, "A".to_owned(), rect.clone()); + doc.insert_sketch(b, "B".to_owned(), rect.clone()); + doc.insert_sketch(c, "C".to_owned(), rect); + assert_save(&doc, &folder); + let loaded = assert_load(&folder); + assert_eq!(loaded.registry().order(), &[a, b, c]); + let _: &SketchRegistry = loaded.registry(); +} + +#[test] +fn f64_special_values_roundtrip_bit_identically() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("bits.bone")); + let specials: [(f64, f64); 7] = [ + (-0.0, 0.0), + (0.0, 1.0), + (f64::INFINITY, 2.0), + (f64::NEG_INFINITY, 3.0), + (f64::MIN_POSITIVE, 4.0), + (f64::MIN_POSITIVE / 2.0, 5.0), + (1.0 + f64::EPSILON, 6.0), + ]; + let sketch = specials + .into_iter() + .try_fold(Sketch::new(plane()), |s, (x, y)| { + s.apply(SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm( + x, y, + )))) + .map(|(next, _)| next) + }); + let Ok(sketch) = sketch else { + panic!("apply points"); + }; + let mut doc = Document::new(document_id(1), "bits".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch.clone()); + assert_save(&doc, &folder); + let loaded = assert_load(&folder); + let Some(back) = loaded.sketch(sketch_id(1)) else { + panic!("sketch missing"); + }; + let coords = |s: &Sketch| -> Vec<(f64, f64)> { + s.entity_order() + .iter() + .map(|id| match s.entities()[*id] { + SketchEntity::Point(p) => p.at().coords_mm(), + other => panic!("expected Point, got {other:?}"), + }) + .collect() + }; + let originals = coords(&sketch); + let reloaded = coords(back); + assert_eq!(originals.len(), reloaded.len()); + originals + .iter() + .zip(reloaded.iter()) + .for_each(|((ax, ay), (bx, by))| { + assert_eq!(ax.to_bits(), bx.to_bits(), "x bits drift: {ax} -> {bx}"); + assert_eq!(ay.to_bits(), by.to_bits(), "y bits drift: {ay} -> {by}"); + }); +} + +#[test] +fn load_refuses_sketch_file_unsupported_major() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("bad-sketch-schema.bone")); + let mut doc = Document::new(document_id(1), "bad".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + assert_save(&doc, &folder); + + let sketch_path = folder.sketch_path(sketch_id(1)); + let text = read_file(&sketch_path); + let patched = text.replace("major: 1,", "major: 9999,"); + assert_ne!(patched, text, "schema major pattern missing"); + write_file(&sketch_path, &patched); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::UnsupportedMajor { name, found, .. }) = result else { + panic!("expected UnsupportedMajor on sketch file, got {result:?}"); + }; + assert_eq!(name, "bone-document"); + assert_eq!(found.major, 9999); +} + +#[test] +fn load_refuses_tampered_sketch_integrity() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("neg.bone")); + 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, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( + center, + mm(1.0), + false, + ))) else { + panic!("circle"); + }; + let mut doc = Document::new(document_id(1), "neg".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), s); + assert_save(&doc, &folder); + + let sketch_path = folder.sketch_path(sketch_id(1)); + let text = read_file(&sketch_path); + let patched = text.replace("radius: 0.001,", "radius: -0.001,"); + assert_ne!(patched, text, "radius pattern missing"); + write_file(&sketch_path, &patched); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::SketchIntegrity { source, .. }) = result else { + panic!("expected SketchIntegrity, got {result:?}"); + }; + assert!(matches!( + source, + bone_document::SketchEditError::DegenerateEntity(_) + )); +} + +fn patch_header(path: &std::path::Path, f: impl FnOnce(&mut DocumentHeader)) { + let text = read_file(path); + let Ok(mut header) = from_str::(&text) else { + panic!("parse header"); + }; + f(&mut header); + let Ok(out) = to_string(&header) else { + panic!("serialise header"); + }; + write_file(path, &out); +} + +#[test] +fn load_refuses_dangling_feature_tree_sketch() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("dangle.bone")); + let mut doc = Document::new(document_id(1), "dangle".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + assert_save(&doc, &folder); + + patch_header(&folder.document_file(), |h| { + h.feature_tree.push_sketch(sketch_id(99)); + }); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::DanglingTreeSketch { id }) = result else { + panic!("expected DanglingTreeSketch, got {result:?}"); + }; + assert_eq!(id, sketch_id(99)); +} + +#[test] +fn load_refuses_orphan_registered_sketch() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("orphan.bone")); + let mut doc = Document::new(document_id(1), "orphan".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + assert_save(&doc, &folder); + + patch_header(&folder.document_file(), |h| { + h.feature_tree.remove_sketch(sketch_id(1)); + }); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::OrphanRegistered { id }) = result else { + panic!("expected OrphanRegistered, got {result:?}"); + }; + assert_eq!(id, sketch_id(1)); +} + +#[test] +fn blob_path_matches_locked_shape() { + let folder = DocumentFolder::new("/tmp/part.bone"); + let hash = BlobHash::of(b"example"); + let path = folder.blob_path(hash, BlobKind::STEP); + let Ok(suffix) = path.strip_prefix("/tmp/part.bone/blobs") else { + panic!("blob under blobs/: {}", path.display()); + }; + let components: Vec = suffix + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + components.len(), + 2, + "/.: got {components:?}" + ); + assert_eq!(components[0].len(), 2, "fanout is 2 hex chars"); + let Some(stem) = components[1].strip_suffix(".step") else { + panic!("extension is .step: {}", components[1]); + }; + assert_eq!(stem.len(), 30, "remaining 30 hex chars"); + let hex = hash.truncated_128_hex(); + assert_eq!(components[0], hex[..2]); + assert_eq!(stem, &hex[2..]); +} diff --git a/crates/bone-document/tests/folder_snapshots.rs b/crates/bone-document/tests/folder_snapshots.rs new file mode 100644 index 0000000..9e50f06 --- /dev/null +++ b/crates/bone-document/tests/folder_snapshots.rs @@ -0,0 +1,244 @@ +use bone_document::{ + DimensionKind, Document, DocumentFolder, DocumentHeader, Sketch, SketchDimension, SketchEdit, + SketchEntity, SketchFile, SketchRelation, save, to_string, +}; +use bone_types::{ + Angle, DocumentId, Length, Point2, Point3, SketchEntityId, SketchId, SketchPlaneBasis, + Tolerance, UnitVec3, degree, 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 deg(v: f64) -> Angle { + Angle::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 point_of(s: &Sketch, i: usize) -> SketchEntityId { + s.entity_order()[i] +} + +fn add_entity(s: Sketch, entity: SketchEntity) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(entity)) else { + panic!("add entity"); + }; + next +} + +fn add_relation(s: Sketch, relation: SketchRelation) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddRelation(relation)) else { + panic!("add relation"); + }; + next +} + +fn add_dimension(s: Sketch, dim: SketchDimension) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddDimension(dim)) else { + panic!("add dim"); + }; + next +} + +fn first_of_kind(s: &Sketch, kind: bone_document::SketchEntityKind) -> SketchEntityId { + let Some(&id) = s.entity_order().iter().find(|id| { + matches!( + s.entities().get(**id).map(bone_document::SketchEntity::kind), + Some(k) if k == kind + ) + }) else { + panic!("no entity of kind {kind:?}"); + }; + id +} + +fn build_full_sketch() -> Sketch { + let coords = [ + Point2::from_mm(0.0, 0.0), + Point2::from_mm(10.0, 0.0), + Point2::from_mm(10.0, 5.0), + Point2::from_mm(0.0, 5.0), + ]; + let with_points = coords.into_iter().fold(Sketch::new(plane()), |s, p| { + add_entity(s, SketchEntity::point(p)) + }); + let p0 = point_of(&with_points, 0); + let p1 = point_of(&with_points, 1); + let p2 = point_of(&with_points, 2); + let p3 = point_of(&with_points, 3); + + let with_lines = [ + (p0, p1, false), + (p1, p2, false), + (p2, p3, false), + (p3, p0, true), + ] + .into_iter() + .fold(with_points, |s, (a, b, cons)| { + add_entity(s, SketchEntity::line(a, b, cons)) + }); + let with_arc = add_entity(with_lines, SketchEntity::arc(p0, p1, p2, false)); + let with_circle = add_entity(with_arc, SketchEntity::circle(p0, mm(2.5), false)); + + let line_ids: Vec<_> = with_circle + .entity_order() + .iter() + .copied() + .filter(|id| { + matches!( + with_circle + .entities() + .get(*id) + .map(bone_document::SketchEntity::kind), + Some(bone_document::SketchEntityKind::Line) + ) + }) + .collect(); + let arc_id = first_of_kind(&with_circle, bone_document::SketchEntityKind::Arc); + let circle_id = first_of_kind(&with_circle, bone_document::SketchEntityKind::Circle); + + let relations = [ + SketchRelation::Coincident(p0, line_ids[0]), + SketchRelation::Horizontal(line_ids[0]), + SketchRelation::Vertical(line_ids[1]), + SketchRelation::Parallel(line_ids[0], line_ids[2]), + SketchRelation::Perpendicular(line_ids[0], line_ids[1]), + SketchRelation::Tangent(line_ids[0], circle_id), + SketchRelation::Equal(line_ids[0], line_ids[2]), + SketchRelation::Concentric(arc_id, circle_id), + SketchRelation::Fix(p0), + ]; + let with_relations = relations.into_iter().fold(with_circle, add_relation); + + let dimensions = [ + SketchDimension::Linear { + a: p0, + b: p1, + value: mm(10.0), + kind: DimensionKind::Driving, + }, + SketchDimension::Radius { + target: circle_id, + value: mm(2.5), + kind: DimensionKind::Driven, + }, + SketchDimension::Diameter { + target: circle_id, + value: mm(5.0), + kind: DimensionKind::Driving, + }, + SketchDimension::Angular { + a: line_ids[0], + b: line_ids[1], + value: deg(90.0), + kind: DimensionKind::Driving, + }, + ]; + dimensions.into_iter().fold(with_relations, add_dimension) +} + +fn assert_ron(value: &T) -> String { + let Ok(ron) = to_string(value) else { + panic!("ron"); + }; + ron +} + +#[test] +fn document_header_ron_surface() { + let mut doc = Document::new(document_id(1), "demo".to_owned()); + let sid = sketch_id(7); + doc.insert_sketch(sid, "Sketch1".to_owned(), build_full_sketch()); + doc.set_parameter("width".to_owned(), 10.0); + + let ron = assert_ron(doc.header()); + insta::assert_snapshot!("document_header", ron); +} + +#[test] +fn sketch_file_ron_surface() { + let file = SketchFile::new(build_full_sketch()); + let ron = assert_ron(&file); + insta::assert_snapshot!("sketch_file", ron); +} + +#[test] +fn saved_folder_layout_is_stable() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("pinned.bone")); + let mut doc = Document::new(document_id(1), "pinned".to_owned()); + doc.insert_sketch(sketch_id(1), "Rect".to_owned(), build_full_sketch()); + let Ok(()) = save(&doc, &folder) else { + panic!("save"); + }; + + let mut entries: Vec = walk_relative(folder.path()).collect(); + entries.sort(); + let listing = entries.join("\n"); + insta::assert_snapshot!("folder_listing", listing); +} + +fn walk_relative(root: &std::path::Path) -> impl Iterator + '_ { + fn walk(root: &std::path::Path, path: &std::path::Path, out: &mut Vec) { + let Ok(iter) = std::fs::read_dir(path) else { + return; + }; + iter.flatten().for_each(|entry| { + let p = entry.path(); + let rel = p + .strip_prefix(root) + .unwrap_or(&p) + .to_string_lossy() + .into_owned(); + if p.is_dir() { + out.push(format!("{rel}/")); + walk(root, &p, out); + } else { + out.push(rel); + } + }); + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.into_iter() +} + +#[test] +fn document_header_roundtrips_through_ron() { + let mut doc = Document::new(document_id(1), "rt".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), build_full_sketch()); + let ron = assert_ron(doc.header()); + let Ok(parsed) = bone_document::from_str::(&ron) else { + panic!("parse"); + }; + assert_eq!(&parsed, doc.header()); +} diff --git a/crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap b/crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap new file mode 100644 index 0000000..e767b08 --- /dev/null +++ b/crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap @@ -0,0 +1,79 @@ +--- +source: crates/bone-document/tests/folder_snapshots.rs +expression: ron +--- +#![enable(unwrap_newtypes)] +#![enable(implicit_some)] +#![enable(explicit_struct_names)] +DocumentHeader( + schema: SchemaHeader( + name: "bone-document", + version: SchemaVersion( + major: 1, + minor: 0, + ), + ), + id: SerKey( + idx: 1, + version: 1, + ), + name: "demo", + units: Millimetre, + parameters: DocumentParameters( + order: ["width"], + entries: { + "width": 10.0, + }, + ), + feature_tree: FeatureTree( + entries: [FeatureEntry( + id: SerKey( + idx: 1, + version: 1, + ), + node: Origin, + ), FeatureEntry( + id: SerKey( + idx: 2, + version: 1, + ), + node: PrincipalPlane(Xy), + ), FeatureEntry( + id: SerKey( + idx: 3, + version: 1, + ), + node: PrincipalPlane(Yz), + ), FeatureEntry( + id: SerKey( + idx: 4, + version: 1, + ), + node: PrincipalPlane(Zx), + ), FeatureEntry( + id: SerKey( + idx: 5, + version: 1, + ), + node: Sketch(SerKey( + idx: 7, + version: 1, + )), + )], + ), + sketches: SketchRegistry( + order: [SerKey( + idx: 7, + version: 1, + )], + entries: { + SerKey( + idx: 7, + version: 1, + ): SketchRegistryEntry( + label: "Sketch1", + filename: "0000000100000007.ron", + ), + }, + ), +) diff --git a/crates/bone-document/tests/snapshots/folder_snapshots__folder_listing.snap b/crates/bone-document/tests/snapshots/folder_snapshots__folder_listing.snap new file mode 100644 index 0000000..b05297e --- /dev/null +++ b/crates/bone-document/tests/snapshots/folder_snapshots__folder_listing.snap @@ -0,0 +1,13 @@ +--- +source: crates/bone-document/tests/folder_snapshots.rs +expression: listing +--- +.gitattributes +.gitignore +blobs/ +caches/ +caches/.gitignore +caches/CACHEDIR.TAG +document.ron +sketches/ +sketches/0000000100000001.ron diff --git a/crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap b/crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap new file mode 100644 index 0000000..852aec5 --- /dev/null +++ b/crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap @@ -0,0 +1,355 @@ +--- +source: crates/bone-document/tests/folder_snapshots.rs +expression: ron +--- +#![enable(unwrap_newtypes)] +#![enable(implicit_some)] +#![enable(explicit_struct_names)] +SketchFile( + schema: SchemaHeader( + name: "bone-document", + version: SchemaVersion( + major: 1, + minor: 0, + ), + ), + sketch: Sketch( + plane: SketchPlaneBasis( + origin: Point3( + x: 0.0, + y: 0.0, + z: 0.0, + ), + x_axis: UnitVec3( + x: 1.0, + y: 0.0, + z: 0.0, + ), + y_axis: UnitVec3( + x: 0.0, + y: 1.0, + z: 0.0, + ), + ), + entities: [SerdeSlot( + value: None, + version: 0, + ), SerdeSlot( + value: Point(PointData( + at: Point2( + x: 0.0, + y: 0.0, + ), + )), + version: 1, + ), SerdeSlot( + value: Point(PointData( + at: Point2( + x: 10.0, + y: 0.0, + ), + )), + version: 1, + ), SerdeSlot( + value: Point(PointData( + at: Point2( + x: 10.0, + y: 5.0, + ), + )), + version: 1, + ), SerdeSlot( + value: Point(PointData( + at: Point2( + x: 0.0, + y: 5.0, + ), + )), + version: 1, + ), SerdeSlot( + value: Line(LineData( + a: SerKey( + idx: 1, + version: 1, + ), + b: SerKey( + idx: 2, + version: 1, + ), + for_construction: false, + )), + version: 1, + ), SerdeSlot( + value: Line(LineData( + a: SerKey( + idx: 2, + version: 1, + ), + b: SerKey( + idx: 3, + version: 1, + ), + for_construction: false, + )), + version: 1, + ), SerdeSlot( + value: Line(LineData( + a: SerKey( + idx: 3, + version: 1, + ), + b: SerKey( + idx: 4, + version: 1, + ), + for_construction: false, + )), + version: 1, + ), SerdeSlot( + value: Line(LineData( + a: SerKey( + idx: 4, + version: 1, + ), + b: SerKey( + idx: 1, + version: 1, + ), + for_construction: true, + )), + version: 1, + ), SerdeSlot( + value: Arc(ArcData( + center: SerKey( + idx: 1, + version: 1, + ), + start: SerKey( + idx: 2, + version: 1, + ), + end: SerKey( + idx: 3, + version: 1, + ), + for_construction: false, + )), + version: 1, + ), SerdeSlot( + value: Circle(CircleData( + center: SerKey( + idx: 1, + version: 1, + ), + radius: 0.0025, + for_construction: false, + )), + version: 1, + )], + entity_order: [SerKey( + idx: 1, + version: 1, + ), SerKey( + idx: 2, + version: 1, + ), SerKey( + idx: 3, + version: 1, + ), SerKey( + idx: 4, + version: 1, + ), SerKey( + idx: 5, + version: 1, + ), SerKey( + idx: 6, + version: 1, + ), SerKey( + idx: 7, + version: 1, + ), SerKey( + idx: 8, + version: 1, + ), SerKey( + idx: 9, + version: 1, + ), SerKey( + idx: 10, + version: 1, + )], + relations: [SerdeSlot( + value: None, + version: 0, + ), SerdeSlot( + value: Coincident(SerKey( + idx: 1, + version: 1, + ), SerKey( + idx: 5, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Horizontal(SerKey( + idx: 5, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Vertical(SerKey( + idx: 6, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Parallel(SerKey( + idx: 5, + version: 1, + ), SerKey( + idx: 7, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Perpendicular(SerKey( + idx: 5, + version: 1, + ), SerKey( + idx: 6, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Tangent(SerKey( + idx: 5, + version: 1, + ), SerKey( + idx: 10, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Equal(SerKey( + idx: 5, + version: 1, + ), SerKey( + idx: 7, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Concentric(SerKey( + idx: 9, + version: 1, + ), SerKey( + idx: 10, + version: 1, + )), + version: 1, + ), SerdeSlot( + value: Fix(SerKey( + idx: 1, + version: 1, + )), + version: 1, + )], + relation_order: [SerKey( + idx: 1, + version: 1, + ), SerKey( + idx: 2, + version: 1, + ), SerKey( + idx: 3, + version: 1, + ), SerKey( + idx: 4, + version: 1, + ), SerKey( + idx: 5, + version: 1, + ), SerKey( + idx: 6, + version: 1, + ), SerKey( + idx: 7, + version: 1, + ), SerKey( + idx: 8, + version: 1, + ), SerKey( + idx: 9, + version: 1, + )], + dimensions: [SerdeSlot( + value: None, + version: 0, + ), SerdeSlot( + value: Linear( + a: SerKey( + idx: 1, + version: 1, + ), + b: SerKey( + idx: 2, + version: 1, + ), + value: 0.01, + kind: Driving, + ), + version: 1, + ), SerdeSlot( + value: Radius( + target: SerKey( + idx: 10, + version: 1, + ), + value: 0.0025, + kind: Driven, + ), + version: 1, + ), SerdeSlot( + value: Diameter( + target: SerKey( + idx: 10, + version: 1, + ), + value: 0.005, + kind: Driving, + ), + version: 1, + ), SerdeSlot( + value: Angular( + a: SerKey( + idx: 5, + version: 1, + ), + b: SerKey( + idx: 6, + version: 1, + ), + value: 1.5707963267948966, + kind: Driving, + ), + version: 1, + )], + dimension_order: [SerKey( + idx: 1, + version: 1, + ), SerKey( + idx: 2, + version: 1, + ), SerKey( + idx: 3, + version: 1, + ), SerKey( + idx: 4, + version: 1, + )], + parameters: [SerdeSlot( + value: None, + version: 0, + )], + parameter_order: [], + ), +) diff --git a/crates/bone-document/tests/undo.rs b/crates/bone-document/tests/undo.rs new file mode 100644 index 0000000..27425be --- /dev/null +++ b/crates/bone-document/tests/undo.rs @@ -0,0 +1,150 @@ +use std::num::NonZeroUsize; + +use bone_document::{Document, Sketch, UndoStack}; +use bone_types::{DocumentId, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3}; +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 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 cap(n: usize) -> NonZeroUsize { + let Some(nz) = NonZeroUsize::new(n) else { + panic!("capacity"); + }; + nz +} + +fn base_doc() -> Document { + Document::new(document_id(1), "d".to_owned()) +} + +fn with_sketch(mut doc: Document, sid: SketchId) -> Document { + doc.insert_sketch(sid, format!("S{sid:?}"), Sketch::new(plane())); + doc +} + +#[test] +fn fresh_stack_has_no_history() { + let stack = UndoStack::with_capacity(cap(5)); + assert!(!stack.can_undo()); + assert!(!stack.can_redo()); + assert_eq!(stack.past_len(), 0); + assert_eq!(stack.future_len(), 0); +} + +#[test] +fn undo_swaps_current_with_previous() { + let a = base_doc(); + let mut live = with_sketch(a.clone(), sketch_id(1)); + let mut stack = UndoStack::with_capacity(cap(5)); + stack.record(a.clone()); + assert!(stack.undo(&mut live)); + assert_eq!(live, a); + assert!(!stack.can_undo()); + assert!(stack.can_redo()); +} + +#[test] +fn undo_on_empty_leaves_current_unchanged() { + let mut live = base_doc(); + let snapshot = live.clone(); + let mut stack = UndoStack::with_capacity(cap(5)); + assert!(!stack.undo(&mut live)); + assert_eq!(live, snapshot); + assert_eq!(stack.past_len(), 0); + assert_eq!(stack.future_len(), 0); +} + +#[test] +fn redo_on_empty_leaves_current_unchanged() { + let mut live = base_doc(); + let snapshot = live.clone(); + let mut stack = UndoStack::with_capacity(cap(5)); + assert!(!stack.redo(&mut live)); + assert_eq!(live, snapshot); + assert_eq!(stack.past_len(), 0); + assert_eq!(stack.future_len(), 0); +} + +#[test] +fn redo_replays_undone_state() { + let a = base_doc(); + let b = with_sketch(a.clone(), sketch_id(1)); + let mut live = b.clone(); + let mut stack = UndoStack::with_capacity(cap(5)); + stack.record(a.clone()); + assert!(stack.undo(&mut live)); + assert!(stack.redo(&mut live)); + assert_eq!(live, b); + assert!(stack.can_undo()); + assert!(!stack.can_redo()); +} + +#[test] +fn recording_after_undo_clears_redo() { + let a = base_doc(); + let b = with_sketch(a.clone(), sketch_id(1)); + let c = with_sketch(a.clone(), sketch_id(2)); + let mut live = b; + let mut stack = UndoStack::with_capacity(cap(5)); + stack.record(a.clone()); + assert!(stack.undo(&mut live)); + assert_eq!(live, a); + stack.record(live.clone()); + let _ = c; + assert!(stack.can_undo()); + assert!(!stack.can_redo()); + assert_eq!(stack.future_len(), 0); +} + +#[test] +fn capacity_drops_oldest_entries() { + let mut stack = UndoStack::with_capacity(cap(2)); + let snapshots: Vec = (1..=4) + .map(|i| with_sketch(base_doc(), sketch_id(i))) + .collect(); + snapshots.iter().cloned().for_each(|doc| stack.record(doc)); + assert_eq!(stack.past_len(), 2); + let mut live = with_sketch(base_doc(), sketch_id(99)); + assert!(stack.undo(&mut live)); + assert_eq!(live, snapshots[3]); + assert!(stack.undo(&mut live)); + assert_eq!(live, snapshots[2]); + assert!(!stack.can_undo()); +} + +#[test] +fn undo_redo_cycle_preserves_determinism() { + let a = base_doc(); + let b = with_sketch(a.clone(), sketch_id(1)); + let c = with_sketch(b.clone(), sketch_id(2)); + let mut live = c.clone(); + let mut stack = UndoStack::with_capacity(cap(5)); + stack.record(a.clone()); + stack.record(b.clone()); + assert!(stack.undo(&mut live)); + assert_eq!(live, b); + assert!(stack.undo(&mut live)); + assert_eq!(live, a); + assert!(stack.redo(&mut live)); + assert_eq!(live, b); + assert!(stack.redo(&mut live)); + assert_eq!(live, c); +} diff --git a/crates/bone-kernel/Cargo.toml b/crates/bone-kernel/Cargo.toml new file mode 100644 index 0000000..4635d86 --- /dev/null +++ b/crates/bone-kernel/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bone-kernel" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +bone-types = { workspace = true } +thiserror = { workspace = true } +uom = { workspace = true } + +[dev-dependencies] +insta = { workspace = true } + +[lints] +workspace = true diff --git a/crates/bone-kernel/src/aabb.rs b/crates/bone-kernel/src/aabb.rs new file mode 100644 index 0000000..d087544 --- /dev/null +++ b/crates/bone-kernel/src/aabb.rs @@ -0,0 +1,51 @@ +use bone_types::Point2; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Aabb2 { + min: Point2, + max: Point2, +} + +impl Aabb2 { + #[must_use] + pub fn from_corners(a: Point2, b: Point2) -> Self { + let (ax, ay) = a.coords_mm(); + let (bx, by) = b.coords_mm(); + Self { + min: Point2::from_mm(ax.min(bx), ay.min(by)), + max: Point2::from_mm(ax.max(bx), ay.max(by)), + } + } + + #[must_use] + pub fn min(self) -> Point2 { + self.min + } + + #[must_use] + pub fn max(self) -> Point2 { + self.max + } + + #[must_use] + pub fn extend_point(self, p: Point2) -> Self { + let (px, py) = p.coords_mm(); + let (mnx, mny) = self.min.coords_mm(); + let (mxx, mxy) = self.max.coords_mm(); + Self { + min: Point2::from_mm(mnx.min(px), mny.min(py)), + max: Point2::from_mm(mxx.max(px), mxy.max(py)), + } + } +} + +impl core::fmt::Display for Aabb2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let (mnx, mny) = self.min.coords_mm(); + let (mxx, mxy) = self.max.coords_mm(); + write!( + f, + "aabb2{{ min=({mnx} mm, {mny} mm), max=({mxx} mm, {mxy} mm) }}" + ) + } +} diff --git a/crates/bone-kernel/src/arc2.rs b/crates/bone-kernel/src/arc2.rs new file mode 100644 index 0000000..9c28784 --- /dev/null +++ b/crates/bone-kernel/src/arc2.rs @@ -0,0 +1,332 @@ +use bone_types::{Angle, AngleTolerance, Length, Parameter, Point2, Tolerance, UnitVec2, Vec2}; +use core::f64::consts::{PI, TAU}; +use uom::si::angle::radian; +use uom::si::length::millimeter; + +use crate::KernelError; +use crate::aabb::Aabb2; +use crate::circle2::Circle2; +use crate::closest::ClosestPoint2; +use crate::curvature::Curvature; +use crate::curve2::{Curve2, Curve2Kind}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Arc2 { + center: Point2, + radius: Length, + start_angle: Angle, + sweep_angle: Angle, +} + +impl Arc2 { + pub fn new( + center: Point2, + radius: Length, + start_angle: Angle, + sweep_angle: Angle, + tolerance: Tolerance, + ) -> Result { + if radius.get::() < tolerance.value() { + return Err(KernelError::DegenerateArc); + } + let sweep = sweep_angle.get::(); + let angle_eps = AngleTolerance::from_arc_length(tolerance, radius).radians(); + if sweep.abs() < angle_eps { + return Err(KernelError::DegenerateArc); + } + if sweep.abs() > TAU + angle_eps { + return Err(KernelError::DegenerateArc); + } + Ok(Self { + center, + radius, + start_angle, + sweep_angle, + }) + } + + #[must_use] + pub const fn center(self) -> Point2 { + self.center + } + + #[must_use] + pub const fn radius(self) -> Length { + self.radius + } + + #[must_use] + pub const fn start_angle(self) -> Angle { + self.start_angle + } + + #[must_use] + pub const fn sweep_angle(self) -> Angle { + self.sweep_angle + } + + #[must_use] + pub fn radius_mm(self) -> f64 { + self.radius.get::() + } + + #[must_use] + pub fn start_rad(self) -> f64 { + self.start_angle.get::() + } + + #[must_use] + pub fn sweep_rad(self) -> f64 { + self.sweep_angle.get::() + } + + #[must_use] + pub fn as_full_circle(self) -> Circle2 { + Circle2::from_raw(self.center, self.radius) + } + + #[must_use] + pub fn contains_point(self, p: Point2, tolerance: Tolerance) -> bool { + let (cx, cy) = self.center.coords_mm(); + let (px, py) = p.coords_mm(); + let dx = px - cx; + let dy = py - cy; + let r = self.radius_mm(); + let radial = (dx * dx + dy * dy).sqrt(); + if (radial - r).abs() > tolerance.value() { + return false; + } + let angle_tol = AngleTolerance::from_arc_length(tolerance, self.radius); + self.contains_angle_rad(dy.atan2(dx), angle_tol) + } + + fn contains_angle_rad(self, theta: f64, tolerance: AngleTolerance) -> bool { + let start = self.start_rad(); + let sweep = self.sweep_rad(); + let eps = tolerance.radians(); + let delta = if sweep >= 0.0 { + (theta - start).rem_euclid(TAU) + } else { + (start - theta).rem_euclid(TAU) + }; + let magnitude = sweep.abs(); + delta <= magnitude + eps || delta >= TAU - eps + } + + fn angle_at(self, t: Parameter) -> f64 { + self.start_rad() + self.sweep_rad() * t.value() + } + + fn clamp_angle_rad(self, theta: f64, tolerance: AngleTolerance) -> f64 { + if self.contains_angle_rad(theta, tolerance) { + return theta; + } + let start = self.start_rad(); + let end = start + self.sweep_rad(); + let to_start = wrap_delta(theta - start).abs(); + let to_end = wrap_delta(theta - end).abs(); + if to_start <= to_end { start } else { end } + } +} + +impl Curve2 for Arc2 { + fn evaluate(&self, t: Parameter) -> Point2 { + let (cx, cy) = self.center.coords_mm(); + let r = self.radius_mm(); + let theta = self.angle_at(t); + Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()) + } + + fn derivative(&self, t: Parameter) -> Vec2 { + let r = self.radius_mm(); + let sweep = self.sweep_rad(); + let theta = self.angle_at(t); + Vec2::from_mm(-r * sweep * theta.sin(), r * sweep * theta.cos()) + } + + fn tangent(&self, t: Parameter) -> UnitVec2 { + let theta = self.angle_at(t); + let sign = self.sweep_rad().signum(); + UnitVec2::new_unchecked(-sign * theta.sin(), sign * theta.cos()) + } + + fn curvature(&self, _t: Parameter) -> Curvature { + Curvature::from_radius(self.radius).with_sign(self.sweep_rad()) + } + + fn bounding_box(&self) -> Aabb2 { + arc_bounding_box(self.center, self.radius, self.start_angle, self.sweep_angle) + } + + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2 { + let (cx, cy) = self.center.coords_mm(); + let (px, py) = p.coords_mm(); + let dx = px - cx; + let dy = py - cy; + let dist = (dx * dx + dy * dy).sqrt(); + let r = self.radius_mm(); + let angle_tol = AngleTolerance::from_arc_length(tolerance, self.radius); + + let theta_unclamped = if dist < tolerance.value() { + self.start_rad() + } else { + dy.atan2(dx) + }; + + let theta = self.clamp_angle_rad(theta_unclamped, angle_tol); + let sweep = self.sweep_rad(); + let delta = if sweep >= 0.0 { + (theta - self.start_rad()).rem_euclid(TAU) + } else { + (self.start_rad() - theta).rem_euclid(TAU) + }; + let t = (delta / sweep.abs()).clamp(0.0, 1.0); + let proj_x = cx + r * theta.cos(); + let proj_y = cy + r * theta.sin(); + let projected = Point2::from_mm(proj_x, proj_y); + let distance = + Length::new::(((px - proj_x).powi(2) + (py - proj_y).powi(2)).sqrt()); + ClosestPoint2::new(Parameter::new(t), projected, distance) + } + + fn as_kind(&self) -> Curve2Kind { + Curve2Kind::Arc(*self) + } +} + +impl core::fmt::Display for Arc2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "arc2{{ c={}, r={} mm, start={} rad, sweep={} rad }}", + self.center, + self.radius.get::(), + self.start_rad(), + self.sweep_rad(), + ) + } +} + +pub(crate) fn wrap_delta(a: f64) -> f64 { + (a + PI).rem_euclid(TAU) - PI +} + +#[must_use] +pub fn arc_bounding_box( + center: Point2, + radius: Length, + start_angle: Angle, + sweep_angle: Angle, +) -> Aabb2 { + let (cx, cy) = center.coords_mm(); + let r = radius.get::(); + let start = start_angle.get::(); + let sweep = sweep_angle.get::(); + let end = start + sweep; + let start_pt = Point2::from_mm(cx + r * start.cos(), cy + r * start.sin()); + let end_pt = Point2::from_mm(cx + r * end.cos(), cy + r * end.sin()); + let base = Aabb2::from_corners(start_pt, end_pt); + let cardinals: [(f64, Point2); 4] = [ + (0.0, Point2::from_mm(cx + r, cy)), + (PI * 0.5, Point2::from_mm(cx, cy + r)), + (PI, Point2::from_mm(cx - r, cy)), + (-PI * 0.5, Point2::from_mm(cx, cy - r)), + ]; + cardinals.into_iter().fold(base, |bbox, (theta, extreme)| { + if arc_contains_angle_rad(theta, start, sweep) { + bbox.extend_point(extreme) + } else { + bbox + } + }) +} + +fn arc_contains_angle_rad(theta: f64, start: f64, sweep: f64) -> bool { + let delta = if sweep >= 0.0 { + (theta - start).rem_euclid(TAU) + } else { + (start - theta).rem_euclid(TAU) + }; + delta <= sweep.abs() +} + +#[cfg(test)] +mod tests { + use super::arc_bounding_box; + use bone_types::{Angle, Length, Point2}; + use core::f64::consts::{FRAC_PI_2, PI, TAU}; + use uom::si::angle::radian; + use uom::si::length::millimeter; + + fn approx(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } + + fn arc(cx: f64, cy: f64, r_mm: f64, start_rad: f64, sweep_rad: f64) -> (Point2, Point2) { + let bbox = arc_bounding_box( + Point2::from_mm(cx, cy), + Length::new::(r_mm), + Angle::new::(start_rad), + Angle::new::(sweep_rad), + ); + (bbox.min(), bbox.max()) + } + + #[test] + fn full_circle_sweep_spans_all_cardinals() { + let (mn, mx) = arc(0.0, 0.0, 2.0, 0.0, TAU); + let (mnx, mny) = mn.coords_mm(); + let (mxx, mxy) = mx.coords_mm(); + assert!(approx(mnx, -2.0) && approx(mny, -2.0)); + assert!(approx(mxx, 2.0) && approx(mxy, 2.0)); + } + + #[test] + fn quarter_arc_hugs_its_quadrant() { + let (mn, mx) = arc(0.0, 0.0, 1.0, 0.0, FRAC_PI_2); + let (mnx, mny) = mn.coords_mm(); + let (mxx, mxy) = mx.coords_mm(); + assert!(approx(mnx, 0.0) && approx(mny, 0.0)); + assert!(approx(mxx, 1.0) && approx(mxy, 1.0)); + } + + #[test] + fn negative_sweep_mirrors_positive() { + let (fwd_min, fwd_max) = arc(0.0, 0.0, 1.0, 0.0, FRAC_PI_2); + let (rev_min, rev_max) = arc(0.0, 0.0, 1.0, FRAC_PI_2, -FRAC_PI_2); + assert_eq!(fwd_min, rev_min); + assert_eq!(fwd_max, rev_max); + } + + #[test] + fn small_sweep_hugs_endpoints_only() { + let start = FRAC_PI_2 * 0.5; + let sweep = 0.1; + let (mn, mx) = arc(0.0, 0.0, 1.0, start, sweep); + let (mnx, mny) = mn.coords_mm(); + let (mxx, mxy) = mx.coords_mm(); + let end = start + sweep; + assert!(approx(mnx, end.cos()) && approx(mny, start.sin())); + assert!(approx(mxx, start.cos()) && approx(mxy, end.sin())); + } + + #[test] + fn wide_sweep_crosses_three_axes() { + let start = FRAC_PI_2 * 0.5; + let sweep = FRAC_PI_2 * 2.5; + let (mn, mx) = arc(0.0, 0.0, 1.0, start, sweep); + let (mnx, mny) = mn.coords_mm(); + let (mxx, mxy) = mx.coords_mm(); + assert!(approx(mnx, -1.0) && approx(mny, -1.0)); + assert!(approx(mxx, start.cos()) && approx(mxy, 1.0)); + } + + #[test] + fn translated_center_offsets_the_box() { + let (mn, mx) = arc(10.0, -5.0, 3.0, 0.0, PI); + let (mnx, mny) = mn.coords_mm(); + let (mxx, mxy) = mx.coords_mm(); + assert!(approx(mnx, 7.0) && approx(mny, -5.0)); + assert!(approx(mxx, 13.0) && approx(mxy, -2.0)); + } +} diff --git a/crates/bone-kernel/src/circle2.rs b/crates/bone-kernel/src/circle2.rs new file mode 100644 index 0000000..bc02f30 --- /dev/null +++ b/crates/bone-kernel/src/circle2.rs @@ -0,0 +1,112 @@ +use bone_types::{Length, Parameter, Point2, Tolerance, UnitVec2, Vec2}; +use core::f64::consts::TAU; +use uom::si::length::millimeter; + +use crate::KernelError; +use crate::aabb::Aabb2; +use crate::closest::ClosestPoint2; +use crate::curvature::Curvature; +use crate::curve2::{Curve2, Curve2Kind}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Circle2 { + center: Point2, + radius: Length, +} + +impl Circle2 { + pub fn new(center: Point2, radius: Length, tolerance: Tolerance) -> Result { + if radius.get::() < tolerance.value() { + return Err(KernelError::DegenerateCircle); + } + Ok(Self { center, radius }) + } + + #[must_use] + pub(crate) const fn from_raw(center: Point2, radius: Length) -> Self { + Self { center, radius } + } + + #[must_use] + pub const fn center(self) -> Point2 { + self.center + } + + #[must_use] + pub const fn radius(self) -> Length { + self.radius + } + + #[must_use] + pub fn radius_mm(self) -> f64 { + self.radius.get::() + } +} + +impl Curve2 for Circle2 { + fn evaluate(&self, t: Parameter) -> Point2 { + let (cx, cy) = self.center.coords_mm(); + let r = self.radius_mm(); + let theta = TAU * t.value(); + Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()) + } + + fn derivative(&self, t: Parameter) -> Vec2 { + let r = self.radius_mm(); + let theta = TAU * t.value(); + Vec2::from_mm(-r * TAU * theta.sin(), r * TAU * theta.cos()) + } + + fn tangent(&self, t: Parameter) -> UnitVec2 { + let theta = TAU * t.value(); + UnitVec2::new_unchecked(-theta.sin(), theta.cos()) + } + + fn curvature(&self, _t: Parameter) -> Curvature { + Curvature::from_radius(self.radius) + } + + fn bounding_box(&self) -> Aabb2 { + let (cx, cy) = self.center.coords_mm(); + let r = self.radius_mm(); + Aabb2::from_corners( + Point2::from_mm(cx - r, cy - r), + Point2::from_mm(cx + r, cy + r), + ) + } + + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2 { + let (cx, cy) = self.center.coords_mm(); + let (px, py) = p.coords_mm(); + let dx = px - cx; + let dy = py - cy; + let dist = (dx * dx + dy * dy).sqrt(); + let r = self.radius_mm(); + if dist < tolerance.value() { + let projected = Point2::from_mm(cx + r, cy); + let distance = Length::new::(r); + return ClosestPoint2::new(Parameter::new(0.0), projected, distance); + } + let theta = dy.atan2(dx); + let t_raw = theta / TAU; + let t = t_raw.rem_euclid(1.0); + let projected = Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()); + let distance = Length::new::((dist - r).abs()); + ClosestPoint2::new(Parameter::new(t), projected, distance) + } + + fn as_kind(&self) -> Curve2Kind { + Curve2Kind::Circle(*self) + } +} + +impl core::fmt::Display for Circle2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "circle2{{ c={}, r={} mm }}", + self.center, + self.radius.get::(), + ) + } +} diff --git a/crates/bone-kernel/src/closest.rs b/crates/bone-kernel/src/closest.rs new file mode 100644 index 0000000..167044a --- /dev/null +++ b/crates/bone-kernel/src/closest.rs @@ -0,0 +1,47 @@ +use bone_types::{Length, Parameter, Point2}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ClosestPoint2 { + parameter: Parameter, + point: Point2, + distance: Length, +} + +impl ClosestPoint2 { + #[must_use] + pub const fn new(parameter: Parameter, point: Point2, distance: Length) -> Self { + Self { + parameter, + point, + distance, + } + } + + #[must_use] + pub const fn parameter(self) -> Parameter { + self.parameter + } + + #[must_use] + pub const fn point(self) -> Point2 { + self.point + } + + #[must_use] + pub const fn distance(self) -> Length { + self.distance + } +} + +impl core::fmt::Display for ClosestPoint2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + use uom::si::length::millimeter; + write!( + f, + "closest{{ t={}, p={}, d={} mm }}", + self.parameter.value(), + self.point, + self.distance.get::(), + ) + } +} diff --git a/crates/bone-kernel/src/curvature.rs b/crates/bone-kernel/src/curvature.rs new file mode 100644 index 0000000..20fec2a --- /dev/null +++ b/crates/bone-kernel/src/curvature.rs @@ -0,0 +1,30 @@ +use bone_types::Length; +use uom::si::length::millimeter; + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct Curvature(f64); + +impl Curvature { + pub const ZERO: Self = Self(0.0); + + #[must_use] + pub fn from_radius(radius: Length) -> Self { + Self(1.0 / radius.get::()) + } + + #[must_use] + pub const fn value_per_mm(self) -> f64 { + self.0 + } + + #[must_use] + pub fn with_sign(self, sign: f64) -> Self { + Self(self.0 * sign.signum()) + } +} + +impl core::fmt::Display for Curvature { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "k={}/mm", self.0) + } +} diff --git a/crates/bone-kernel/src/curve2.rs b/crates/bone-kernel/src/curve2.rs new file mode 100644 index 0000000..4af4fab --- /dev/null +++ b/crates/bone-kernel/src/curve2.rs @@ -0,0 +1,73 @@ +use bone_types::{Parameter, Point2, Tolerance, UnitVec2, Vec2}; + +use crate::aabb::Aabb2; +use crate::arc2::Arc2; +use crate::circle2::Circle2; +use crate::closest::ClosestPoint2; +use crate::curvature::Curvature; +use crate::line2::Line2; + +pub trait Curve2: Copy { + fn evaluate(&self, t: Parameter) -> Point2; + fn derivative(&self, t: Parameter) -> Vec2; + fn tangent(&self, t: Parameter) -> UnitVec2; + fn curvature(&self, t: Parameter) -> Curvature; + fn bounding_box(&self) -> Aabb2; + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2; + fn as_kind(&self) -> Curve2Kind; +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Curve2Kind { + Line(Line2), + Arc(Arc2), + Circle(Circle2), +} + +impl Curve2 for Curve2Kind { + fn evaluate(&self, t: Parameter) -> Point2 { + match self { + Self::Line(c) => c.evaluate(t), + Self::Arc(c) => c.evaluate(t), + Self::Circle(c) => c.evaluate(t), + } + } + fn derivative(&self, t: Parameter) -> Vec2 { + match self { + Self::Line(c) => c.derivative(t), + Self::Arc(c) => c.derivative(t), + Self::Circle(c) => c.derivative(t), + } + } + fn tangent(&self, t: Parameter) -> UnitVec2 { + match self { + Self::Line(c) => c.tangent(t), + Self::Arc(c) => c.tangent(t), + Self::Circle(c) => c.tangent(t), + } + } + fn curvature(&self, t: Parameter) -> Curvature { + match self { + Self::Line(c) => c.curvature(t), + Self::Arc(c) => c.curvature(t), + Self::Circle(c) => c.curvature(t), + } + } + fn bounding_box(&self) -> Aabb2 { + match self { + Self::Line(c) => c.bounding_box(), + Self::Arc(c) => c.bounding_box(), + Self::Circle(c) => c.bounding_box(), + } + } + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2 { + match self { + Self::Line(c) => c.closest_point(p, tolerance), + Self::Arc(c) => c.closest_point(p, tolerance), + Self::Circle(c) => c.closest_point(p, tolerance), + } + } + fn as_kind(&self) -> Curve2Kind { + *self + } +} diff --git a/crates/bone-kernel/src/intersect.rs b/crates/bone-kernel/src/intersect.rs new file mode 100644 index 0000000..0fdf576 --- /dev/null +++ b/crates/bone-kernel/src/intersect.rs @@ -0,0 +1,305 @@ +use bone_types::{AngleTolerance, Point2, Tolerance}; +use core::f64::consts::TAU; + +use crate::arc2::{Arc2, wrap_delta}; +use crate::circle2::Circle2; +use crate::curve2::Curve2Kind; +use crate::line2::Line2; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum IntersectionSet { + Empty, + One(Point2), + Two(Point2, Point2), + Coincident, +} + +impl core::fmt::Display for IntersectionSet { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Empty => write!(f, "empty"), + Self::One(p) => write!(f, "one{{ {p} }}"), + Self::Two(a, b) => write!(f, "two{{ {a}, {b} }}"), + Self::Coincident => write!(f, "coincident"), + } + } +} + +#[must_use] +pub fn intersect_curves(a: &Curve2Kind, b: &Curve2Kind, tolerance: Tolerance) -> IntersectionSet { + match (a, b) { + (Curve2Kind::Line(l), Curve2Kind::Line(m)) => line_line(l, m, tolerance), + (Curve2Kind::Line(l), Curve2Kind::Circle(c)) + | (Curve2Kind::Circle(c), Curve2Kind::Line(l)) => line_circle(l, c, tolerance), + (Curve2Kind::Line(l), Curve2Kind::Arc(a)) | (Curve2Kind::Arc(a), Curve2Kind::Line(l)) => { + line_arc(l, a, tolerance) + } + (Curve2Kind::Circle(c), Curve2Kind::Circle(d)) => circle_circle(c, d, tolerance), + (Curve2Kind::Circle(c), Curve2Kind::Arc(a)) + | (Curve2Kind::Arc(a), Curve2Kind::Circle(c)) => circle_arc(c, a, tolerance), + (Curve2Kind::Arc(a), Curve2Kind::Arc(b)) => arc_arc(a, b, tolerance), + } +} + +fn line_line(a: &Line2, b: &Line2, tolerance: Tolerance) -> IntersectionSet { + let (ax, ay) = a.start().coords_mm(); + let (ux, uy) = a.unit_direction().components(); + let la = a.length_mm(); + let (cx, cy) = b.start().coords_mm(); + let (vx, vy) = b.unit_direction().components(); + let lb = b.length_mm(); + let eps = tolerance.value(); + + let det = ux * vy - uy * vx; + let angle_eps = eps / la.max(lb); + + if det.abs() < angle_eps { + let dx = cx - ax; + let dy = cy - ay; + let perp = dx * uy - dy * ux; + if perp.abs() > eps { + return IntersectionSet::Empty; + } + let (ex, ey) = b.end().coords_mm(); + let t_c = dx * ux + dy * uy; + let t_d = (ex - ax) * ux + (ey - ay) * uy; + let (t_lo, t_hi) = if t_c <= t_d { (t_c, t_d) } else { (t_d, t_c) }; + let overlap_lo = t_lo.max(0.0); + let overlap_hi = t_hi.min(la); + let width = overlap_hi - overlap_lo; + if width < -eps { + return IntersectionSet::Empty; + } + if width <= eps { + let t = f64::midpoint(overlap_lo, overlap_hi); + return IntersectionSet::One(Point2::from_mm(ax + t * ux, ay + t * uy)); + } + return IntersectionSet::Coincident; + } + + let t = ((cx - ax) * vy - (cy - ay) * vx) / det; + let s = ((cx - ax) * uy - (cy - ay) * ux) / det; + if !in_segment(t, la, eps) || !in_segment(s, lb, eps) { + return IntersectionSet::Empty; + } + IntersectionSet::One(Point2::from_mm(ax + t * ux, ay + t * uy)) +} + +fn line_circle(line: &Line2, circle: &Circle2, tolerance: Tolerance) -> IntersectionSet { + let (ax, ay) = line.start().coords_mm(); + let (ux, uy) = line.unit_direction().components(); + let la = line.length_mm(); + let (cx, cy) = circle.center().coords_mm(); + let r = circle.radius_mm(); + let eps = tolerance.value(); + + let fx = ax - cx; + let fy = ay - cy; + let b_coef = 2.0 * (fx * ux + fy * uy); + let c_coef = fx * fx + fy * fy - r * r; + let disc = b_coef * b_coef - 4.0 * c_coef; + if disc < -eps * eps { + return IntersectionSet::Empty; + } + let sqrt_disc = disc.max(0.0).sqrt(); + let t1 = f64::midpoint(-b_coef, -sqrt_disc); + let t2 = f64::midpoint(-b_coef, sqrt_disc); + + let p = |t: f64| Point2::from_mm(ax + t * ux, ay + t * uy); + let in1 = in_segment(t1, la, eps); + let in2 = in_segment(t2, la, eps); + if sqrt_disc < eps { + if in1 { + IntersectionSet::One(p(t1)) + } else { + IntersectionSet::Empty + } + } else { + match (in1, in2) { + (true, true) => IntersectionSet::Two(p(t1), p(t2)), + (true, false) => IntersectionSet::One(p(t1)), + (false, true) => IntersectionSet::One(p(t2)), + (false, false) => IntersectionSet::Empty, + } + } +} + +fn line_arc(line: &Line2, arc: &Arc2, tolerance: Tolerance) -> IntersectionSet { + let circle = arc.as_full_circle(); + match line_circle(line, &circle, tolerance) { + IntersectionSet::Empty => IntersectionSet::Empty, + IntersectionSet::One(p) => { + if arc.contains_point(p, tolerance) { + IntersectionSet::One(p) + } else { + IntersectionSet::Empty + } + } + IntersectionSet::Two(p, q) => filter_two_by_arc(p, q, arc, tolerance), + IntersectionSet::Coincident => unreachable!("line_circle never returns Coincident"), + } +} + +fn circle_circle(a: &Circle2, b: &Circle2, tolerance: Tolerance) -> IntersectionSet { + let (ax, ay) = a.center().coords_mm(); + let (bx, by) = b.center().coords_mm(); + let ra = a.radius_mm(); + let rb = b.radius_mm(); + let eps = tolerance.value(); + + let dx = bx - ax; + let dy = by - ay; + let d_sq = dx * dx + dy * dy; + let d = d_sq.sqrt(); + + if d < eps && (ra - rb).abs() < eps { + return IntersectionSet::Coincident; + } + if d > ra + rb + eps { + return IntersectionSet::Empty; + } + if d + ra.min(rb) < ra.max(rb) - eps { + return IntersectionSet::Empty; + } + if d < eps { + return IntersectionSet::Empty; + } + + let a_proj = (d_sq + ra * ra - rb * rb) / (2.0 * d); + let h_sq = ra * ra - a_proj * a_proj; + let mx = ax + a_proj * dx / d; + let my = ay + a_proj * dy / d; + + if h_sq.abs() < eps * eps { + return IntersectionSet::One(Point2::from_mm(mx, my)); + } + if h_sq < 0.0 { + return IntersectionSet::Empty; + } + let h = h_sq.sqrt(); + let px = -dy / d * h; + let py = dx / d * h; + IntersectionSet::Two( + Point2::from_mm(mx + px, my + py), + Point2::from_mm(mx - px, my - py), + ) +} + +fn circle_arc(circle: &Circle2, arc: &Arc2, tolerance: Tolerance) -> IntersectionSet { + let arc_full = arc.as_full_circle(); + match circle_circle(circle, &arc_full, tolerance) { + IntersectionSet::Empty => IntersectionSet::Empty, + IntersectionSet::Coincident => IntersectionSet::Coincident, + IntersectionSet::One(p) => { + if arc.contains_point(p, tolerance) { + IntersectionSet::One(p) + } else { + IntersectionSet::Empty + } + } + IntersectionSet::Two(p, q) => filter_two_by_arc(p, q, arc, tolerance), + } +} + +fn arc_arc(a: &Arc2, b: &Arc2, tolerance: Tolerance) -> IntersectionSet { + let a_full = a.as_full_circle(); + let b_full = b.as_full_circle(); + match circle_circle(&a_full, &b_full, tolerance) { + IntersectionSet::Empty => IntersectionSet::Empty, + IntersectionSet::Coincident => same_circle_arc_overlap(a, b, tolerance), + IntersectionSet::One(p) => { + if a.contains_point(p, tolerance) && b.contains_point(p, tolerance) { + IntersectionSet::One(p) + } else { + IntersectionSet::Empty + } + } + IntersectionSet::Two(p, q) => { + let p_ok = a.contains_point(p, tolerance) && b.contains_point(p, tolerance); + let q_ok = a.contains_point(q, tolerance) && b.contains_point(q, tolerance); + match (p_ok, q_ok) { + (true, true) => IntersectionSet::Two(p, q), + (true, false) => IntersectionSet::One(p), + (false, true) => IntersectionSet::One(q), + (false, false) => IntersectionSet::Empty, + } + } + } +} + +fn same_circle_arc_overlap(a: &Arc2, b: &Arc2, tolerance: Tolerance) -> IntersectionSet { + let eps_angle = AngleTolerance::from_arc_length(tolerance, a.radius()).radians(); + let (lo_a, mag_a) = canonical_range(a); + let (lo_b, mag_b) = canonical_range(b); + + if mag_a >= TAU - eps_angle || mag_b >= TAU - eps_angle { + return IntersectionSet::Coincident; + } + + let hi_a = lo_a + mag_a; + + let overlaps: [Option<(f64, f64)>; 3] = [-TAU, 0.0, TAU].map(|shift| { + let lo_b_s = lo_b + shift; + let hi_b_s = lo_b_s + mag_b; + let o_lo = lo_a.max(lo_b_s); + let o_hi = hi_a.min(hi_b_s); + (o_hi + eps_angle >= o_lo).then_some((o_lo, o_hi)) + }); + + let has_1d = overlaps + .iter() + .any(|o| matches!(o, Some((s, e)) if e - s > eps_angle)); + if has_1d { + return IntersectionSet::Coincident; + } + + let points: Vec = overlaps + .iter() + .filter_map(|o| o.map(|(s, e)| (0.5 * (s + e)).rem_euclid(TAU))) + .fold(Vec::new(), |mut acc, t| { + if !acc.iter().any(|&p: &f64| angle_close(p, t, eps_angle)) { + acc.push(t); + } + acc + }); + + match points.as_slice() { + [] => IntersectionSet::Empty, + [t] => IntersectionSet::One(arc_point_at_angle(a, *t)), + [t1, t2, ..] => { + IntersectionSet::Two(arc_point_at_angle(a, *t1), arc_point_at_angle(a, *t2)) + } + } +} + +fn canonical_range(arc: &Arc2) -> (f64, f64) { + let start = arc.start_rad(); + let sweep = arc.sweep_rad(); + let lo = if sweep >= 0.0 { start } else { start + sweep }; + (lo.rem_euclid(TAU), sweep.abs()) +} + +fn arc_point_at_angle(arc: &Arc2, theta: f64) -> Point2 { + let (cx, cy) = arc.center().coords_mm(); + let r = arc.radius_mm(); + Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()) +} + +fn angle_close(a: f64, b: f64, eps: f64) -> bool { + wrap_delta(a - b).abs() < eps +} + +fn filter_two_by_arc(p: Point2, q: Point2, arc: &Arc2, tolerance: Tolerance) -> IntersectionSet { + let p_in = arc.contains_point(p, tolerance); + let q_in = arc.contains_point(q, tolerance); + match (p_in, q_in) { + (true, true) => IntersectionSet::Two(p, q), + (true, false) => IntersectionSet::One(p), + (false, true) => IntersectionSet::One(q), + (false, false) => IntersectionSet::Empty, + } +} + +fn in_segment(t: f64, length: f64, eps: f64) -> bool { + t >= -eps && t <= length + eps +} diff --git a/crates/bone-kernel/src/lib.rs b/crates/bone-kernel/src/lib.rs new file mode 100644 index 0000000..eb2292d --- /dev/null +++ b/crates/bone-kernel/src/lib.rs @@ -0,0 +1,29 @@ +pub mod aabb; +pub mod arc2; +pub mod circle2; +pub mod closest; +pub mod curvature; +pub mod curve2; +pub mod intersect; +pub mod line2; + +pub use aabb::Aabb2; +pub use arc2::{Arc2, arc_bounding_box}; +pub use circle2::Circle2; +pub use closest::ClosestPoint2; +pub use curvature::Curvature; +pub use curve2::{Curve2, Curve2Kind}; +pub use intersect::{IntersectionSet, intersect_curves}; +pub use line2::Line2; + +#[derive(Debug, thiserror::Error)] +pub enum KernelError { + #[error("line endpoints coincide within tolerance")] + DegenerateLine, + #[error("arc sweep is within tolerance of zero or exceeds 2π")] + DegenerateArc, + #[error("circle radius is within tolerance of zero")] + DegenerateCircle, +} + +pub type Result = core::result::Result; diff --git a/crates/bone-kernel/src/line2.rs b/crates/bone-kernel/src/line2.rs new file mode 100644 index 0000000..ee9c95e --- /dev/null +++ b/crates/bone-kernel/src/line2.rs @@ -0,0 +1,95 @@ +use bone_types::{Length, Parameter, Point2, Tolerance, UnitVec2, Vec2}; +use uom::si::length::millimeter; + +use crate::KernelError; +use crate::aabb::Aabb2; +use crate::closest::ClosestPoint2; +use crate::curvature::Curvature; +use crate::curve2::{Curve2, Curve2Kind}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Line2 { + start: Point2, + end: Point2, +} + +impl Line2 { + pub fn new(start: Point2, end: Point2, tolerance: Tolerance) -> Result { + if (end - start).norm_mm() < tolerance.value() { + return Err(KernelError::DegenerateLine); + } + Ok(Self { start, end }) + } + + #[must_use] + pub const fn start(self) -> Point2 { + self.start + } + + #[must_use] + pub const fn end(self) -> Point2 { + self.end + } + + #[must_use] + pub fn length(self) -> Length { + (self.end - self.start).norm() + } + + #[must_use] + pub fn length_mm(self) -> f64 { + (self.end - self.start).norm_mm() + } + + #[must_use] + pub fn unit_direction(self) -> UnitVec2 { + let (dx, dy) = (self.end - self.start).coords_mm(); + let len = (dx * dx + dy * dy).sqrt(); + UnitVec2::new_unchecked(dx / len, dy / len) + } +} + +impl Curve2 for Line2 { + fn evaluate(&self, t: Parameter) -> Point2 { + let (sx, sy) = self.start.coords_mm(); + let (ex, ey) = self.end.coords_mm(); + let u = t.value(); + Point2::from_mm(sx + u * (ex - sx), sy + u * (ey - sy)) + } + + fn derivative(&self, _t: Parameter) -> Vec2 { + self.end - self.start + } + + fn tangent(&self, _t: Parameter) -> UnitVec2 { + self.unit_direction() + } + + fn curvature(&self, _t: Parameter) -> Curvature { + Curvature::ZERO + } + + fn bounding_box(&self) -> Aabb2 { + Aabb2::from_corners(self.start, self.end) + } + + fn closest_point(&self, p: Point2, _tolerance: Tolerance) -> ClosestPoint2 { + let d = self.end - self.start; + let v = p - self.start; + let len_sq = d.norm_squared_mm2(); + let t = (v.dot_mm2(d) / len_sq).clamp(0.0, 1.0); + let point = self.evaluate(Parameter::new(t)); + let dist_mm = (p - point).norm_mm(); + ClosestPoint2::new(Parameter::new(t), point, Length::new::(dist_mm)) + } + + fn as_kind(&self) -> Curve2Kind { + Curve2Kind::Line(*self) + } +} + +impl core::fmt::Display for Line2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "line2{{ {} -> {} }}", self.start, self.end) + } +} diff --git a/crates/bone-kernel/tests/curves.rs b/crates/bone-kernel/tests/curves.rs new file mode 100644 index 0000000..672ed28 --- /dev/null +++ b/crates/bone-kernel/tests/curves.rs @@ -0,0 +1,155 @@ +use bone_kernel::{Arc2, Circle2, Curve2, Line2}; +use bone_types::{Angle, Length, Parameter, Point2, Tolerance}; +use uom::si::angle::radian; +use uom::si::length::millimeter; + +const TOL: Tolerance = Tolerance::new(1e-9); + +fn samples() -> [Parameter; 5] { + [ + Parameter::new(0.0), + Parameter::new(0.25), + Parameter::new(0.5), + Parameter::new(0.75), + Parameter::new(1.0), + ] +} + +fn fmt_point(p: Point2) -> String { + let (x, y) = p.coords_mm(); + format!("({x:.6}, {y:.6})") +} + +fn fmt_curve(name: &str, c: &C) -> String { + let samples = samples(); + let points = samples + .iter() + .map(|t| { + format!( + "t={:.2} -> p={} v={}", + t.value(), + fmt_point(c.evaluate(*t)), + c.derivative(*t) + ) + }) + .collect::>() + .join("\n "); + let k_start = c.curvature(Parameter::new(0.0)); + let k_mid = c.curvature(Parameter::new(0.5)); + let tangent_mid = c.tangent(Parameter::new(0.5)); + let bbox = c.bounding_box(); + let closest = c.closest_point(Point2::from_mm(0.0, 0.0), TOL); + format!( + "{name}_display = {c}\n\ + {name}_debug = {c:?}\n\ + {name}_samples:\n {points}\n\ + {name}_tangent_mid = {tangent_mid}\n\ + {name}_curvature_start = {k_start}\n\ + {name}_curvature_mid = {k_mid}\n\ + {name}_bbox = {bbox}\n\ + {name}_closest_origin = {closest}", + ) +} + +#[test] +fn line2_surface() { + let Ok(line) = Line2::new(Point2::from_mm(1.0, 2.0), Point2::from_mm(7.0, 6.0), TOL) else { + panic!("endpoints distinct"); + }; + insta::assert_snapshot!(fmt_curve("line2", &line)); +} + +#[test] +fn circle2_surface() { + let Ok(circle) = Circle2::new( + Point2::from_mm(2.0, -1.0), + Length::new::(3.0), + TOL, + ) else { + panic!("radius positive"); + }; + insta::assert_snapshot!(fmt_curve("circle2", &circle)); +} + +#[test] +fn arc2_quarter_ccw_surface() { + let Ok(arc) = Arc2::new( + Point2::from_mm(0.0, 0.0), + Length::new::(5.0), + Angle::new::(0.0), + Angle::new::(core::f64::consts::FRAC_PI_2), + TOL, + ) else { + panic!("sweep nonzero"); + }; + insta::assert_snapshot!(fmt_curve("arc2_q", &arc)); +} + +#[test] +fn arc2_half_cw_surface() { + let Ok(arc) = Arc2::new( + Point2::from_mm(1.0, 1.0), + Length::new::(2.0), + Angle::new::(core::f64::consts::PI), + Angle::new::(-core::f64::consts::PI), + TOL, + ) else { + panic!("sweep nonzero"); + }; + insta::assert_snapshot!(fmt_curve("arc2_h", &arc)); +} + +#[test] +fn line2_rejects_degenerate_endpoints() { + let err = Line2::new( + Point2::from_mm(1.0, 1.0), + Point2::from_mm(1.0, 1.0 + 1e-12), + TOL, + ); + assert!(err.is_err()); +} + +#[test] +fn circle2_rejects_zero_radius() { + let err = Circle2::new( + Point2::from_mm(0.0, 0.0), + Length::new::(0.0), + TOL, + ); + assert!(err.is_err()); +} + +#[test] +fn arc2_rejects_zero_sweep() { + let err = Arc2::new( + Point2::from_mm(0.0, 0.0), + Length::new::(1.0), + Angle::new::(0.0), + Angle::new::(0.0), + TOL, + ); + assert!(err.is_err()); +} + +#[test] +fn arc2_closest_point_wide_sweep_returns_midarc_t() { + let Ok(arc) = Arc2::new( + Point2::from_mm(0.0, 0.0), + Length::new::(1.0), + Angle::new::(0.0), + Angle::new::(3.0 * core::f64::consts::FRAC_PI_2), + TOL, + ) else { + panic!("3π/2 sweep within bounds"); + }; + let px = -0.7_f64; + let py = -0.8_f64; + let closest = arc.closest_point(Point2::from_mm(px, py), TOL); + let theta = py.atan2(px).rem_euclid(core::f64::consts::TAU); + let expected_t = theta / (3.0 * core::f64::consts::FRAC_PI_2); + let got_t = closest.parameter().value(); + assert!( + (got_t - expected_t).abs() < 1e-12, + "wide-sweep closest-point: expected t≈{expected_t}, got {got_t}" + ); +} diff --git a/crates/bone-kernel/tests/intersect.rs b/crates/bone-kernel/tests/intersect.rs new file mode 100644 index 0000000..e0d5a58 --- /dev/null +++ b/crates/bone-kernel/tests/intersect.rs @@ -0,0 +1,143 @@ +use bone_kernel::{Arc2, Circle2, Curve2, Curve2Kind, Line2, intersect_curves}; +use bone_types::{Angle, Length, Point2, Tolerance}; +use uom::si::angle::radian; +use uom::si::length::millimeter; + +const TOL: Tolerance = Tolerance::new(1e-9); + +fn line(ax: f64, ay: f64, bx: f64, by: f64) -> Curve2Kind { + let Ok(l) = Line2::new(Point2::from_mm(ax, ay), Point2::from_mm(bx, by), TOL) else { + panic!("line endpoints distinct"); + }; + l.as_kind() +} + +fn circle(cx: f64, cy: f64, r: f64) -> Curve2Kind { + let Ok(c) = Circle2::new(Point2::from_mm(cx, cy), Length::new::(r), TOL) else { + panic!("radius positive"); + }; + c.as_kind() +} + +fn arc(cx: f64, cy: f64, r: f64, start: f64, sweep: f64) -> Curve2Kind { + let Ok(a) = Arc2::new( + Point2::from_mm(cx, cy), + Length::new::(r), + Angle::new::(start), + Angle::new::(sweep), + TOL, + ) else { + panic!("sweep nonzero"); + }; + a.as_kind() +} + +fn row(label: &str, a: &Curve2Kind, b: &Curve2Kind) -> String { + format!("{label} = {}", intersect_curves(a, b, TOL)) +} + +#[test] +fn intersection_matrix_surface() { + let line_diag = line(-5.0, -5.0, 5.0, 5.0); + let line_cross = line(-5.0, 5.0, 5.0, -5.0); + let line_parallel = line(-5.0, -4.0, 5.0, 6.0); + let line_coincident = line(-2.0, -2.0, 2.0, 2.0); + let line_collinear_touch = line(5.0, 5.0, 10.0, 10.0); + let line_miss = line(10.0, 10.0, 11.0, 11.0); + let line_tangent = line(-5.0, 3.0, 5.0, 3.0); + let line_through_circle = line(-5.0, 0.0, 5.0, 0.0); + + let circle_unit = circle(0.0, 0.0, 3.0); + let circle_shift = circle(4.0, 0.0, 3.0); + let circle_contain = circle(0.0, 0.0, 1.0); + let circle_dup = circle(0.0, 0.0, 3.0); + let circle_far = circle(20.0, 0.0, 1.0); + + let arc_right = arc( + 0.0, + 0.0, + 3.0, + -core::f64::consts::FRAC_PI_2, + core::f64::consts::PI, + ); + let arc_upper = arc(0.0, 0.0, 3.0, 0.0, core::f64::consts::PI); + let arc_other = arc( + 4.0, + 0.0, + 3.0, + core::f64::consts::FRAC_PI_2, + core::f64::consts::PI, + ); + let line_above_arc = line(-5.0, 5.0, 5.0, 5.0); + + let arc_same_disjoint_q1 = arc(0.0, 0.0, 3.0, 0.0, core::f64::consts::FRAC_PI_2); + let arc_same_disjoint_q3 = arc( + 0.0, + 0.0, + 3.0, + core::f64::consts::PI, + core::f64::consts::FRAC_PI_2, + ); + let arc_same_touch_left = arc(0.0, 0.0, 3.0, 0.0, core::f64::consts::PI); + let arc_same_touch_right = arc(0.0, 0.0, 3.0, core::f64::consts::PI, core::f64::consts::PI); + let arc_same_partial_b = arc( + 0.0, + 0.0, + 3.0, + core::f64::consts::FRAC_PI_2, + core::f64::consts::PI, + ); + let arc_same_single_touch = arc( + 0.0, + 0.0, + 3.0, + core::f64::consts::FRAC_PI_2, + core::f64::consts::FRAC_PI_2, + ); + + let rows = [ + row("line_line_cross", &line_diag, &line_cross), + row("line_line_parallel_miss", &line_diag, &line_parallel), + row("line_line_coincident_overlap", &line_diag, &line_coincident), + row( + "line_line_collinear_touch", + &line_diag, + &line_collinear_touch, + ), + row("line_line_segment_miss", &line_diag, &line_miss), + row("line_circle_tangent", &line_tangent, &circle_unit), + row("line_circle_secant", &line_through_circle, &circle_unit), + row("line_arc_right_hit", &line_through_circle, &arc_right), + row("line_arc_upper_endpoints", &line_through_circle, &arc_upper), + row("line_arc_above_miss", &line_above_arc, &arc_upper), + row("circle_circle_two_points", &circle_unit, &circle_shift), + row("circle_circle_contained", &circle_unit, &circle_contain), + row("circle_circle_coincident", &circle_unit, &circle_dup), + row("circle_circle_disjoint", &circle_unit, &circle_far), + row("circle_arc_partial", &circle_shift, &arc_upper), + row("arc_arc_upper_vs_other", &arc_upper, &arc_other), + row( + "arc_arc_same_circle_disjoint", + &arc_same_disjoint_q1, + &arc_same_disjoint_q3, + ), + row( + "arc_arc_same_circle_two_touches", + &arc_same_touch_left, + &arc_same_touch_right, + ), + row( + "arc_arc_same_circle_partial_overlap", + &arc_upper, + &arc_same_partial_b, + ), + row( + "arc_arc_same_circle_single_touch", + &arc_same_disjoint_q1, + &arc_same_single_touch, + ), + ]; + + let surface = rows.join("\n"); + insta::assert_snapshot!(surface); +} diff --git a/crates/bone-kernel/tests/snapshots/curves__arc2_half_cw_surface.snap b/crates/bone-kernel/tests/snapshots/curves__arc2_half_cw_surface.snap new file mode 100644 index 0000000..0894289 --- /dev/null +++ b/crates/bone-kernel/tests/snapshots/curves__arc2_half_cw_surface.snap @@ -0,0 +1,17 @@ +--- +source: crates/bone-kernel/tests/curves.rs +expression: "fmt_curve(\"arc2_h\", &arc)" +--- +arc2_h_display = arc2{ c=(1 mm, 1 mm), r=2 mm, start=3.141592653589793 rad, sweep=-3.141592653589793 rad } +arc2_h_debug = Arc2 { center: Point2(1 mm, 1 mm), radius: 0.002 m^1, start_angle: 3.141592653589793, sweep_angle: -3.141592653589793 } +arc2_h_samples: + t=0.00 -> p=(-1.000000, 1.000000) v=<0.0000000000000007694682774887159 mm, 6.283185307179586 mm> + t=0.25 -> p=(-0.414214, 2.414214) v=<4.442882938158366 mm, 4.442882938158366 mm> + t=0.50 -> p=(1.000000, 3.000000) v=<6.283185307179586 mm, -0.00000000000000038473413874435795 mm> + t=0.75 -> p=(2.414214, 2.414214) v=<4.442882938158366 mm, -4.442882938158366 mm> + t=1.00 -> p=(3.000000, 1.000000) v=<0 mm, -6.283185307179586 mm> +arc2_h_tangent_mid = [1, -0.00000000000000006123233995736766] +arc2_h_curvature_start = k=-0.5/mm +arc2_h_curvature_mid = k=-0.5/mm +arc2_h_bbox = aabb2{ min=(-1 mm, 1 mm), max=(3 mm, 3 mm) } +arc2_h_closest_origin = closest{ t=0, p=(-1 mm, 1.0000000000000002 mm), d=1.4142135623730951 mm } diff --git a/crates/bone-kernel/tests/snapshots/curves__arc2_quarter_ccw_surface.snap b/crates/bone-kernel/tests/snapshots/curves__arc2_quarter_ccw_surface.snap new file mode 100644 index 0000000..b192efb --- /dev/null +++ b/crates/bone-kernel/tests/snapshots/curves__arc2_quarter_ccw_surface.snap @@ -0,0 +1,17 @@ +--- +source: crates/bone-kernel/tests/curves.rs +expression: "fmt_curve(\"arc2_q\", &arc)" +--- +arc2_q_display = arc2{ c=(0 mm, 0 mm), r=5 mm, start=0 rad, sweep=1.5707963267948966 rad } +arc2_q_debug = Arc2 { center: Point2(0 mm, 0 mm), radius: 0.005 m^1, start_angle: 0.0, sweep_angle: 1.5707963267948966 } +arc2_q_samples: + t=0.00 -> p=(5.000000, 0.000000) v=<-0 mm, 7.853981633974483 mm> + t=0.25 -> p=(4.619398, 1.913417) v=<-3.0055886494217314 mm, 7.256132880348577 mm> + t=0.50 -> p=(3.535534, 3.535534) v=<-5.553603672697957 mm, 5.553603672697958 mm> + t=0.75 -> p=(1.913417, 4.619398) v=<-7.256132880348577 mm, 3.005588649421732 mm> + t=1.00 -> p=(0.000000, 5.000000) v=<-7.853981633974483 mm, 0.0000000000000004809176734304475 mm> +arc2_q_tangent_mid = [-0.7071067811865475, 0.7071067811865476] +arc2_q_curvature_start = k=0.2/mm +arc2_q_curvature_mid = k=0.2/mm +arc2_q_bbox = aabb2{ min=(0 mm, 0 mm), max=(5 mm, 5 mm) } +arc2_q_closest_origin = closest{ t=0, p=(5 mm, 0 mm), d=5 mm } diff --git a/crates/bone-kernel/tests/snapshots/curves__circle2_surface.snap b/crates/bone-kernel/tests/snapshots/curves__circle2_surface.snap new file mode 100644 index 0000000..4b127f6 --- /dev/null +++ b/crates/bone-kernel/tests/snapshots/curves__circle2_surface.snap @@ -0,0 +1,17 @@ +--- +source: crates/bone-kernel/tests/curves.rs +expression: "fmt_curve(\"circle2\", &circle)" +--- +circle2_display = circle2{ c=(2 mm, -1 mm), r=3 mm } +circle2_debug = Circle2 { center: Point2(2 mm, -1 mm), radius: 0.003 m^1 } +circle2_samples: + t=0.00 -> p=(5.000000, -1.000000) v=<-0 mm, 18.84955592153876 mm> + t=0.25 -> p=(2.000000, 2.000000) v=<-18.84955592153876 mm, 0.0000000000000011542024162330739 mm> + t=0.50 -> p=(-1.000000, -1.000000) v=<-0.0000000000000023084048324661477 mm, -18.84955592153876 mm> + t=0.75 -> p=(2.000000, -4.000000) v=<18.84955592153876 mm, -0.0000000000000034626072486992214 mm> + t=1.00 -> p=(5.000000, -1.000000) v=<0.0000000000000046168096649322955 mm, 18.84955592153876 mm> +circle2_tangent_mid = [-0.00000000000000012246467991473532, -1] +circle2_curvature_start = k=0.3333333333333333/mm +circle2_curvature_mid = k=0.3333333333333333/mm +circle2_bbox = aabb2{ min=(-1 mm, -4 mm), max=(5 mm, 2 mm) } +circle2_closest_origin = closest{ t=0.42620819117478337, p=(-0.6832815729997477 mm, 0.3416407864998743 mm), d=0.7639320225002102 mm } diff --git a/crates/bone-kernel/tests/snapshots/curves__line2_surface.snap b/crates/bone-kernel/tests/snapshots/curves__line2_surface.snap new file mode 100644 index 0000000..4b5ebe7 --- /dev/null +++ b/crates/bone-kernel/tests/snapshots/curves__line2_surface.snap @@ -0,0 +1,17 @@ +--- +source: crates/bone-kernel/tests/curves.rs +expression: "fmt_curve(\"line2\", &line)" +--- +line2_display = line2{ (1 mm, 2 mm) -> (7 mm, 6 mm) } +line2_debug = Line2 { start: Point2(1 mm, 2 mm), end: Point2(7 mm, 6 mm) } +line2_samples: + t=0.00 -> p=(1.000000, 2.000000) v=<6 mm, 4 mm> + t=0.25 -> p=(2.500000, 3.000000) v=<6 mm, 4 mm> + t=0.50 -> p=(4.000000, 4.000000) v=<6 mm, 4 mm> + t=0.75 -> p=(5.500000, 5.000000) v=<6 mm, 4 mm> + t=1.00 -> p=(7.000000, 6.000000) v=<6 mm, 4 mm> +line2_tangent_mid = [0.8320502943378437, 0.5547001962252291] +line2_curvature_start = k=0/mm +line2_curvature_mid = k=0/mm +line2_bbox = aabb2{ min=(1 mm, 2 mm), max=(7 mm, 6 mm) } +line2_closest_origin = closest{ t=0, p=(1 mm, 2 mm), d=2.23606797749979 mm } diff --git a/crates/bone-kernel/tests/snapshots/intersect__intersection_matrix_surface.snap b/crates/bone-kernel/tests/snapshots/intersect__intersection_matrix_surface.snap new file mode 100644 index 0000000..40e0e65 --- /dev/null +++ b/crates/bone-kernel/tests/snapshots/intersect__intersection_matrix_surface.snap @@ -0,0 +1,24 @@ +--- +source: crates/bone-kernel/tests/intersect.rs +expression: surface +--- +line_line_cross = one{ (0 mm, 0 mm) } +line_line_parallel_miss = empty +line_line_coincident_overlap = coincident +line_line_collinear_touch = one{ (5 mm, 5 mm) } +line_line_segment_miss = empty +line_circle_tangent = one{ (0 mm, 3 mm) } +line_circle_secant = two{ (-3 mm, 0 mm), (3 mm, 0 mm) } +line_arc_right_hit = one{ (3 mm, 0 mm) } +line_arc_upper_endpoints = two{ (-3 mm, 0 mm), (3 mm, 0 mm) } +line_arc_above_miss = empty +circle_circle_two_points = two{ (2 mm, 2.23606797749979 mm), (2 mm, -2.23606797749979 mm) } +circle_circle_contained = empty +circle_circle_coincident = coincident +circle_circle_disjoint = empty +circle_arc_partial = one{ (2 mm, 2.23606797749979 mm) } +arc_arc_upper_vs_other = one{ (2 mm, 2.23606797749979 mm) } +arc_arc_same_circle_disjoint = empty +arc_arc_same_circle_two_touches = two{ (3 mm, 0 mm), (-3 mm, 0.00000000000000036739403974420594 mm) } +arc_arc_same_circle_partial_overlap = coincident +arc_arc_same_circle_single_touch = one{ (0.00000000000000018369701987210297 mm, 3 mm) } diff --git a/crates/bone-render/Cargo.toml b/crates/bone-render/Cargo.toml new file mode 100644 index 0000000..eccbb98 --- /dev/null +++ b/crates/bone-render/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bone-render" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +bone-document = { workspace = true } +bone-kernel = { workspace = true } +bone-types = { workspace = true } +bytemuck = { workspace = true } +png = { workspace = true } +slotmap = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uom = { workspace = true } +wgpu = { workspace = true } + +[dev-dependencies] +pollster = { workspace = true } +proptest = { workspace = true } + +[lints] +workspace = true diff --git a/crates/bone-render/src/camera.rs b/crates/bone-render/src/camera.rs new file mode 100644 index 0000000..a66d042 --- /dev/null +++ b/crates/bone-render/src/camera.rs @@ -0,0 +1,330 @@ +use bone_types::{Length, Vec2}; +use uom::si::length::millimeter; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ViewportPx(u32); + +impl ViewportPx { + #[must_use] + pub const fn new(value: u32) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u32 { + self.0 + } +} + +impl core::fmt::Display for ViewportPx { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}px", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct ViewportExtent { + width: ViewportPx, + height: ViewportPx, +} + +impl ViewportExtent { + #[must_use] + pub const fn new(width: ViewportPx, height: ViewportPx) -> Self { + Self { width, height } + } + + #[must_use] + pub const fn square(side: ViewportPx) -> Self { + Self { + width: side, + height: side, + } + } + + #[must_use] + pub const fn width(self) -> ViewportPx { + self.width + } + + #[must_use] + pub const fn height(self) -> ViewportPx { + self.height + } + + #[must_use] + pub fn pixel_count(self) -> u64 { + u64::from(self.width.value()) * u64::from(self.height.value()) + } +} + +impl core::fmt::Display for ViewportExtent { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}x{}", self.width, self.height) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct PixelsPerMm(f64); + +impl PixelsPerMm { + pub const DEFAULT: Self = Self(10.0); + + #[must_use] + pub fn new(value: f64) -> Self { + assert!( + value.is_finite() && value > 0.0, + "PixelsPerMm requires a positive, finite value: got {value}", + ); + Self(value) + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } +} + +impl core::fmt::Display for PixelsPerMm { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{} px/mm", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Camera2 { + extent: ViewportExtent, + pan_mm: Vec2, + zoom: PixelsPerMm, +} + +impl Camera2 { + #[must_use] + pub fn new(extent: ViewportExtent) -> Self { + Self { + extent, + pan_mm: Vec2::zero(), + zoom: PixelsPerMm::DEFAULT, + } + } + + #[must_use] + pub const fn extent(self) -> ViewportExtent { + self.extent + } + + #[must_use] + pub const fn pan_mm(self) -> Vec2 { + self.pan_mm + } + + #[must_use] + pub const fn zoom(self) -> PixelsPerMm { + self.zoom + } + + #[must_use] + pub fn with_extent(self, extent: ViewportExtent) -> Self { + Self { extent, ..self } + } + + #[must_use] + pub fn with_pan(self, pan_mm: Vec2) -> Self { + Self { pan_mm, ..self } + } + + #[must_use] + pub fn with_zoom(self, zoom: PixelsPerMm) -> Self { + Self { zoom, ..self } + } + + #[must_use] + pub fn world_mm_per_pixel(self) -> f64 { + 1.0 / self.zoom.value() + } + + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn clip_from_world_mm(self) -> [f32; 16] { + let (sx, sy) = self.scale(); + let (px, py) = self.pan_mm.coords_mm(); + let tx = -px * sx; + let ty = -py * sy; + column_major(sx as f32, sy as f32, tx as f32, ty as f32) + } + + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn world_mm_from_clip(self) -> [f32; 16] { + let (sx, sy) = self.scale(); + let inv_x = 1.0 / sx; + let inv_y = 1.0 / sy; + let (px, py) = self.pan_mm.coords_mm(); + column_major(inv_x as f32, inv_y as f32, px as f32, py as f32) + } + + fn scale(self) -> (f64, f64) { + let w = f64::from(self.extent.width.value()); + let h = f64::from(self.extent.height.value()); + let z = self.zoom.value(); + (2.0 * z / w, 2.0 * z / h) + } +} + +fn column_major(sx: f32, sy: f32, tx: f32, ty: f32) -> [f32; 16] { + [ + sx, 0.0, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, tx, ty, 0.0, 1.0, + ] +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct GridSpacing { + minor: Length, + major_every: u32, +} + +impl GridSpacing { + #[must_use] + pub fn minor(self) -> Length { + self.minor + } + + #[must_use] + pub const fn major_every(self) -> u32 { + self.major_every + } + + #[must_use] + pub fn major(self) -> Length { + self.minor * f64::from(self.major_every) + } + + #[must_use] + pub fn from_zoom(zoom: PixelsPerMm, target_minor_px: f32) -> Self { + assert!( + target_minor_px.is_finite() && target_minor_px > 0.0, + "GridSpacing::from_zoom requires a positive, finite target: got {target_minor_px}", + ); + let desired_mm = f64::from(target_minor_px) / zoom.value(); + let k = desired_mm.log10().floor(); + let frac = desired_mm / 10f64.powf(k); + let (multiplier, major_every, k_bump) = if frac < f64::sqrt(2.0) { + (1.0, 10, 0.0) + } else if frac < f64::sqrt(10.0) { + (2.0, 5, 0.0) + } else if frac < f64::sqrt(50.0) { + (5.0, 2, 0.0) + } else { + (1.0, 10, 1.0) + }; + let minor_mm = multiplier * 10f64.powf(k + k_bump); + Self { + minor: Length::new::(minor_mm), + major_every, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn extent(w: u32, h: u32) -> ViewportExtent { + ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h)) + } + + fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-6 + } + + #[test] + fn default_camera_maps_origin_to_clip_center() { + let cam = Camera2::new(extent(200, 100)); + let m = cam.clip_from_world_mm(); + assert!(approx_eq(m[12], 0.0)); + assert!(approx_eq(m[13], 0.0)); + } + + #[test] + fn clip_from_world_is_column_major() { + let cam = Camera2::new(extent(100, 100)).with_pan(Vec2::from_mm(2.0, 3.0)); + let m = cam.clip_from_world_mm(); + assert!(approx_eq(m[3], 0.0)); + assert!(approx_eq(m[7], 0.0)); + assert!(approx_eq(m[11], 0.0)); + assert!(approx_eq(m[15], 1.0)); + } + + #[test] + fn inverse_round_trips_origin() { + let cam = Camera2::new(extent(256, 128)) + .with_pan(Vec2::from_mm(-5.0, 4.0)) + .with_zoom(PixelsPerMm::new(8.0)); + let fwd = cam.clip_from_world_mm(); + let inv = cam.world_mm_from_clip(); + let rt = mul4(&inv, &fwd); + (0..16).for_each(|idx| { + let (col, row) = (idx / 4, idx % 4); + let want = if col == row { 1.0 } else { 0.0 }; + assert!( + approx_eq(rt[idx], want), + "round trip broke at col={col},row={row}: {}", + rt[idx] + ); + }); + } + + fn mul4(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + core::array::from_fn(|idx| { + let (col, row) = (idx / 4, idx % 4); + (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum() + }) + } + + #[test] + fn grid_spacing_selects_one_two_five_in_decade() { + [1.0_f64, 10.0, 100.0] + .into_iter() + .map(PixelsPerMm::new) + .map(|z| GridSpacing::from_zoom(z, 16.0)) + .for_each(|s| { + let minor_mm = s.minor().get::(); + let k = minor_mm.log10().floor(); + let decade = 10f64.powf(k); + let mult = minor_mm / decade; + let snapped = [1.0, 2.0, 5.0] + .into_iter() + .any(|m: f64| (mult - m).abs() < 1e-9); + assert!(snapped, "minor {minor_mm} mm not in {{1, 2, 5}} x decade"); + assert!([2, 5, 10].contains(&s.major_every())); + }); + } + + #[test] + fn grid_spacing_rolls_over_decade_past_sqrt_fifty() { + let zoom = PixelsPerMm::new(2.0); + let s = GridSpacing::from_zoom(zoom, 16.0); + let minor_mm = s.minor().get::(); + assert!( + (minor_mm - 10.0).abs() < 1e-9, + "expected rollover to 10 mm, got {minor_mm}", + ); + assert_eq!(s.major_every(), 10); + } + + #[test] + fn clip_x_of_pan_point_is_zero() { + let cam = Camera2::new(extent(320, 240)) + .with_pan(Vec2::from_mm(7.0, -3.0)) + .with_zoom(PixelsPerMm::new(5.0)); + let m = cam.clip_from_world_mm(); + let world = [7.0_f32, -3.0_f32, 0.0_f32, 1.0_f32]; + let clip = apply(&m, world); + assert!(approx_eq(clip[0], 0.0)); + assert!(approx_eq(clip[1], 0.0)); + } + + fn apply(m: &[f32; 16], v: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|row| (0..4).map(|col| m[col * 4 + row] * v[col]).sum()) + } +} diff --git a/crates/bone-render/src/diff.rs b/crates/bone-render/src/diff.rs new file mode 100644 index 0000000..6812272 --- /dev/null +++ b/crates/bone-render/src/diff.rs @@ -0,0 +1,280 @@ +use bone_types::Tolerance; + +use crate::camera::ViewportExtent; +use crate::snapshot::SnapshotFrame; + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct PixelDiffThreshold(f64); + +impl PixelDiffThreshold { + pub const EXACT: Self = Self(0.0); + + #[must_use] + pub fn new(channel_fraction: f64) -> Self { + Self(channel_fraction.clamp(0.0, 1.0)) + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } + + #[must_use] + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + pub fn as_u8(self) -> u8 { + (self.0.clamp(0.0, 1.0) * 255.0).round() as u8 + } +} + +impl From for PixelDiffThreshold { + fn from(t: Tolerance) -> Self { + Self::new(t.value()) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct PixelMismatch { + x: u32, + y: u32, + channel_delta: [u8; 4], +} + +impl PixelMismatch { + #[must_use] + pub const fn x(self) -> u32 { + self.x + } + + #[must_use] + pub const fn y(self) -> u32 { + self.y + } + + #[must_use] + pub const fn channel_delta(self) -> [u8; 4] { + self.channel_delta + } + + #[must_use] + pub fn max_delta(self) -> u8 { + self.channel_delta.into_iter().max().unwrap_or(0) + } +} + +#[derive(Clone, Debug, Default)] +pub struct PixelDiffReport { + worst: Option, + over_threshold: u32, +} + +impl PixelDiffReport { + #[must_use] + pub const fn worst(&self) -> Option { + self.worst + } + + #[must_use] + pub const fn over_threshold(&self) -> u32 { + self.over_threshold + } + + #[must_use] + pub const fn is_clean(&self) -> bool { + self.over_threshold == 0 + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PixelDiffError { + #[error( + "pixel buffer length mismatch: actual={actual}, expected={expected}, required={required}" + )] + ShapeMismatch { + actual: usize, + expected: usize, + required: usize, + }, + #[error("viewport dimension is zero")] + ZeroExtent, + #[error("pixel count {pixels} exceeds u32::MAX")] + ExtentOverflow { pixels: u64 }, +} + +pub struct PixelDiff; + +impl PixelDiff { + pub fn compare_bytes( + extent: ViewportExtent, + actual: &[u8], + expected: &[u8], + threshold: PixelDiffThreshold, + ) -> core::result::Result { + let width = extent.width().value(); + let height = extent.height().value(); + if width == 0 || height == 0 { + return Err(PixelDiffError::ZeroExtent); + } + let pixel_count = extent.pixel_count(); + if pixel_count > u64::from(u32::MAX) { + return Err(PixelDiffError::ExtentOverflow { + pixels: pixel_count, + }); + } + let Ok(required) = usize::try_from(pixel_count * 4) else { + return Err(PixelDiffError::ExtentOverflow { + pixels: pixel_count, + }); + }; + if actual.len() != required || expected.len() != required { + return Err(PixelDiffError::ShapeMismatch { + actual: actual.len(), + expected: expected.len(), + required, + }); + } + let limit = threshold.as_u8(); + let report = actual + .chunks_exact(4) + .zip(expected.chunks_exact(4)) + .zip(0_u32..) + .filter_map(|((a, e), idx)| { + let delta: [u8; 4] = [ + a[0].abs_diff(e[0]), + a[1].abs_diff(e[1]), + a[2].abs_diff(e[2]), + a[3].abs_diff(e[3]), + ]; + let max = delta[0].max(delta[1]).max(delta[2]).max(delta[3]); + (max > limit).then_some((idx, delta)) + }) + .fold(PixelDiffReport::default(), |acc, (idx, delta)| { + let m = PixelMismatch { + x: idx % width, + y: idx / width, + channel_delta: delta, + }; + let worst = match acc.worst { + Some(prev) if prev.max_delta() >= m.max_delta() => Some(prev), + _ => Some(m), + }; + PixelDiffReport { + worst, + over_threshold: acc.over_threshold + 1, + } + }); + Ok(report) + } + + pub fn compare( + actual: &SnapshotFrame, + expected: &[u8], + threshold: PixelDiffThreshold, + ) -> core::result::Result { + Self::compare_bytes(actual.extent(), actual.rgba(), expected, threshold) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::camera::{ViewportExtent, ViewportPx}; + + fn extent(w: u32, h: u32) -> ViewportExtent { + ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h)) + } + + fn run( + extent: ViewportExtent, + actual: &[u8], + expected: &[u8], + threshold: PixelDiffThreshold, + ) -> PixelDiffReport { + let Ok(report) = PixelDiff::compare_bytes(extent, actual, expected, threshold) else { + panic!("compare_bytes rejected inputs"); + }; + report + } + + #[test] + fn exact_match_is_clean() { + let buf = vec![10_u8, 20, 30, 255, 10, 20, 30, 255]; + let report = run(extent(2, 1), &buf, &buf, PixelDiffThreshold::EXACT); + assert!(report.is_clean()); + assert!(report.worst().is_none()); + } + + #[test] + fn single_channel_delta_under_threshold_is_clean() { + let a = vec![10_u8, 20, 30, 255]; + let b = vec![12_u8, 20, 30, 255]; + let report = run(extent(1, 1), &a, &b, PixelDiffThreshold::new(4.0 / 255.0)); + assert!(report.is_clean()); + } + + #[test] + fn over_threshold_reports_worst_pixel_coords() { + let a = vec![0_u8, 0, 0, 255, 200, 0, 0, 255]; + let b = vec![0_u8, 0, 0, 255, 50, 0, 0, 255]; + let report = run(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT); + let Some(worst) = report.worst() else { + panic!("worst pixel present"); + }; + assert_eq!(worst.x(), 1); + assert_eq!(worst.y(), 0); + assert_eq!(worst.max_delta(), 150); + assert_eq!(report.over_threshold(), 1); + } + + #[test] + fn tolerance_maps_to_threshold() { + let t = Tolerance::new(8.0 / 255.0); + let th: PixelDiffThreshold = t.into(); + assert_eq!(th.as_u8(), 8); + } + + #[test] + fn coords_index_by_row_major() { + let a = vec![0_u8; 32]; + let mut b = a.clone(); + let pixel = 5_usize; + b[pixel * 4] = 200; + let report = run(extent(4, 2), &a, &b, PixelDiffThreshold::EXACT); + let Some(worst) = report.worst() else { + panic!("worst present"); + }; + assert_eq!(worst.x(), 1); + assert_eq!(worst.y(), 1); + } + + #[test] + fn shape_mismatch_is_rejected() { + let a = vec![0_u8; 12]; + let b = vec![0_u8; 8]; + let Err(err) = PixelDiff::compare_bytes(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT) + else { + panic!("expected ShapeMismatch"); + }; + let PixelDiffError::ShapeMismatch { + actual, + expected, + required, + } = err + else { + panic!("wrong error variant: {err:?}"); + }; + assert_eq!(actual, 12); + assert_eq!(expected, 8); + assert_eq!(required, 8); + } + + #[test] + fn zero_extent_is_rejected() { + let buf: Vec = Vec::new(); + let Err(err) = + PixelDiff::compare_bytes(extent(0, 4), &buf, &buf, PixelDiffThreshold::EXACT) + else { + panic!("expected ZeroExtent"); + }; + assert!(matches!(err, PixelDiffError::ZeroExtent)); + } +} diff --git a/crates/bone-render/src/gpu.rs b/crates/bone-render/src/gpu.rs new file mode 100644 index 0000000..0cc2000 --- /dev/null +++ b/crates/bone-render/src/gpu.rs @@ -0,0 +1,372 @@ +use crate::camera::{ViewportExtent, ViewportPx}; +use crate::pick::{PickId, PickIndex, Picker}; +use crate::snapshot::{SnapshotFrame, Style}; +use crate::{RenderError, Result}; + +pub(crate) const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +pub(crate) const PICK_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint; +pub(crate) const BYTES_PER_PIXEL: u32 = 4; +pub(crate) const PICK_BYTES_PER_PIXEL: u32 = 4; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct BackendTag(wgpu::Backend); + +impl BackendTag { + #[cfg(test)] + pub(crate) const fn from_backend(backend: wgpu::Backend) -> Self { + Self(backend) + } + + #[must_use] + pub fn backend(self) -> wgpu::Backend { + self.0 + } +} + +impl core::fmt::Display for BackendTag { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +#[derive(Clone, Debug)] +pub struct Capabilities { + adapter_limits: wgpu::Limits, + backend: BackendTag, + adapter_name: String, +} + +impl Capabilities { + pub(crate) fn probe(adapter: &wgpu::Adapter) -> Self { + let info = adapter.get_info(); + Self { + adapter_limits: adapter.limits(), + backend: BackendTag(info.backend), + adapter_name: info.name, + } + } + + #[must_use] + pub fn adapter_limits(&self) -> &wgpu::Limits { + &self.adapter_limits + } + + #[must_use] + pub fn backend(&self) -> BackendTag { + self.backend + } + + #[must_use] + pub fn adapter_name(&self) -> &str { + &self.adapter_name + } +} + +pub struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, + capabilities: Capabilities, +} + +impl Gpu { + pub(crate) fn from_parts( + device: wgpu::Device, + queue: wgpu::Queue, + capabilities: Capabilities, + ) -> Self { + Self { + device, + queue, + capabilities, + } + } + + #[must_use] + pub fn device(&self) -> &wgpu::Device { + &self.device + } + + #[must_use] + pub fn queue(&self) -> &wgpu::Queue { + &self.queue + } + + #[must_use] + pub fn capabilities(&self) -> &Capabilities { + &self.capabilities + } +} + +pub struct OffscreenContext { + gpu: Gpu, + color: wgpu::Texture, + pick: wgpu::Texture, + pick_staging: wgpu::Buffer, + extent: ViewportExtent, +} + +impl OffscreenContext { + pub async fn new(extent: ViewportExtent) -> Result { + if extent.width().value() == 0 || extent.height().value() == 0 { + return Err(RenderError::ZeroExtent); + } + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await?; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("bone-render:offscreen"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + experimental_features: wgpu::ExperimentalFeatures::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::Off, + }) + .await?; + let capabilities = Capabilities::probe(&adapter); + let color = device.create_texture(&wgpu::TextureDescriptor { + label: Some("bone-render:offscreen-color"), + size: texture_size(extent), + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: COLOR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let pick = device.create_texture(&wgpu::TextureDescriptor { + label: Some("bone-render:offscreen-pick"), + size: texture_size(extent), + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: PICK_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let pick_staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bone-render:pick-readback"), + size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + Ok(Self { + gpu: Gpu { + device, + queue, + capabilities, + }, + color, + pick, + pick_staging, + extent, + }) + } + + #[must_use] + pub fn gpu(&self) -> &Gpu { + &self.gpu + } + + #[must_use] + pub fn extent(&self) -> ViewportExtent { + self.extent + } + + #[must_use] + pub const fn color_format(&self) -> wgpu::TextureFormat { + COLOR_FORMAT + } + + #[must_use] + pub fn color_view(&self) -> wgpu::TextureView { + self.color + .create_view(&wgpu::TextureViewDescriptor::default()) + } + + #[must_use] + pub fn pick_view(&self) -> wgpu::TextureView { + self.pick + .create_view(&wgpu::TextureViewDescriptor::default()) + } + + #[must_use] + pub fn picker(&self, index: PickIndex) -> Picker<'_> { + Picker::new( + &self.gpu, + &self.pick, + &self.pick_staging, + self.extent, + index, + ) + } + + pub fn render_clear(&self, style: &Style) -> Result { + self.render(|encoder, color_view, pick_view| { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bone-render:clear"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(style.background().into()), + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: pick_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(pick_clear_color()), + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + }) + } + + pub(crate) fn render(&self, mut build_passes: F) -> Result + where + F: FnMut(&mut wgpu::CommandEncoder, &wgpu::TextureView, &wgpu::TextureView), + { + let mut encoder = self + .gpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bone-render:encoder"), + }); + let color_view = self.color_view(); + let pick_view = self.pick_view(); + build_passes(&mut encoder, &color_view, &pick_view); + let padded_bpr = padded_bytes_per_row(self.extent.width()); + let staging = self.gpu.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bone-render:readback"), + size: u64::from(padded_bpr) * u64::from(self.extent.height().value()), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.color, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bpr), + rows_per_image: Some(self.extent.height().value()), + }, + }, + texture_size(self.extent), + ); + self.gpu.queue.submit(Some(encoder.finish())); + let rgba = read_staging(&self.gpu.device, &staging, self.extent, padded_bpr)?; + Ok(SnapshotFrame::new( + self.extent, + rgba, + self.gpu.capabilities.backend(), + )) + } +} + +pub(crate) fn clear_pick_attachment( + encoder: &mut wgpu::CommandEncoder, + pick_view: &wgpu::TextureView, +) { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bone-render:pick-clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: pick_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(pick_clear_color()), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); +} + +fn pick_clear_color() -> wgpu::Color { + wgpu::Color { + r: f64::from(PickId::NONE.raw()), + g: 0.0, + b: 0.0, + a: 0.0, + } +} + +fn texture_size(extent: ViewportExtent) -> wgpu::Extent3d { + wgpu::Extent3d { + width: extent.width().value(), + height: extent.height().value(), + depth_or_array_layers: 1, + } +} + +pub(crate) fn padded_bytes_per_row(width: ViewportPx) -> u32 { + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let w = width.value(); + debug_assert!( + w <= u32::MAX / BYTES_PER_PIXEL, + "viewport width too large for padded row calc" + ); + let raw = w * BYTES_PER_PIXEL; + raw.div_ceil(align) * align +} + +fn read_staging( + device: &wgpu::Device, + buffer: &wgpu::Buffer, + extent: ViewportExtent, + padded_bpr: u32, +) -> Result> { + let slice = buffer.slice(..); + let (tx, rx) = + std::sync::mpsc::sync_channel::>(1); + slice.map_async(wgpu::MapMode::Read, move |res| { + let _ = tx.send(res); + }); + device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(RenderError::Poll)?; + match rx.try_recv() { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(RenderError::Map(e)), + Err(_) => return Err(RenderError::MapMissing), + } + let row_bytes = extent.width().value() * BYTES_PER_PIXEL; + let rgba: Vec = { + let view = slice.get_mapped_range(); + (0..extent.height().value()) + .flat_map(|y| { + let start = (y * padded_bpr) as usize; + let end = start + row_bytes as usize; + view[start..end].iter().copied() + }) + .collect() + }; + buffer.unmap(); + Ok(rgba) +} diff --git a/crates/bone-render/src/lib.rs b/crates/bone-render/src/lib.rs new file mode 100644 index 0000000..de04dad --- /dev/null +++ b/crates/bone-render/src/lib.rs @@ -0,0 +1,105 @@ +pub mod camera; +pub mod diff; +pub mod gpu; +pub mod pick; +pub mod pipelines; +pub mod scene; +pub mod snapshot; +pub mod surface; + +pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx}; +pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; +pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; +pub use pick::{EntityKindTag, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker}; +pub use pipelines::{ArcPipeline, GridPipeline, LinesPipeline}; +pub use scene::{SceneArc, SceneCircle, SceneLine, ScenePoint, SketchScene}; +pub use snapshot::{ + ClearColor, GridStyle, SnapshotFrame, StrokeStyle, Style, decode_png, encode_png, +}; +pub use surface::{SurfaceContext, SurfaceError}; + +#[derive(Debug, thiserror::Error)] +pub enum RenderError { + #[error("no wgpu adapter matched the offscreen request: {0}")] + NoAdapter(#[from] wgpu::RequestAdapterError), + #[error("wgpu device request failed: {0}")] + Device(#[from] wgpu::RequestDeviceError), + #[error("wgpu poll failed: {0}")] + Poll(wgpu::PollError), + #[error("wgpu buffer map failed: {0}")] + Map(wgpu::BufferAsyncError), + #[error("wgpu buffer map callback did not fire after poll")] + MapMissing, + #[error("png encode failed: {0}")] + PngEncode(#[from] png::EncodingError), + #[error("png decode failed: {0}")] + PngDecode(#[from] png::DecodingError), + #[error("png format unsupported: color={color_type:?}, depth={bit_depth:?}, require rgba8")] + PngFormat { + color_type: png::ColorType, + bit_depth: png::BitDepth, + }, + #[error("viewport dimension is zero")] + ZeroExtent, + #[error("pick id construction failed: {0}")] + PickId(#[from] PickIdError), + #[error("pick query {query} outside viewport {extent}")] + PickOutOfBounds { + query: PickQuery, + extent: ViewportExtent, + }, +} + +pub type Result = core::result::Result; + +#[derive(Debug)] +pub struct SketchRenderer { + grid: GridPipeline, + arcs: ArcPipeline, + lines: LinesPipeline, +} + +impl SketchRenderer { + #[must_use] + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { + Self { + grid: GridPipeline::new(gpu, color_format), + arcs: ArcPipeline::new(gpu, color_format), + lines: LinesPipeline::new(gpu, color_format), + } + } + + pub fn encode_passes( + &self, + encoder: &mut wgpu::CommandEncoder, + color_view: &wgpu::TextureView, + pick_view: &wgpu::TextureView, + scene: &SketchScene, + camera: Camera2, + style: &Style, + ) { + self.grid.draw(encoder, color_view, camera, style); + gpu::clear_pick_attachment(encoder, pick_view); + self.arcs + .draw(encoder, color_view, pick_view, camera, style, scene); + self.lines + .draw(encoder, color_view, pick_view, camera, style, scene); + } + + pub fn render( + &self, + ctx: &OffscreenContext, + scene: &SketchScene, + camera: Camera2, + style: &Style, + ) -> Result { + debug_assert_eq!( + camera.extent(), + ctx.extent(), + "camera extent must match offscreen context extent", + ); + ctx.render(|encoder, color_view, pick_view| { + self.encode_passes(encoder, color_view, pick_view, scene, camera, style); + }) + } +} diff --git a/crates/bone-render/src/pick.rs b/crates/bone-render/src/pick.rs new file mode 100644 index 0000000..16fcc92 --- /dev/null +++ b/crates/bone-render/src/pick.rs @@ -0,0 +1,670 @@ +use bone_types::{SketchDimensionId, SketchEntityId, SketchRelationId}; +use slotmap::Key; +use std::collections::HashMap; +use std::collections::hash_map::Entry; + +use crate::camera::{ViewportExtent, ViewportPx}; +use crate::gpu::{Gpu, PICK_BYTES_PER_PIXEL}; +use crate::{RenderError, Result}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum EntityKindTag { + Point = 0, + Line = 1, + Arc = 2, + Circle = 3, + Relation = 4, + Dimension = 5, +} + +impl EntityKindTag { + #[must_use] + pub const fn bits(self) -> u8 { + self as u8 + } + + #[must_use] + pub const fn from_bits(bits: u8) -> Option { + match bits { + 0 => Some(Self::Point), + 1 => Some(Self::Line), + 2 => Some(Self::Arc), + 3 => Some(Self::Circle), + 4 => Some(Self::Relation), + 5 => Some(Self::Dimension), + _ => None, + } + } +} + +impl core::fmt::Display for EntityKindTag { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let name = match self { + Self::Point => "point", + Self::Line => "line", + Self::Arc => "arc", + Self::Circle => "circle", + Self::Relation => "relation", + Self::Dimension => "dimension", + }; + f.write_str(name) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum PickedItem { + Point(SketchEntityId), + Line(SketchEntityId), + Arc(SketchEntityId), + Circle(SketchEntityId), + Relation(SketchRelationId), + Dimension(SketchDimensionId), +} + +impl PickedItem { + #[must_use] + pub const fn tag(self) -> EntityKindTag { + match self { + Self::Point(_) => EntityKindTag::Point, + Self::Line(_) => EntityKindTag::Line, + Self::Arc(_) => EntityKindTag::Arc, + Self::Circle(_) => EntityKindTag::Circle, + Self::Relation(_) => EntityKindTag::Relation, + Self::Dimension(_) => EntityKindTag::Dimension, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PickIdError { + #[error("slotmap slot {slot:#x} exceeds {limit:#x}, the 28-bit PickId index field")] + SlotIndexOverflow { slot: u32, limit: u32 }, + #[error("two entries collapse to packed slot index {0:#x} in the PickIndex")] + SlotIndexCollision(u32), +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct PickId(u32); + +impl PickId { + pub const TAG_SHIFT: u32 = 28; + pub const INDEX_MASK: u32 = 0x0FFF_FFFF; + pub const TAG_MASK: u32 = 0xF000_0000; + pub const NONE: Self = Self(u32::MAX); + + #[must_use] + pub const fn from_raw(bits: u32) -> Self { + Self(bits) + } + + #[must_use] + pub const fn raw(self) -> u32 { + self.0 + } + + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub const fn tag(self) -> Option { + EntityKindTag::from_bits((self.0 >> Self::TAG_SHIFT) as u8) + } + + #[must_use] + pub const fn index(self) -> u32 { + self.0 & Self::INDEX_MASK + } + + pub fn point(id: SketchEntityId) -> Result { + Self::from_keyed(EntityKindTag::Point, id) + } + + pub fn line(id: SketchEntityId) -> Result { + Self::from_keyed(EntityKindTag::Line, id) + } + + pub fn arc(id: SketchEntityId) -> Result { + Self::from_keyed(EntityKindTag::Arc, id) + } + + pub fn circle(id: SketchEntityId) -> Result { + Self::from_keyed(EntityKindTag::Circle, id) + } + + pub fn relation(id: SketchRelationId) -> Result { + Self::from_keyed(EntityKindTag::Relation, id) + } + + pub fn dimension(id: SketchDimensionId) -> Result { + Self::from_keyed(EntityKindTag::Dimension, id) + } + + #[must_use] + pub fn unpack(self, index: &PickIndex) -> Option { + let tag = self.tag()?; + let slot = self.index(); + match tag { + EntityKindTag::Point => index.lookup_entity(slot, tag).map(PickedItem::Point), + EntityKindTag::Line => index.lookup_entity(slot, tag).map(PickedItem::Line), + EntityKindTag::Arc => index.lookup_entity(slot, tag).map(PickedItem::Arc), + EntityKindTag::Circle => index.lookup_entity(slot, tag).map(PickedItem::Circle), + EntityKindTag::Relation => index + .relations + .get(&slot) + .copied() + .map(PickedItem::Relation), + EntityKindTag::Dimension => index + .dimensions + .get(&slot) + .copied() + .map(PickedItem::Dimension), + } + } + + fn from_keyed(tag: EntityKindTag, id: K) -> Result { + slot_index(id).map(|idx| Self::pack(tag, idx)) + } + + const fn pack(tag: EntityKindTag, idx: u32) -> Self { + debug_assert!(idx <= Self::INDEX_MASK); + Self(((tag.bits() as u32) << Self::TAG_SHIFT) | idx) + } +} + +const _: () = assert!(PickId::TAG_MASK | PickId::INDEX_MASK == u32::MAX); +const _: () = assert!(PickId::TAG_MASK & PickId::INDEX_MASK == 0); +const _: () = assert!(PickId::TAG_MASK == 0xFu32 << PickId::TAG_SHIFT); +const _: () = assert!(PickId::NONE.tag().is_none()); + +impl core::fmt::Display for PickId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self.tag() { + Some(tag) => write!(f, "{tag}#{}", self.index()), + None => write!(f, "invalid#{:08x}", self.0), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct PickQuery { + x: ViewportPx, + y: ViewportPx, +} + +impl PickQuery { + #[must_use] + pub const fn new(x: ViewportPx, y: ViewportPx) -> Self { + Self { x, y } + } + + #[must_use] + pub const fn x(self) -> ViewportPx { + self.x + } + + #[must_use] + pub const fn y(self) -> ViewportPx { + self.y + } +} + +impl core::fmt::Display for PickQuery { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +pub struct Picker<'a> { + gpu: &'a Gpu, + pick: &'a wgpu::Texture, + staging: &'a wgpu::Buffer, + extent: ViewportExtent, + index: PickIndex, +} + +impl<'a> Picker<'a> { + pub(crate) fn new( + gpu: &'a Gpu, + pick: &'a wgpu::Texture, + staging: &'a wgpu::Buffer, + extent: ViewportExtent, + index: PickIndex, + ) -> Self { + Self { + gpu, + pick, + staging, + extent, + index, + } + } + + pub fn raw_at(&self, query: PickQuery) -> Result { + let width = self.extent.width().value(); + let height = self.extent.height().value(); + if query.x.value() >= width || query.y.value() >= height { + return Err(RenderError::PickOutOfBounds { + query, + extent: self.extent, + }); + } + let device = self.gpu.device(); + let queue = self.gpu.queue(); + let padded_bpr = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bone-render:pick-encoder"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: self.pick, + mip_level: 0, + origin: wgpu::Origin3d { + x: query.x.value(), + y: query.y.value(), + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: self.staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bpr), + rows_per_image: Some(1), + }, + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + queue.submit(Some(encoder.finish())); + + let slice = self.staging.slice(..); + let (tx, rx) = + std::sync::mpsc::sync_channel::>(1); + slice.map_async(wgpu::MapMode::Read, move |res| { + let _ = tx.send(res); + }); + device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(RenderError::Poll)?; + match rx.try_recv() { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(RenderError::Map(e)), + Err(_) => return Err(RenderError::MapMissing), + } + let raw = { + let view = slice.get_mapped_range(); + debug_assert!(view.len() >= PICK_BYTES_PER_PIXEL as usize); + u32::from_le_bytes([view[0], view[1], view[2], view[3]]) + }; + self.staging.unmap(); + Ok(PickId::from_raw(raw)) + } + + pub fn at(&self, query: PickQuery) -> Result> { + let pid = self.raw_at(query)?; + Ok(pid.unpack(&self.index)) + } +} + +impl core::fmt::Debug for Picker<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Picker") + .field("extent", &self.extent) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Default, Clone)] +pub struct PickIndex { + entities: HashMap, + relations: HashMap, + dimensions: HashMap, +} + +impl PickIndex { + pub fn build( + entities: EI, + relations: RI, + dimensions: DI, + ) -> Result + where + EI: IntoIterator, + RI: IntoIterator, + DI: IntoIterator, + { + Ok(Self { + entities: try_collect_keyed(entities, |(id, _)| slot_index(*id))?, + relations: try_collect_keyed(relations, |id| slot_index(*id))?, + dimensions: try_collect_keyed(dimensions, |id| slot_index(*id))?, + }) + } + + fn lookup_entity(&self, slot: u32, expected: EntityKindTag) -> Option { + self.entities + .get(&slot) + .filter(|(_, kind)| *kind == expected) + .map(|(id, _)| *id) + } +} + +#[allow(clippy::cast_possible_truncation)] +fn slot_index(key: K) -> Result { + let slot = (key.data().as_ffi() & 0xFFFF_FFFF_u64) as u32; + if slot > PickId::INDEX_MASK { + return Err(PickIdError::SlotIndexOverflow { + slot, + limit: PickId::INDEX_MASK, + }); + } + Ok(slot) +} + +fn try_collect_keyed(iter: I, slot_of: F) -> Result, PickIdError> +where + I: IntoIterator, + F: Fn(&V) -> Result, +{ + iter.into_iter().try_fold(HashMap::new(), |mut acc, item| { + let slot = slot_of(&item)?; + match acc.entry(slot) { + Entry::Vacant(v) => { + v.insert(item); + Ok(acc) + } + Entry::Occupied(_) => Err(PickIdError::SlotIndexCollision(slot)), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use slotmap::{KeyData, SlotMap}; + + #[derive(Copy, Clone, Debug)] + enum TagKind { + Point, + Line, + Arc, + Circle, + Relation, + Dimension, + } + + impl TagKind { + fn entity_kind(self) -> EntityKindTag { + match self { + Self::Point | Self::Relation | Self::Dimension => EntityKindTag::Point, + Self::Line => EntityKindTag::Line, + Self::Arc => EntityKindTag::Arc, + Self::Circle => EntityKindTag::Circle, + } + } + } + + fn arb_tag() -> impl Strategy { + prop_oneof![ + Just(TagKind::Point), + Just(TagKind::Line), + Just(TagKind::Arc), + Just(TagKind::Circle), + Just(TagKind::Relation), + Just(TagKind::Dimension), + ] + } + + type TestSlots = ( + SlotMap, + SlotMap, + SlotMap, + ); + + fn populate_uniform( + n: usize, + entity_kind: EntityKindTag, + ) -> ( + Vec, + Vec, + Vec, + TestSlots, + ) { + let mut entities = SlotMap::with_key(); + let mut relations = SlotMap::with_key(); + let mut dimensions = SlotMap::with_key(); + let e: Vec<_> = (0..n).map(|_| entities.insert(entity_kind)).collect(); + let r: Vec<_> = (0..n).map(|_| relations.insert(())).collect(); + let d: Vec<_> = (0..n).map(|_| dimensions.insert(())).collect(); + (e, r, d, (entities, relations, dimensions)) + } + + fn ok(result: Result) -> T { + let Ok(value) = result else { + panic!("test fixture should fit in PickId field"); + }; + value + } + + fn build_index(slots: &TestSlots) -> PickIndex { + ok(PickIndex::build( + slots.0.iter().map(|(k, kind)| (k, *kind)), + slots.1.keys(), + slots.2.keys(), + )) + } + + fn build_case( + tag: TagKind, + e: &[SketchEntityId], + r: &[SketchRelationId], + d: &[SketchDimensionId], + target: usize, + ) -> (PickId, PickedItem) { + match tag { + TagKind::Point => (ok(PickId::point(e[target])), PickedItem::Point(e[target])), + TagKind::Line => (ok(PickId::line(e[target])), PickedItem::Line(e[target])), + TagKind::Arc => (ok(PickId::arc(e[target])), PickedItem::Arc(e[target])), + TagKind::Circle => (ok(PickId::circle(e[target])), PickedItem::Circle(e[target])), + TagKind::Relation => ( + ok(PickId::relation(r[target])), + PickedItem::Relation(r[target]), + ), + TagKind::Dimension => ( + ok(PickId::dimension(d[target])), + PickedItem::Dimension(d[target]), + ), + } + } + + fn forget(slots: &mut TestSlots, item: PickedItem) { + match item { + PickedItem::Point(k) + | PickedItem::Line(k) + | PickedItem::Arc(k) + | PickedItem::Circle(k) => { + slots.0.remove(k); + } + PickedItem::Relation(k) => { + slots.1.remove(k); + } + PickedItem::Dimension(k) => { + slots.2.remove(k); + } + } + } + + #[test] + fn tag_bits_roundtrip() { + (0u8..6).for_each(|bits| { + let Some(tag) = EntityKindTag::from_bits(bits) else { + panic!("tag {bits} should decode"); + }; + assert_eq!(tag.bits(), bits); + }); + } + + #[test] + fn invalid_tag_bits_return_none() { + (6u8..16).for_each(|bits| { + assert!(EntityKindTag::from_bits(bits).is_none()); + }); + } + + #[test] + fn raw_roundtrip() { + let mut entities: SlotMap = SlotMap::with_key(); + let k = entities.insert(()); + let pid = ok(PickId::point(k)); + assert_eq!(PickId::from_raw(pid.raw()), pid); + } + + #[test] + fn tag_and_index_split_cleanly() { + let mut entities: SlotMap = SlotMap::with_key(); + let k = entities.insert(()); + let pid = ok(PickId::line(k)); + assert_eq!(pid.tag(), Some(EntityKindTag::Line)); + assert_eq!(pid.raw() & PickId::TAG_MASK, 1u32 << PickId::TAG_SHIFT); + assert_eq!(pid.index(), pid.raw() & PickId::INDEX_MASK); + } + + #[test] + fn display_renders_tag_and_index() { + let mut entities: SlotMap = SlotMap::with_key(); + let k = entities.insert(()); + let pid = ok(PickId::arc(k)); + assert!(format!("{pid}").starts_with("arc#")); + } + + #[test] + fn display_renders_invalid_tag() { + let raw = 0xF000_0000u32; + let pid = PickId::from_raw(raw); + assert_eq!(pid.tag(), None); + assert_eq!(format!("{pid}"), "invalid#f0000000"); + } + + #[test] + fn max_index_roundtrip() { + let raw = + (u32::from(EntityKindTag::Point.bits()) << PickId::TAG_SHIFT) | PickId::INDEX_MASK; + let pid = PickId::from_raw(raw); + assert_eq!(pid.tag(), Some(EntityKindTag::Point)); + assert_eq!(pid.index(), PickId::INDEX_MASK); + assert_eq!(pid.raw(), raw); + } + + #[test] + fn slot_overflow_is_rejected_by_constructor() { + let bad_slot: u32 = PickId::INDEX_MASK + 1; + let key = SketchEntityId::from(KeyData::from_ffi((1u64 << 32) | u64::from(bad_slot))); + let result = PickId::point(key); + assert!(matches!( + result, + Err(PickIdError::SlotIndexOverflow { slot, limit }) + if slot == bad_slot && limit == PickId::INDEX_MASK + )); + } + + #[test] + fn slot_overflow_is_rejected_by_pick_index_build() { + let bad_slot: u32 = PickId::INDEX_MASK + 1; + let key = SketchEntityId::from(KeyData::from_ffi((1u64 << 32) | u64::from(bad_slot))); + let result = PickIndex::build( + [(key, EntityKindTag::Point)], + std::iter::empty::(), + std::iter::empty::(), + ); + assert!(matches!(result, Err(PickIdError::SlotIndexOverflow { .. }))); + } + + #[test] + fn none_sentinel_is_invalid() { + assert_eq!(PickId::NONE.raw(), u32::MAX); + assert_eq!(PickId::NONE.tag(), None); + assert_eq!(PickId::NONE.unpack(&PickIndex::default()), None); + } + + #[test] + fn collision_in_pick_index_build_is_rejected() { + let mut entities: SlotMap = SlotMap::with_key(); + let k = entities.insert(EntityKindTag::Point); + let result = PickIndex::build( + [(k, EntityKindTag::Point), (k, EntityKindTag::Line)], + std::iter::empty::(), + std::iter::empty::(), + ); + assert!(matches!(result, Err(PickIdError::SlotIndexCollision(_)))); + } + + fn build_entity_index(entities: &SlotMap) -> PickIndex { + ok(PickIndex::build( + entities.iter().map(|(k, kind)| (k, *kind)), + std::iter::empty::(), + std::iter::empty::(), + )) + } + + #[test] + fn slot_reuse_with_same_kind_returns_new_key() { + let mut entities: SlotMap = SlotMap::with_key(); + let k1 = entities.insert(EntityKindTag::Point); + let pid = ok(PickId::point(k1)); + entities.remove(k1); + let k2 = entities.insert(EntityKindTag::Point); + assert_ne!(k1, k2); + let index = build_entity_index(&entities); + assert_eq!(pid.unpack(&index), Some(PickedItem::Point(k2))); + } + + #[test] + fn slot_reuse_with_different_kind_returns_none() { + let mut entities: SlotMap = SlotMap::with_key(); + let k1 = entities.insert(EntityKindTag::Point); + let pid = ok(PickId::point(k1)); + entities.remove(k1); + let _k2 = entities.insert(EntityKindTag::Line); + let index = build_entity_index(&entities); + assert_eq!(pid.unpack(&index), None); + } + + proptest! { + #[test] + fn unpack_roundtrip_across_all_populated( + tag in arb_tag(), + n in 1usize..32, + target in 0usize..32, + ) { + let target = target % n; + let (e, r, d, slots) = populate_uniform(n, tag.entity_kind()); + let (pid, expected) = build_case(tag, &e, &r, &d, target); + let index = build_index(&slots); + prop_assert_eq!(pid.unpack(&index), Some(expected)); + prop_assert_eq!(pid.tag(), Some(expected.tag())); + } + + #[test] + fn removed_slot_unpacks_to_none( + tag in arb_tag(), + n in 1usize..32, + target in 0usize..32, + ) { + let target = target % n; + let (e, r, d, mut slots) = populate_uniform(n, tag.entity_kind()); + let (pid, item) = build_case(tag, &e, &r, &d, target); + forget(&mut slots, item); + let index = build_index(&slots); + prop_assert!(pid.unpack(&index).is_none()); + } + + #[test] + fn invalid_tag_bits_unpack_to_none(bits in 6u8..16, idx in 0u32..=PickId::INDEX_MASK) { + let raw = (u32::from(bits) << PickId::TAG_SHIFT) | (idx & PickId::INDEX_MASK); + let pid = PickId::from_raw(raw); + prop_assert!(pid.unpack(&PickIndex::default()).is_none()); + } + } +} diff --git a/crates/bone-render/src/pipelines/arc.rs b/crates/bone-render/src/pipelines/arc.rs new file mode 100644 index 0000000..f4e4949 --- /dev/null +++ b/crates/bone-render/src/pipelines/arc.rs @@ -0,0 +1,317 @@ +use bone_types::{Angle, Length, Point2}; +use uom::si::angle::radian; +use uom::si::length::millimeter; +use wgpu::util::DeviceExt; + +use crate::camera::Camera2; +use crate::gpu::{Gpu, PICK_FORMAT}; +use crate::pipelines::{CONSTRUCTION_BIT, FRAME_UNIFORM_SIZE, build_frame_uniform}; +use crate::scene::{SceneArc, SceneCircle, SketchScene}; +use crate::snapshot::Style; + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct ArcInstance { + center: [f32; 2], + radius_mm: f32, + half_width_px: f32, + start_rad: f32, + sweep_rad: f32, + aabb_min_mm: [f32; 2], + aabb_max_mm: [f32; 2], + pick_id: u32, + style_bits: u32, +} + +const INSTANCE_STRIDE: u64 = core::mem::size_of::() as u64; + +pub struct ArcPipeline { + device: wgpu::Device, + queue: wgpu::Queue, + pipeline: wgpu::RenderPipeline, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, +} + +impl ArcPipeline { + #[must_use] + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { + let device = gpu.device().clone(); + let queue = gpu.queue().clone(); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("bone-render:arc-shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("arc.wgsl").into()), + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("bone-render:arc-bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(FRAME_UNIFORM_SIZE), + }, + count: None, + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("bone-render:arc-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bone-render:arc-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: INSTANCE_STRIDE, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &INSTANCE_ATTRS, + }], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[ + Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: PICK_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bone-render:arc-uniform"), + size: FRAME_UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bone-render:arc-bg"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + Self { + device, + queue, + pipeline, + uniform_buffer, + bind_group, + } + } + + pub fn draw( + &self, + encoder: &mut wgpu::CommandEncoder, + color_view: &wgpu::TextureView, + pick_view: &wgpu::TextureView, + camera: Camera2, + style: &Style, + scene: &SketchScene, + ) { + let instances = build_instances(scene, style); + if instances.is_empty() { + return; + } + let uniform = build_frame_uniform(camera, style); + self.queue + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); + let instance_buffer = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("bone-render:arc-instances"), + contents: bytemuck::cast_slice(&instances), + usage: wgpu::BufferUsages::VERTEX, + }); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bone-render:arc-pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: pick_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, instance_buffer.slice(..)); + let len = instances.len(); + let Ok(count) = u32::try_from(len) else { + panic!("arc instance count {len} exceeds u32::MAX"); + }; + pass.draw(0..6, 0..count); + } +} + +impl core::fmt::Debug for ArcPipeline { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ArcPipeline").finish_non_exhaustive() + } +} + +const INSTANCE_ATTRS: [wgpu::VertexAttribute; 9] = wgpu::vertex_attr_array![ + 0 => Float32x2, + 1 => Float32, + 2 => Float32, + 3 => Float32, + 4 => Float32, + 5 => Float32x2, + 6 => Float32x2, + 7 => Uint32, + 8 => Uint32, +]; + +const FULL_CIRCLE_SWEEP: f32 = core::f32::consts::TAU; + +fn build_instances(scene: &SketchScene, style: &Style) -> Vec { + let arcs = scene.arcs().iter().map(|a| arc_instance(*a, style)); + let circles = scene.circles().iter().map(|c| circle_instance(*c, style)); + arcs.chain(circles).collect() +} + +#[allow(clippy::cast_possible_truncation)] +fn arc_instance(arc: SceneArc, style: &Style) -> ArcInstance { + let (cx, cy) = arc.center().coords_mm(); + let radius_mm = arc.radius().get::() as f32; + let start_rad = arc.start_angle().get::() as f32; + let sweep_rad = arc.sweep_angle().get::() as f32; + let (aabb_min_mm, aabb_max_mm) = arc_aabb_offsets_mm( + arc.center(), + arc.radius(), + arc.start_angle(), + arc.sweep_angle(), + ); + let bits = if arc.for_construction() { + CONSTRUCTION_BIT + } else { + 0 + }; + ArcInstance { + center: [cx as f32, cy as f32], + radius_mm, + half_width_px: style.strokes().stroke_width_px() * 0.5, + start_rad, + sweep_rad, + aabb_min_mm, + aabb_max_mm, + pick_id: arc.pick().raw(), + style_bits: bits, + } +} + +#[allow(clippy::cast_possible_truncation)] +fn circle_instance(circle: SceneCircle, style: &Style) -> ArcInstance { + let (cx, cy) = circle.center().coords_mm(); + let radius_mm = circle.radius().get::() as f32; + let bits = if circle.for_construction() { + CONSTRUCTION_BIT + } else { + 0 + }; + ArcInstance { + center: [cx as f32, cy as f32], + radius_mm, + half_width_px: style.strokes().stroke_width_px() * 0.5, + start_rad: 0.0, + sweep_rad: FULL_CIRCLE_SWEEP, + aabb_min_mm: [-radius_mm, -radius_mm], + aabb_max_mm: [radius_mm, radius_mm], + pick_id: circle.pick().raw(), + style_bits: bits, + } +} + +#[allow(clippy::cast_possible_truncation)] +fn arc_aabb_offsets_mm( + center: Point2, + radius: Length, + start: Angle, + sweep: Angle, +) -> ([f32; 2], [f32; 2]) { + let aabb = bone_kernel::arc_bounding_box(center, radius, start, sweep); + let (cx, cy) = center.coords_mm(); + let (mnx, mny) = aabb.min().coords_mm(); + let (mxx, mxy) = aabb.max().coords_mm(); + ( + [(mnx - cx) as f32, (mny - cy) as f32], + [(mxx - cx) as f32, (mxy - cy) as f32], + ) +} + +#[cfg(test)] +mod tests { + use super::arc_aabb_offsets_mm; + use bone_types::{Angle, Length, Point2}; + use core::f64::consts::FRAC_PI_2; + use uom::si::angle::radian; + use uom::si::length::millimeter; + + #[test] + fn quarter_arc_at_origin_offsets_to_unit_quadrant() { + let (min, max) = arc_aabb_offsets_mm( + Point2::from_mm(0.0, 0.0), + Length::new::(1.0), + Angle::new::(0.0), + Angle::new::(FRAC_PI_2), + ); + assert!(min[0].abs() < 1e-5 && min[1].abs() < 1e-5); + assert!((max[0] - 1.0).abs() < 1e-5 && (max[1] - 1.0).abs() < 1e-5); + } + + #[test] + fn translated_center_yields_the_same_relative_offsets() { + let (min, max) = arc_aabb_offsets_mm( + Point2::from_mm(10.0, -5.0), + Length::new::(1.0), + Angle::new::(0.0), + Angle::new::(FRAC_PI_2), + ); + assert!(min[0].abs() < 1e-5 && min[1].abs() < 1e-5); + assert!((max[0] - 1.0).abs() < 1e-5 && (max[1] - 1.0).abs() < 1e-5); + } +} diff --git a/crates/bone-render/src/pipelines/arc.wgsl b/crates/bone-render/src/pipelines/arc.wgsl new file mode 100644 index 0000000..c7f8f5d --- /dev/null +++ b/crates/bone-render/src/pipelines/arc.wgsl @@ -0,0 +1,141 @@ +struct Frame { + clip_from_world: mat4x4, + stroke_color: vec4, + construction: vec4, + pixels_per_mm: f32, + dash_period_px: f32, + dash_on_ratio: f32, + _pad: f32, +}; + +struct Instance { + @location(0) center: vec2, + @location(1) radius_mm: f32, + @location(2) half_width_px: f32, + @location(3) start_rad: f32, + @location(4) sweep_rad: f32, + @location(5) aabb_min_mm: vec2, + @location(6) aabb_max_mm: vec2, + @location(7) pick_id: u32, + @location(8) style_bits: u32, +}; + +struct VsOut { + @builtin(position) clip: vec4, + @location(0) local_px: vec2, + @location(1) radius_px: f32, + @location(2) half_width_px: f32, + @location(3) start_rad: f32, + @location(4) sweep_rad: f32, + @location(5) @interpolate(flat) style_bits: u32, + @location(6) @interpolate(flat) pick_id: u32, +}; + +struct FsOut { + @location(0) color: vec4, + @location(1) pick_id: u32, +}; + +@group(0) @binding(0) var u: Frame; + +const CORNERS: array, 6> = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(0.0, 1.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), +); + +const TWO_PI: f32 = 6.283185307179586; +const CONSTRUCTION_BIT: u32 = 1u; + +@vertex +fn vs(@builtin(vertex_index) vid: u32, inst: Instance) -> VsOut { + var corners = CORNERS; + let corner = corners[vid]; + let expand_mm = (inst.half_width_px + 1.0) / u.pixels_per_mm; + let bbox_min = inst.aabb_min_mm - vec2(expand_mm, expand_mm); + let bbox_max = inst.aabb_max_mm + vec2(expand_mm, expand_mm); + let offset_mm = mix(bbox_min, bbox_max, corner); + let world_mm = inst.center + offset_mm; + let clip = u.clip_from_world * vec4(world_mm, 0.0, 1.0); + + var out: VsOut; + out.clip = clip; + out.local_px = offset_mm * u.pixels_per_mm; + out.radius_px = inst.radius_mm * u.pixels_per_mm; + out.half_width_px = inst.half_width_px; + out.start_rad = inst.start_rad; + out.sweep_rad = inst.sweep_rad; + out.style_bits = inst.style_bits; + out.pick_id = inst.pick_id; + return out; +} + +struct ArcPoint { + distance_px: f32, + along_px: f32, +}; + +fn arc_sample(p: vec2, radius_px: f32, start_rad: f32, sweep_rad: f32) -> ArcPoint { + let r_px = length(p); + let full_circle = sweep_rad >= TWO_PI - 1.0e-4; + if (full_circle) { + let theta = atan2(p.y, p.x); + var delta = theta - start_rad; + delta = delta - floor(delta / TWO_PI) * TWO_PI; + var out: ArcPoint; + out.distance_px = abs(r_px - radius_px); + out.along_px = delta * radius_px; + return out; + } + let theta = atan2(p.y, p.x); + var delta = theta - start_rad; + delta = delta - floor(delta / TWO_PI) * TWO_PI; + var theta_nearest: f32; + var along_rad: f32; + if (delta <= sweep_rad) { + theta_nearest = theta; + along_rad = delta; + } else { + let to_end = delta - sweep_rad; + let to_start = TWO_PI - delta; + if (to_end < to_start) { + theta_nearest = start_rad + sweep_rad; + along_rad = sweep_rad; + } else { + theta_nearest = start_rad; + along_rad = 0.0; + } + } + let nearest = vec2(cos(theta_nearest), sin(theta_nearest)) * radius_px; + var out: ArcPoint; + out.distance_px = length(p - nearest); + out.along_px = along_rad * radius_px; + return out; +} + +@fragment +fn fs(in: VsOut) -> FsOut { + let sample = arc_sample(in.local_px, in.radius_px, in.start_rad, in.sweep_rad); + let aa = 0.5; + let coverage = 1.0 - smoothstep(in.half_width_px - aa, in.half_width_px + aa, sample.distance_px); + if (coverage <= 0.0) { + discard; + } + + let is_construction = (in.style_bits & CONSTRUCTION_BIT) != 0u; + let dash_visible = !is_construction + || u.dash_period_px <= 0.0 + || fract(sample.along_px / u.dash_period_px) <= u.dash_on_ratio; + let color_coverage = select(0.0, coverage, dash_visible); + + let base = select(u.stroke_color, u.construction, is_construction); + let a = base.a * color_coverage; + + var out: FsOut; + out.color = vec4(base.rgb * a, a); + out.pick_id = in.pick_id; + return out; +} diff --git a/crates/bone-render/src/pipelines/grid.rs b/crates/bone-render/src/pipelines/grid.rs new file mode 100644 index 0000000..c9b4a52 --- /dev/null +++ b/crates/bone-render/src/pipelines/grid.rs @@ -0,0 +1,176 @@ +use uom::si::length::millimeter; + +use crate::camera::{Camera2, GridSpacing}; +use crate::gpu::Gpu; +use crate::snapshot::Style; + +#[repr(C, align(16))] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct GridUniform { + world_from_clip: [f32; 16], + minor: [f32; 4], + major: [f32; 4], + axis_x: [f32; 4], + axis_y: [f32; 4], + origin: [f32; 4], + viewport: [f32; 2], + minor_spacing: f32, + major_every: f32, + line_width_px: f32, + axis_width_px: f32, + origin_radius_px: f32, + pixels_per_mm: f32, +} + +const UNIFORM_SIZE: u64 = core::mem::size_of::() as u64; + +pub struct GridPipeline { + pipeline: wgpu::RenderPipeline, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, + queue: wgpu::Queue, +} + +impl GridPipeline { + #[must_use] + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { + let device = gpu.device(); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("bone-render:grid-shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("grid.wgsl").into()), + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("bone-render:grid-bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(UNIFORM_SIZE), + }, + count: None, + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("bone-render:grid-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bone-render:grid-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bone-render:grid-uniform"), + size: UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bone-render:grid-bg"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + Self { + pipeline, + uniform_buffer, + bind_group, + queue: gpu.queue().clone(), + } + } + + pub fn draw( + &self, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + camera: Camera2, + style: &Style, + ) { + let spacing = GridSpacing::from_zoom(camera.zoom(), style.grid().minor_spacing_target_px()); + let uniform = build_uniform(camera, spacing, style); + self.queue + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bone-render:grid-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(style.background().into()), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.draw(0..3, 0..1); + } +} + +impl core::fmt::Debug for GridPipeline { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("GridPipeline").finish_non_exhaustive() + } +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +fn build_uniform(camera: Camera2, spacing: GridSpacing, style: &Style) -> GridUniform { + let grid = style.grid(); + let extent = camera.extent(); + GridUniform { + world_from_clip: camera.world_mm_from_clip(), + minor: grid.minor().to_rgba_array(), + major: grid.major().to_rgba_array(), + axis_x: grid.axis_x().to_rgba_array(), + axis_y: grid.axis_y().to_rgba_array(), + origin: grid.origin().to_rgba_array(), + viewport: [ + extent.width().value() as f32, + extent.height().value() as f32, + ], + minor_spacing: spacing.minor().get::() as f32, + major_every: spacing.major_every() as f32, + line_width_px: grid.line_width_px(), + axis_width_px: grid.axis_width_px(), + origin_radius_px: grid.origin_radius_px(), + pixels_per_mm: camera.zoom().value() as f32, + } +} diff --git a/crates/bone-render/src/pipelines/grid.wgsl b/crates/bone-render/src/pipelines/grid.wgsl new file mode 100644 index 0000000..0e3250a --- /dev/null +++ b/crates/bone-render/src/pipelines/grid.wgsl @@ -0,0 +1,76 @@ +struct Grid { + world_from_clip: mat4x4, + minor: vec4, + major: vec4, + axis_x: vec4, + axis_y: vec4, + origin: vec4, + viewport: vec2, + minor_spacing: f32, + major_every: f32, + line_width_px: f32, + axis_width_px: f32, + origin_radius_px: f32, + pixels_per_mm: f32, +}; + +@group(0) @binding(0) var u: Grid; + +@vertex +fn vs(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + var positions = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + return vec4(positions[vid], 0.0, 1.0); +} + +fn line_distance_world(coord: f32, spacing: f32) -> f32 { + let nearest = round(coord / spacing) * spacing; + return abs(coord - nearest); +} + +fn line_alpha(dist_px: f32, half_width_px: f32) -> f32 { + let aa = min(0.5, half_width_px); + return 1.0 - smoothstep(half_width_px - aa, half_width_px + aa, dist_px); +} + +fn over(dst_pma: vec4, src: vec4, coverage: f32) -> vec4 { + let a = src.a * coverage; + let src_pma = vec4(src.rgb * a, a); + return src_pma + dst_pma * (1.0 - a); +} + +@fragment +fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { + let ndc = (pos.xy / u.viewport) * vec2(2.0, -2.0) + vec2(-1.0, 1.0); + let world4 = u.world_from_clip * vec4(ndc, 0.0, 1.0); + let world = world4.xy / world4.w; + + var acc = vec4(0.0, 0.0, 0.0, 0.0); + + let half_line = u.line_width_px * 0.5; + let dx_minor = line_distance_world(world.x, u.minor_spacing) * u.pixels_per_mm; + let dy_minor = line_distance_world(world.y, u.minor_spacing) * u.pixels_per_mm; + let minor_cov = max(line_alpha(dx_minor, half_line), line_alpha(dy_minor, half_line)); + acc = over(acc, u.minor, minor_cov); + + let major_spacing = u.minor_spacing * u.major_every; + let dx_major = line_distance_world(world.x, major_spacing) * u.pixels_per_mm; + let dy_major = line_distance_world(world.y, major_spacing) * u.pixels_per_mm; + let major_cov = max(line_alpha(dx_major, half_line), line_alpha(dy_major, half_line)); + acc = over(acc, u.major, major_cov); + + let axis_half = u.axis_width_px * 0.5; + let ax_y_cov = line_alpha(abs(world.x) * u.pixels_per_mm, axis_half); + acc = over(acc, u.axis_y, ax_y_cov); + let ax_x_cov = line_alpha(abs(world.y) * u.pixels_per_mm, axis_half); + acc = over(acc, u.axis_x, ax_x_cov); + + let d_origin_px = length(world) * u.pixels_per_mm; + let origin_cov = 1.0 - smoothstep(u.origin_radius_px - 1.0, u.origin_radius_px + 1.0, d_origin_px); + acc = over(acc, u.origin, origin_cov); + + return acc; +} diff --git a/crates/bone-render/src/pipelines/lines.rs b/crates/bone-render/src/pipelines/lines.rs new file mode 100644 index 0000000..f8e7a8e --- /dev/null +++ b/crates/bone-render/src/pipelines/lines.rs @@ -0,0 +1,233 @@ +use wgpu::util::DeviceExt; + +use crate::camera::Camera2; +use crate::gpu::{Gpu, PICK_FORMAT}; +use crate::pipelines::{CONSTRUCTION_BIT, FRAME_UNIFORM_SIZE, build_frame_uniform}; +use crate::scene::{SceneLine, ScenePoint, SketchScene}; +use crate::snapshot::Style; + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct LineInstance { + a: [f32; 2], + b: [f32; 2], + half_width_px: f32, + pick_id: u32, + style_bits: u32, +} + +const INSTANCE_STRIDE: u64 = core::mem::size_of::() as u64; + +pub struct LinesPipeline { + device: wgpu::Device, + queue: wgpu::Queue, + pipeline: wgpu::RenderPipeline, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, +} + +impl LinesPipeline { + #[must_use] + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { + let device = gpu.device().clone(); + let queue = gpu.queue().clone(); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("bone-render:lines-shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("lines.wgsl").into()), + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("bone-render:lines-bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(FRAME_UNIFORM_SIZE), + }, + count: None, + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("bone-render:lines-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bone-render:lines-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: INSTANCE_STRIDE, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &INSTANCE_ATTRS, + }], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[ + Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: PICK_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bone-render:lines-uniform"), + size: FRAME_UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bone-render:lines-bg"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + Self { + device, + queue, + pipeline, + uniform_buffer, + bind_group, + } + } + + pub fn draw( + &self, + encoder: &mut wgpu::CommandEncoder, + color_view: &wgpu::TextureView, + pick_view: &wgpu::TextureView, + camera: Camera2, + style: &Style, + scene: &SketchScene, + ) { + let instances = build_instances(scene, style); + if instances.is_empty() { + return; + } + let uniform = build_frame_uniform(camera, style); + self.queue + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); + let instance_buffer = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("bone-render:lines-instances"), + contents: bytemuck::cast_slice(&instances), + usage: wgpu::BufferUsages::VERTEX, + }); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bone-render:lines-pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: pick_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, instance_buffer.slice(..)); + let len = instances.len(); + let Ok(count) = u32::try_from(len) else { + panic!("line instance count {len} exceeds u32::MAX"); + }; + pass.draw(0..6, 0..count); + } +} + +impl core::fmt::Debug for LinesPipeline { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("LinesPipeline").finish_non_exhaustive() + } +} + +const INSTANCE_ATTRS: [wgpu::VertexAttribute; 5] = wgpu::vertex_attr_array![ + 0 => Float32x2, + 1 => Float32x2, + 2 => Float32, + 3 => Uint32, + 4 => Uint32, +]; + +fn build_instances(scene: &SketchScene, style: &Style) -> Vec { + let lines = scene.lines().iter().map(|l| line_instance(*l, style)); + let points = scene.points().iter().map(|p| point_instance(*p, style)); + lines.chain(points).collect() +} + +#[allow(clippy::cast_possible_truncation)] +fn line_instance(line: SceneLine, style: &Style) -> LineInstance { + let (ax, ay) = line.a().coords_mm(); + let (bx, by) = line.b().coords_mm(); + let bits = if line.for_construction() { + CONSTRUCTION_BIT + } else { + 0 + }; + LineInstance { + a: [ax as f32, ay as f32], + b: [bx as f32, by as f32], + half_width_px: style.strokes().stroke_width_px() * 0.5, + pick_id: line.pick().raw(), + style_bits: bits, + } +} + +#[allow(clippy::cast_possible_truncation)] +fn point_instance(point: ScenePoint, style: &Style) -> LineInstance { + let (x, y) = point.at().coords_mm(); + let xy = [x as f32, y as f32]; + LineInstance { + a: xy, + b: xy, + half_width_px: style.strokes().point_radius_px(), + pick_id: point.pick().raw(), + style_bits: 0, + } +} diff --git a/crates/bone-render/src/pipelines/lines.wgsl b/crates/bone-render/src/pipelines/lines.wgsl new file mode 100644 index 0000000..7002bd5 --- /dev/null +++ b/crates/bone-render/src/pipelines/lines.wgsl @@ -0,0 +1,98 @@ +struct Frame { + clip_from_world: mat4x4, + stroke_color: vec4, + construction: vec4, + pixels_per_mm: f32, + dash_period_px: f32, + dash_on_ratio: f32, + _pad: f32, +}; + +struct Instance { + @location(0) a: vec2, + @location(1) b: vec2, + @location(2) half_width_px: f32, + @location(3) pick_id: u32, + @location(4) style_bits: u32, +}; + +struct VsOut { + @builtin(position) clip: vec4, + @location(0) local_px: vec2, + @location(1) length_px: f32, + @location(2) half_width_px: f32, + @location(3) @interpolate(flat) style_bits: u32, + @location(4) @interpolate(flat) pick_id: u32, +}; + +struct FsOut { + @location(0) color: vec4, + @location(1) pick_id: u32, +}; + +@group(0) @binding(0) var u: Frame; + +const CORNERS: array, 6> = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(0.0, 1.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), +); + +const CONSTRUCTION_BIT: u32 = 1u; + +@vertex +fn vs(@builtin(vertex_index) vid: u32, inst: Instance) -> VsOut { + var corners = CORNERS; + let corner = corners[vid]; + let d_mm = inst.b - inst.a; + let len_mm = length(d_mm); + let safe = max(len_mm, 1.0e-9); + let t_hat = select(vec2(1.0, 0.0), d_mm / safe, len_mm > 1.0e-9); + let n_hat = vec2(-t_hat.y, t_hat.x); + + let expand_mm = (inst.half_width_px + 1.0) / u.pixels_per_mm; + let along_mm = mix(-expand_mm, len_mm + expand_mm, corner.x); + let perp_mm = mix(-expand_mm, expand_mm, corner.y); + + let world_mm = inst.a + along_mm * t_hat + perp_mm * n_hat; + let clip = u.clip_from_world * vec4(world_mm, 0.0, 1.0); + + var out: VsOut; + out.clip = clip; + out.local_px = vec2(along_mm, perp_mm) * u.pixels_per_mm; + out.length_px = len_mm * u.pixels_per_mm; + out.half_width_px = inst.half_width_px; + out.style_bits = inst.style_bits; + out.pick_id = inst.pick_id; + return out; +} + +@fragment +fn fs(in: VsOut) -> FsOut { + let along = clamp(in.local_px.x, 0.0, in.length_px); + let dx = in.local_px.x - along; + let dy = in.local_px.y; + let d_px = sqrt(dx * dx + dy * dy); + let aa = 0.5; + let coverage = 1.0 - smoothstep(in.half_width_px - aa, in.half_width_px + aa, d_px); + if (coverage <= 0.0) { + discard; + } + + let is_construction = (in.style_bits & CONSTRUCTION_BIT) != 0u; + let dash_visible = !is_construction + || u.dash_period_px <= 0.0 + || fract(along / u.dash_period_px) <= u.dash_on_ratio; + let color_coverage = select(0.0, coverage, dash_visible); + + let base = select(u.stroke_color, u.construction, is_construction); + let a = base.a * color_coverage; + + var out: FsOut; + out.color = vec4(base.rgb * a, a); + out.pick_id = in.pick_id; + return out; +} diff --git a/crates/bone-render/src/pipelines/mod.rs b/crates/bone-render/src/pipelines/mod.rs new file mode 100644 index 0000000..8708d41 --- /dev/null +++ b/crates/bone-render/src/pipelines/mod.rs @@ -0,0 +1,40 @@ +pub mod arc; +pub mod grid; +pub mod lines; + +pub use arc::ArcPipeline; +pub use grid::GridPipeline; +pub use lines::LinesPipeline; + +use crate::camera::Camera2; +use crate::snapshot::Style; + +#[repr(C, align(16))] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub(crate) struct FrameUniform { + pub clip_from_world: [f32; 16], + pub stroke_color: [f32; 4], + pub construction_color: [f32; 4], + pub pixels_per_mm: f32, + pub dash_period_px: f32, + pub dash_on_ratio: f32, + pub _pad: f32, +} + +pub(crate) const FRAME_UNIFORM_SIZE: u64 = core::mem::size_of::() as u64; + +pub(crate) const CONSTRUCTION_BIT: u32 = 1; + +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +pub(crate) fn build_frame_uniform(camera: Camera2, style: &Style) -> FrameUniform { + let strokes = style.strokes(); + FrameUniform { + clip_from_world: camera.clip_from_world_mm(), + stroke_color: strokes.stroke().to_rgba_array(), + construction_color: strokes.construction().to_rgba_array(), + pixels_per_mm: camera.zoom().value() as f32, + dash_period_px: strokes.construction_dash_period_px(), + dash_on_ratio: strokes.construction_dash_on_ratio(), + _pad: 0.0, + } +} diff --git a/crates/bone-render/src/scene.rs b/crates/bone-render/src/scene.rs new file mode 100644 index 0000000..61f286d --- /dev/null +++ b/crates/bone-render/src/scene.rs @@ -0,0 +1,556 @@ +use bone_document::{Sketch, SketchEntity}; +use bone_kernel::{Aabb2, arc_bounding_box}; +use bone_types::{Angle, Length, Point2, SketchEntityId}; +use core::f64::consts::TAU; +use uom::si::angle::radian; +use uom::si::length::millimeter; + +use crate::pick::{EntityKindTag, PickId, PickIdError, PickIndex}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ScenePoint { + at: Point2, + entity: SketchEntityId, + pick: PickId, +} + +impl ScenePoint { + #[must_use] + pub const fn at(self) -> Point2 { + self.at + } + + #[must_use] + pub const fn pick(self) -> PickId { + self.pick + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct SceneLine { + a: Point2, + b: Point2, + entity: SketchEntityId, + pick: PickId, + for_construction: bool, +} + +impl SceneLine { + #[must_use] + pub const fn a(self) -> Point2 { + self.a + } + + #[must_use] + pub const fn b(self) -> Point2 { + self.b + } + + #[must_use] + pub const fn pick(self) -> PickId { + self.pick + } + + #[must_use] + pub const fn for_construction(self) -> bool { + self.for_construction + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct SceneArc { + center: Point2, + radius: Length, + start_angle: Angle, + sweep_angle: Angle, + entity: SketchEntityId, + pick: PickId, + for_construction: bool, +} + +impl SceneArc { + #[must_use] + pub const fn center(self) -> Point2 { + self.center + } + + #[must_use] + pub const fn radius(self) -> Length { + self.radius + } + + #[must_use] + pub const fn start_angle(self) -> Angle { + self.start_angle + } + + #[must_use] + pub const fn sweep_angle(self) -> Angle { + self.sweep_angle + } + + #[must_use] + pub const fn pick(self) -> PickId { + self.pick + } + + #[must_use] + pub const fn for_construction(self) -> bool { + self.for_construction + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct SceneCircle { + center: Point2, + radius: Length, + entity: SketchEntityId, + pick: PickId, + for_construction: bool, +} + +impl SceneCircle { + #[must_use] + pub const fn center(self) -> Point2 { + self.center + } + + #[must_use] + pub const fn radius(self) -> Length { + self.radius + } + + #[must_use] + pub const fn pick(self) -> PickId { + self.pick + } + + #[must_use] + pub const fn for_construction(self) -> bool { + self.for_construction + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SketchScene { + points: Vec, + lines: Vec, + arcs: Vec, + circles: Vec, +} + +impl SketchScene { + #[must_use] + pub const fn empty() -> Self { + Self { + points: Vec::new(), + lines: Vec::new(), + arcs: Vec::new(), + circles: Vec::new(), + } + } + + pub fn extract(sketch: &Sketch) -> Result { + sketch + .entity_order() + .iter() + .copied() + .try_fold(Self::empty(), |acc, id| acc.push_from_sketch(sketch, id)) + } + + #[must_use] + pub fn points(&self) -> &[ScenePoint] { + &self.points + } + + #[must_use] + pub fn lines(&self) -> &[SceneLine] { + &self.lines + } + + #[must_use] + pub fn arcs(&self) -> &[SceneArc] { + &self.arcs + } + + #[must_use] + pub fn circles(&self) -> &[SceneCircle] { + &self.circles + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.points.is_empty() + && self.lines.is_empty() + && self.arcs.is_empty() + && self.circles.is_empty() + } + + #[must_use] + pub fn aabb(&self) -> Option { + let points = self + .points + .iter() + .map(|p| Aabb2::from_corners(p.at(), p.at())); + let lines = self.lines.iter().map(|l| Aabb2::from_corners(l.a(), l.b())); + let circles = self.circles.iter().map(|c| { + let (cx, cy) = c.center().coords_mm(); + let r = c.radius().get::(); + Aabb2::from_corners( + Point2::from_mm(cx - r, cy - r), + Point2::from_mm(cx + r, cy + r), + ) + }); + let arcs = self + .arcs + .iter() + .map(|a| arc_bounding_box(a.center(), a.radius(), a.start_angle(), a.sweep_angle())); + points + .chain(lines) + .chain(circles) + .chain(arcs) + .reduce(|a, b| a.extend_point(b.min()).extend_point(b.max())) + } + + pub fn pick_index(&self) -> Result { + let entities = self + .points + .iter() + .map(|p| (p.entity, EntityKindTag::Point)) + .chain(self.lines.iter().map(|l| (l.entity, EntityKindTag::Line))) + .chain(self.arcs.iter().map(|a| (a.entity, EntityKindTag::Arc))) + .chain( + self.circles + .iter() + .map(|c| (c.entity, EntityKindTag::Circle)), + ); + PickIndex::build( + entities, + core::iter::empty::(), + core::iter::empty::(), + ) + } + + fn push_from_sketch( + mut self, + sketch: &Sketch, + id: SketchEntityId, + ) -> Result { + let Some(entity) = sketch.entities().get(id).copied() else { + unreachable!( + "entity_order references id {id:?} missing from entities — Sketch invariants broken" + ); + }; + match entity { + SketchEntity::Point(p) => { + self.points.push(ScenePoint { + at: p.at(), + entity: id, + pick: PickId::point(id)?, + }); + } + SketchEntity::Line(l) => { + self.lines.push(SceneLine { + a: point_position(sketch, l.a()), + b: point_position(sketch, l.b()), + entity: id, + pick: PickId::line(id)?, + for_construction: l.for_construction(), + }); + } + SketchEntity::Arc(a) => { + if let Some(arc) = derive_arc(sketch, id, a, PickId::arc(id)?) { + self.arcs.push(arc); + } + } + SketchEntity::Circle(c) => { + self.circles.push(SceneCircle { + center: point_position(sketch, c.center()), + radius: c.radius(), + entity: id, + pick: PickId::circle(id)?, + for_construction: c.for_construction(), + }); + } + } + Ok(self) + } +} + +fn point_position(sketch: &Sketch, id: SketchEntityId) -> Point2 { + let Some(entity) = sketch.entities().get(id) else { + unreachable!( + "SketchEntityId {id:?} missing from sketch entities — Sketch invariants broken" + ); + }; + let SketchEntity::Point(p) = entity else { + unreachable!( + "expected Point at {id:?}, got {:?} — Sketch::apply guarantees references resolve to Points", + entity.kind() + ); + }; + p.at() +} + +fn derive_arc( + sketch: &Sketch, + id: SketchEntityId, + data: bone_document::ArcData, + pick: PickId, +) -> Option { + let center = point_position(sketch, data.center()); + let start = point_position(sketch, data.start()); + let end = point_position(sketch, data.end()); + let (cx, cy) = center.coords_mm(); + let (start_x, start_y) = start.coords_mm(); + let (end_x, end_y) = end.coords_mm(); + let start_offset_x = start_x - cx; + let start_offset_y = start_y - cy; + let radius_mm = (start_offset_x * start_offset_x + start_offset_y * start_offset_y).sqrt(); + if !(radius_mm.is_finite() && radius_mm > 0.0) { + return None; + } + let start_angle = start_offset_y.atan2(start_offset_x); + let end_angle = (end_y - cy).atan2(end_x - cx); + let sweep = (end_angle - start_angle).rem_euclid(TAU); + if !(sweep.is_finite() && sweep > 0.0) { + return None; + } + Some(SceneArc { + center, + radius: Length::new::(radius_mm), + start_angle: Angle::new::(start_angle), + sweep_angle: Angle::new::(sweep), + entity: id, + pick, + for_construction: data.for_construction(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use bone_document::{EditOutcome, SketchEdit}; + use bone_types::{Point3, SketchPlaneBasis, Tolerance, UnitVec3}; + use uom::si::length::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 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 { + panic!("add point"); + }; + (next, id) + } + + fn add_line( + sketch: Sketch, + a: SketchEntityId, + b: SketchEntityId, + construction: bool, + ) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( + SketchEntity::line(a, b, construction), + )) else { + panic!("add line"); + }; + (next, id) + } + + fn add_circle( + sketch: Sketch, + center: SketchEntityId, + radius_mm: f64, + ) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( + SketchEntity::circle(center, len_mm(radius_mm), false), + )) else { + panic!("add circle"); + }; + (next, id) + } + + fn add_arc_ids( + sketch: Sketch, + center: SketchEntityId, + start: SketchEntityId, + end: SketchEntityId, + ) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( + SketchEntity::arc(center, start, end, false), + )) else { + panic!("add arc"); + }; + (next, id) + } + + #[test] + fn empty_scene_has_no_items() { + let s = Sketch::new(plane()); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract empty"); + }; + assert!(scene.is_empty()); + } + + #[test] + fn rectangle_extracts_four_points_and_four_lines() { + let s = Sketch::new(plane()); + let (s, p0) = add_point(s, 0.0, 0.0); + let (s, p1) = add_point(s, 10.0, 0.0); + let (s, p2) = add_point(s, 10.0, 5.0); + let (s, p3) = add_point(s, 0.0, 5.0); + let (s, _) = add_line(s, p0, p1, false); + let (s, _) = add_line(s, p1, p2, false); + let (s, _) = add_line(s, p2, p3, false); + let (s, _) = add_line(s, p3, p0, false); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + assert_eq!(scene.points().len(), 4); + assert_eq!(scene.lines().len(), 4); + assert!(scene.arcs().is_empty()); + assert!(scene.circles().is_empty()); + assert_eq!(scene.lines()[0].a(), Point2::from_mm(0.0, 0.0)); + assert_eq!(scene.lines()[0].b(), Point2::from_mm(10.0, 0.0)); + } + + #[test] + fn construction_flag_propagates_to_line() { + let s = Sketch::new(plane()); + let (s, a) = add_point(s, 0.0, 0.0); + let (s, b) = add_point(s, 3.0, 4.0); + let (s, _) = add_line(s, a, b, true); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + assert!(scene.lines()[0].for_construction()); + } + + #[test] + fn circle_extracts_with_center_and_radius() { + let s = Sketch::new(plane()); + let (s, c) = add_point(s, 1.0, 2.0); + let (s, _) = add_circle(s, c, 4.0); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + assert_eq!(scene.circles().len(), 1); + assert_eq!(scene.circles()[0].center(), Point2::from_mm(1.0, 2.0)); + assert!((scene.circles()[0].radius().get::() - 4.0).abs() < 1e-12); + } + + #[test] + fn arc_derives_radius_and_ccw_sweep() { + let s = Sketch::new(plane()); + let (s, c) = add_point(s, 0.0, 0.0); + let (s, start) = add_point(s, 5.0, 0.0); + let (s, end) = add_point(s, 0.0, 5.0); + let (s, _) = add_arc_ids(s, c, start, end); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + let arc = scene.arcs()[0]; + assert!((arc.radius().get::() - 5.0).abs() < 1e-12); + let start_rad = arc.start_angle().get::(); + let sweep_rad = arc.sweep_angle().get::(); + assert!(start_rad.abs() < 1e-12); + assert!((sweep_rad - core::f64::consts::FRAC_PI_2).abs() < 1e-12); + } + + #[test] + fn pick_index_round_trips_each_scene_item() { + use crate::pick::PickedItem; + let s = Sketch::new(plane()); + let (s, p0) = add_point(s, 0.0, 0.0); + let (s, p1) = add_point(s, 1.0, 0.0); + let (s, line) = add_line(s, p0, p1, false); + let (s, cc) = add_point(s, 5.0, 0.0); + let (s, circle) = add_circle(s, cc, 2.0); + let (s, ac) = add_point(s, 10.0, 0.0); + let (s, a_start) = add_point(s, 12.0, 0.0); + let (s, a_end) = add_point(s, 10.0, 2.0); + let (s, arc) = add_arc_ids(s, ac, a_start, a_end); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + let Ok(index) = scene.pick_index() else { + panic!("pick index"); + }; + assert_eq!( + scene.points()[0].pick().unpack(&index), + Some(PickedItem::Point(p0)) + ); + assert_eq!( + scene.lines()[0].pick().unpack(&index), + Some(PickedItem::Line(line)) + ); + assert_eq!( + scene.arcs()[0].pick().unpack(&index), + Some(PickedItem::Arc(arc)) + ); + assert_eq!( + scene.circles()[0].pick().unpack(&index), + Some(PickedItem::Circle(circle)) + ); + } + + #[test] + fn arc_coincident_start_and_end_is_dropped() { + let s = Sketch::new(plane()); + let (s, c) = add_point(s, 0.0, 0.0); + let (s, start) = add_point(s, 5.0, 0.0); + let (s, end) = add_point(s, 5.0, 0.0); + let (s, _) = add_arc_ids(s, c, start, end); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + assert!(scene.arcs().is_empty()); + } + + #[test] + fn arc_with_zero_radius_is_dropped() { + let s = Sketch::new(plane()); + let (s, c) = add_point(s, 0.0, 0.0); + let (s, start) = add_point(s, 0.0, 0.0); + let (s, end) = add_point(s, 5.0, 0.0); + let (s, _) = add_arc_ids(s, c, start, end); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + assert!(scene.arcs().is_empty()); + } + + #[test] + fn arc_sweep_follows_ccw_convention() { + let s = Sketch::new(plane()); + let (s, c) = add_point(s, 0.0, 0.0); + let (s, start) = add_point(s, 0.0, 5.0); + let (s, end) = add_point(s, 5.0, 0.0); + let (s, _) = add_arc_ids(s, c, start, end); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("extract"); + }; + let arc = scene.arcs()[0]; + let start_rad = arc.start_angle().get::(); + let sweep_rad = arc.sweep_angle().get::(); + assert!((start_rad - core::f64::consts::FRAC_PI_2).abs() < 1e-12); + assert!((sweep_rad - 3.0 * core::f64::consts::FRAC_PI_2).abs() < 1e-12); + } +} diff --git a/crates/bone-render/src/snapshot.rs b/crates/bone-render/src/snapshot.rs new file mode 100644 index 0000000..7897577 --- /dev/null +++ b/crates/bone-render/src/snapshot.rs @@ -0,0 +1,371 @@ +use crate::camera::ViewportExtent; +use crate::gpu::BackendTag; +use crate::{RenderError, Result}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ClearColor { + r: f64, + g: f64, + b: f64, + a: f64, +} + +impl ClearColor { + #[must_use] + pub const fn new(r: f64, g: f64, b: f64, a: f64) -> Self { + Self { r, g, b, a } + } + + #[must_use] + pub const fn opaque(r: f64, g: f64, b: f64) -> Self { + Self::new(r, g, b, 1.0) + } + + #[must_use] + pub fn to_rgba8(self) -> [u8; 4] { + [ + channel_to_u8(self.r), + channel_to_u8(self.g), + channel_to_u8(self.b), + channel_to_u8(self.a), + ] + } + + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn to_rgba_array(self) -> [f32; 4] { + [self.r as f32, self.g as f32, self.b as f32, self.a as f32] + } +} + +impl From for wgpu::Color { + fn from(c: ClearColor) -> Self { + Self { + r: c.r, + g: c.g, + b: c.b, + a: c.a, + } + } +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn channel_to_u8(value: f64) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct GridStyle { + minor: ClearColor, + major: ClearColor, + axis_x: ClearColor, + axis_y: ClearColor, + origin: ClearColor, + line_width_px: f32, + axis_width_px: f32, + origin_radius_px: f32, + minor_spacing_target_px: f32, +} + +impl GridStyle { + pub const DEFAULT: Self = Self { + minor: ClearColor::new(1.0, 1.0, 1.0, 0.08), + major: ClearColor::new(1.0, 1.0, 1.0, 0.22), + axis_x: ClearColor::new(1.0, 0.0, 0.0, 1.0), + axis_y: ClearColor::new(0.0, 1.0, 0.0, 1.0), + origin: ClearColor::new(1.0, 1.0, 1.0, 0.95), + line_width_px: 1.0, + axis_width_px: 1.6, + origin_radius_px: 4.0, + minor_spacing_target_px: 24.0, + }; + + #[must_use] + pub const fn minor(self) -> ClearColor { + self.minor + } + + #[must_use] + pub const fn major(self) -> ClearColor { + self.major + } + + #[must_use] + pub const fn axis_x(self) -> ClearColor { + self.axis_x + } + + #[must_use] + pub const fn axis_y(self) -> ClearColor { + self.axis_y + } + + #[must_use] + pub const fn origin(self) -> ClearColor { + self.origin + } + + #[must_use] + pub const fn line_width_px(self) -> f32 { + self.line_width_px + } + + #[must_use] + pub const fn axis_width_px(self) -> f32 { + self.axis_width_px + } + + #[must_use] + pub const fn origin_radius_px(self) -> f32 { + self.origin_radius_px + } + + #[must_use] + pub const fn minor_spacing_target_px(self) -> f32 { + self.minor_spacing_target_px + } +} + +impl Default for GridStyle { + fn default() -> Self { + Self::DEFAULT + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct StrokeStyle { + stroke: ClearColor, + construction: ClearColor, + stroke_width_px: f32, + point_radius_px: f32, + construction_dash_period_px: f32, + construction_dash_on_ratio: f32, +} + +impl StrokeStyle { + pub const DEFAULT: Self = Self { + stroke: ClearColor::new(0.95, 0.95, 0.98, 1.0), + construction: ClearColor::new(0.55, 0.75, 1.0, 0.85), + stroke_width_px: 1.5, + point_radius_px: 3.0, + construction_dash_period_px: 8.0, + construction_dash_on_ratio: 0.5, + }; + + #[must_use] + pub const fn stroke(self) -> ClearColor { + self.stroke + } + + #[must_use] + pub const fn construction(self) -> ClearColor { + self.construction + } + + #[must_use] + pub const fn stroke_width_px(self) -> f32 { + self.stroke_width_px + } + + #[must_use] + pub const fn point_radius_px(self) -> f32 { + self.point_radius_px + } + + #[must_use] + pub const fn construction_dash_period_px(self) -> f32 { + self.construction_dash_period_px + } + + #[must_use] + pub const fn construction_dash_on_ratio(self) -> f32 { + self.construction_dash_on_ratio + } +} + +impl Default for StrokeStyle { + fn default() -> Self { + Self::DEFAULT + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Style { + background: ClearColor, + grid: GridStyle, + strokes: StrokeStyle, +} + +impl Style { + #[must_use] + pub const fn new(background: ClearColor) -> Self { + Self { + background, + grid: GridStyle::DEFAULT, + strokes: StrokeStyle::DEFAULT, + } + } + + #[must_use] + pub const fn background(self) -> ClearColor { + self.background + } + + #[must_use] + pub const fn grid(self) -> GridStyle { + self.grid + } + + #[must_use] + pub const fn strokes(self) -> StrokeStyle { + self.strokes + } +} + +impl Default for Style { + fn default() -> Self { + Self::new(ClearColor::opaque(0.05, 0.05, 0.07)) + } +} + +#[derive(Clone, Debug)] +pub struct SnapshotFrame { + extent: ViewportExtent, + rgba: Vec, + backend: BackendTag, +} + +impl SnapshotFrame { + pub(crate) fn new(extent: ViewportExtent, rgba: Vec, backend: BackendTag) -> Self { + Self { + extent, + rgba, + backend, + } + } + + #[must_use] + pub fn extent(&self) -> ViewportExtent { + self.extent + } + + #[must_use] + pub fn rgba(&self) -> &[u8] { + &self.rgba + } + + #[must_use] + pub fn backend(&self) -> BackendTag { + self.backend + } +} + +pub fn encode_png(frame: &SnapshotFrame) -> Result> { + let mut out: Vec = Vec::new(); + { + let mut encoder = png::Encoder::new( + &mut out, + frame.extent.width().value(), + frame.extent.height().value(), + ); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header()?; + writer.write_image_data(frame.rgba())?; + } + Ok(out) +} + +pub fn decode_png(bytes: &[u8]) -> Result<(ViewportExtent, Vec)> { + use crate::camera::ViewportPx; + let decoder = png::Decoder::new(bytes); + let mut reader = decoder.read_info()?; + { + let info = reader.info(); + if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight { + return Err(RenderError::PngFormat { + color_type: info.color_type, + bit_depth: info.bit_depth, + }); + } + } + let mut buf = vec![0_u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buf)?; + let extent = ViewportExtent::new(ViewportPx::new(info.width), ViewportPx::new(info.height)); + buf.truncate(info.buffer_size()); + Ok((extent, buf)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::camera::ViewportPx; + use crate::gpu::BackendTag; + + fn tag() -> BackendTag { + BackendTag::from_backend(wgpu::Backend::Noop) + } + + #[test] + fn clear_color_rounds_to_nearest_u8() { + let c = ClearColor::opaque(0.0, 0.5, 1.0); + assert_eq!(c.to_rgba8(), [0, 128, 255, 255]); + } + + #[test] + fn style_default_is_navy_opaque() { + let rgba = Style::default().background().to_rgba8(); + assert_eq!(rgba[3], 255); + assert!(rgba[0] < 32 && rgba[1] < 32 && rgba[2] < 32); + } + + fn encode_grayscale(width: u32, height: u32, pixels: &[u8]) -> Vec { + let mut bytes: Vec = Vec::new(); + { + let mut enc = png::Encoder::new(&mut bytes, width, height); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::Eight); + let Ok(mut writer) = enc.write_header() else { + panic!("grayscale png header write failed"); + }; + assert!( + writer.write_image_data(pixels).is_ok(), + "grayscale png body write failed", + ); + } + bytes + } + + #[test] + fn decode_png_rejects_non_rgba8() { + let bytes = encode_grayscale(4, 4, &[0_u8; 16]); + let Err(RenderError::PngFormat { + color_type, + bit_depth, + }) = decode_png(&bytes) + else { + panic!("expected PngFormat rejection"); + }; + assert_eq!(color_type, png::ColorType::Grayscale); + assert_eq!(bit_depth, png::BitDepth::Eight); + } + + #[test] + fn png_encode_decode_round_trips_solid_frame() { + let extent = ViewportExtent::new(ViewportPx::new(8), ViewportPx::new(4)); + let pixel = [200_u8, 80, 40, 255]; + let rgba: Vec = (0..extent.pixel_count()) + .flat_map(|_| pixel.into_iter()) + .collect(); + let frame = SnapshotFrame::new(extent, rgba.clone(), tag()); + + let Ok(encoded) = encode_png(&frame) else { + panic!("encode_png failed"); + }; + let Ok((decoded_extent, decoded)) = decode_png(&encoded) else { + panic!("decode_png failed"); + }; + assert_eq!(decoded_extent, extent); + assert_eq!(decoded, rgba); + } +} diff --git a/crates/bone-render/src/surface.rs b/crates/bone-render/src/surface.rs new file mode 100644 index 0000000..6ba5a3f --- /dev/null +++ b/crates/bone-render/src/surface.rs @@ -0,0 +1,235 @@ +use crate::camera::ViewportExtent; +use crate::gpu::{Capabilities, Gpu}; + +const PICK_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint; + +#[derive(Debug, thiserror::Error)] +pub enum SurfaceError { + #[error("surface creation: {0}")] + Create(#[from] wgpu::CreateSurfaceError), + #[error("no wgpu adapter matched the surface: {0}")] + NoAdapter(#[from] wgpu::RequestAdapterError), + #[error("wgpu device request failed: {0}")] + Device(#[from] wgpu::RequestDeviceError), + #[error( + "surface exposes no srgb colour format; baseline requires one. available: {available:?}" + )] + NoSrgbFormat { available: Vec }, + #[error("viewport dimension is zero")] + ZeroExtent, +} + +pub struct SurfaceContext { + gpu: Gpu, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + pick: wgpu::Texture, + extent: ViewportExtent, + reconfigure_pending: bool, +} + +enum AcquireOutcome { + Frame(wgpu::SurfaceTexture), + Skip, + Reconfigure, +} + +impl SurfaceContext { + pub async fn new( + target: impl Into>, + extent: ViewportExtent, + ) -> Result { + if extent.width().value() == 0 || extent.height().value() == 0 { + return Err(SurfaceError::ZeroExtent); + } + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let target: wgpu::SurfaceTarget<'static> = target.into(); + let surface = instance.create_surface(target)?; + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + }) + .await?; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("bone-render:surface"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + experimental_features: wgpu::ExperimentalFeatures::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::Off, + }) + .await?; + let capabilities = Capabilities::probe(&adapter); + let caps = surface.get_capabilities(&adapter); + let Some(format) = caps + .formats + .iter() + .copied() + .find(wgpu::TextureFormat::is_srgb) + else { + return Err(SurfaceError::NoSrgbFormat { + available: caps.formats.clone(), + }); + }; + let present_mode = pick_present_mode(&caps.present_modes); + let alpha_mode = caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto); + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: extent.width().value(), + height: extent.height().value(), + present_mode, + alpha_mode, + view_formats: vec![], + desired_maximum_frame_latency: 1, + }; + surface.configure(&device, &config); + let pick = create_pick_texture(&device, extent); + Ok(Self { + gpu: Gpu::from_parts(device, queue, capabilities), + surface, + config, + pick, + extent, + reconfigure_pending: false, + }) + } + + #[must_use] + pub fn gpu(&self) -> &Gpu { + &self.gpu + } + + #[must_use] + pub const fn extent(&self) -> ViewportExtent { + self.extent + } + + #[must_use] + pub const fn color_format(&self) -> wgpu::TextureFormat { + self.config.format + } + + pub fn resize(&mut self, extent: ViewportExtent) { + if extent.width().value() == 0 || extent.height().value() == 0 { + return; + } + self.extent = extent; + self.config.width = extent.width().value(); + self.config.height = extent.height().value(); + self.surface.configure(self.gpu.device(), &self.config); + self.pick = create_pick_texture(self.gpu.device(), extent); + self.reconfigure_pending = false; + } + + pub fn render(&mut self, build_passes: F, pre_present: G) + where + F: FnOnce(&mut wgpu::CommandEncoder, &wgpu::TextureView, &wgpu::TextureView), + G: FnOnce(), + { + let Some(frame) = self.acquire_frame() else { + return; + }; + let color_view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let pick_view = self + .pick + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = + self.gpu + .device() + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bone-render:surface-encoder"), + }); + build_passes(&mut encoder, &color_view, &pick_view); + self.gpu.queue().submit(Some(encoder.finish())); + pre_present(); + frame.present(); + } + + fn acquire_frame(&mut self) -> Option { + if self.reconfigure_pending { + self.surface.configure(self.gpu.device(), &self.config); + self.reconfigure_pending = false; + } + match self.classify_acquire() { + AcquireOutcome::Frame(frame) => Some(frame), + AcquireOutcome::Skip => None, + AcquireOutcome::Reconfigure => { + self.surface.configure(self.gpu.device(), &self.config); + match self.classify_acquire() { + AcquireOutcome::Frame(frame) => Some(frame), + AcquireOutcome::Skip => None, + AcquireOutcome::Reconfigure => { + tracing::warn!( + "surface still outdated/lost after reconfigure; skipping frame" + ); + None + } + } + } + } + } + + fn classify_acquire(&mut self) -> AcquireOutcome { + match self.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(frame) => AcquireOutcome::Frame(frame), + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { + self.reconfigure_pending = true; + AcquireOutcome::Frame(frame) + } + wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { + AcquireOutcome::Reconfigure + } + wgpu::CurrentSurfaceTexture::Timeout => { + tracing::warn!("surface frame acquire timed out; skipping frame"); + AcquireOutcome::Skip + } + wgpu::CurrentSurfaceTexture::Occluded => { + tracing::debug!("surface occluded; skipping frame"); + AcquireOutcome::Skip + } + wgpu::CurrentSurfaceTexture::Validation => { + tracing::warn!("surface validation error on acquire; skipping frame"); + AcquireOutcome::Skip + } + } + } +} + +fn create_pick_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("bone-render:surface-pick"), + size: wgpu::Extent3d { + width: extent.width().value(), + height: extent.height().value(), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: PICK_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) +} + +fn pick_present_mode(modes: &[wgpu::PresentMode]) -> wgpu::PresentMode { + [ + wgpu::PresentMode::Mailbox, + wgpu::PresentMode::Immediate, + wgpu::PresentMode::FifoRelaxed, + ] + .into_iter() + .find(|m| modes.contains(m)) + .unwrap_or(wgpu::PresentMode::Fifo) +} diff --git a/crates/bone-render/tests/arcs.rs b/crates/bone-render/tests/arcs.rs new file mode 100644 index 0000000..bab4cef --- /dev/null +++ b/crates/bone-render/tests/arcs.rs @@ -0,0 +1,148 @@ +use std::path::PathBuf; + +use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; +use bone_render::{ + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, PixelsPerMm, RenderError, + SketchRenderer, SketchScene, Style, ViewportExtent, ViewportPx, decode_png, encode_png, +}; +use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; +use uom::si::length::millimeter; + +const GOLDEN: &str = "tests/goldens/arc_circle_zoom100_256.png"; +const UPDATE_ENV: &str = "BONE_UPDATE_ARC_GOLDEN"; +const DIFF_TOLERANCE: f64 = 16.0 / 255.0; + +fn extent(side: u32) -> ViewportExtent { + ViewportExtent::square(ViewportPx::new(side)) +} + +fn make_context(extent: ViewportExtent) -> OffscreenContext { + match pollster::block_on(OffscreenContext::new(extent)) { + Ok(ctx) => ctx, + Err(RenderError::NoAdapter(e)) => { + panic!( + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" + ); + } + Err(e) => panic!("offscreen context init failed: {e}"), + } +} + +fn golden_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) +} + +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 add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( + Point2::from_mm(x, y), + ))) else { + panic!("add point"); + }; + (next, id) +} + +fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( + center, + Length::new::(radius_mm), + false, + ))) else { + panic!("add circle"); + }; + next +} + +fn add_arc( + s: Sketch, + center: SketchEntityId, + start: SketchEntityId, + end: SketchEntityId, +) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::arc( + center, start, end, false, + ))) else { + panic!("add arc"); + }; + next +} + +fn arc_circle_scene() -> SketchScene { + let s = Sketch::new(plane()); + let (s, circle_c) = add_point(s, -0.7, 0.0); + let s = add_circle(s, circle_c, 0.5); + + let (s, arc_c) = add_point(s, 0.7, 0.0); + let (s, arc_start) = add_point(s, 1.2, 0.0); + let (s, arc_end) = add_point(s, 0.7, 0.5); + let s = add_arc(s, arc_c, arc_start, arc_end); + + let Ok(scene) = SketchScene::extract(&s) else { + panic!("scene extract"); + }; + scene +} + +#[test] +fn arc_circle_at_100x_zoom_matches_golden() { + let size = extent(256); + let ctx = make_context(size); + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); + let scene = arc_circle_scene(); + let camera = Camera2::new(size).with_zoom(PixelsPerMm::new(100.0)); + let style = Style::default(); + + let Ok(frame) = renderer.render(&ctx, &scene, camera, &style) else { + panic!("SketchRenderer::render failed"); + }; + + let path = golden_path(); + + if std::env::var(UPDATE_ENV).is_ok() { + let Ok(bytes) = encode_png(&frame) else { + panic!("encode_png failed"); + }; + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + panic!("create goldens dir {}: {e}", parent.display()); + } + if let Err(e) = std::fs::write(&path, &bytes) { + panic!("write golden {}: {e}", path.display()); + } + return; + } + + let Ok(bytes) = std::fs::read(&path) else { + panic!( + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", + path.display() + ); + }; + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { + panic!("failed to decode golden PNG"); + }; + assert_eq!(golden_extent, size, "golden extent drift"); + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { + panic!("PixelDiff rejected inputs"); + }; + assert!( + report.is_clean(), + "arc render drifted from golden: {} mismatches, worst {:?}, backend {}", + report.over_threshold(), + report.worst(), + frame.backend(), + ); +} diff --git a/crates/bone-render/tests/clear.rs b/crates/bone-render/tests/clear.rs new file mode 100644 index 0000000..a54ea6c --- /dev/null +++ b/crates/bone-render/tests/clear.rs @@ -0,0 +1,91 @@ +use bone_render::{ + ClearColor, OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, Style, + ViewportExtent, ViewportPx, +}; + +fn extent(side: u32) -> ViewportExtent { + ViewportExtent::square(ViewportPx::new(side)) +} + +fn make_context(extent: ViewportExtent) -> OffscreenContext { + match pollster::block_on(OffscreenContext::new(extent)) { + Ok(ctx) => ctx, + Err(RenderError::NoAdapter(e)) => { + panic!( + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" + ); + } + Err(e) => panic!("offscreen context init failed: {e}"), + } +} + +fn expected_rgba(extent: ViewportExtent, color: ClearColor) -> Vec { + let pixel = color.to_rgba8(); + let count = usize::try_from(extent.pixel_count()).unwrap_or(0); + (0..count).flat_map(|_| pixel.into_iter()).collect() +} + +#[test] +fn offscreen_clear_matches_style_background_exactly() { + let size = extent(64); + let ctx = make_context(size); + let style = Style::default(); + + let Ok(frame) = ctx.render_clear(&style) else { + panic!("render_clear failed"); + }; + + assert_eq!(frame.extent(), size); + let expected = expected_rgba(size, style.background()); + assert_eq!(frame.rgba().len(), expected.len()); + + let Ok(report) = PixelDiff::compare(&frame, &expected, PixelDiffThreshold::EXACT) else { + panic!("pixel diff rejected inputs"); + }; + assert!( + report.is_clean(), + "clear frame diverged from expected bytes: {} mismatches, worst {:?}", + report.over_threshold(), + report.worst(), + ); +} + +#[test] +fn offscreen_clear_honours_custom_background() { + let size = extent(32); + let ctx = make_context(size); + let background = ClearColor::opaque(0.25, 0.50, 0.75); + let style = Style::new(background); + + let Ok(frame) = ctx.render_clear(&style) else { + panic!("render_clear failed"); + }; + + let expected = expected_rgba(size, background); + let Ok(report) = PixelDiff::compare(&frame, &expected, PixelDiffThreshold::EXACT) else { + panic!("pixel diff rejected inputs"); + }; + assert!( + report.is_clean(), + "custom clear diverged: {} mismatches", + report.over_threshold(), + ); +} + +#[test] +fn offscreen_extent_with_non_aligned_width_reads_back_packed_rgba() { + let size = ViewportExtent::new(ViewportPx::new(63), ViewportPx::new(16)); + let ctx = make_context(size); + let style = Style::new(ClearColor::opaque(1.0, 0.0, 0.0)); + + let Ok(frame) = ctx.render_clear(&style) else { + panic!("render_clear failed"); + }; + + let expected = expected_rgba(size, style.background()); + assert_eq!(frame.rgba().len(), expected.len()); + let Ok(report) = PixelDiff::compare(&frame, &expected, PixelDiffThreshold::EXACT) else { + panic!("pixel diff rejected inputs"); + }; + assert!(report.is_clean(), "unaligned width readback diverged"); +} diff --git a/crates/bone-render/tests/construction_styling.rs b/crates/bone-render/tests/construction_styling.rs new file mode 100644 index 0000000..fe13ca2 --- /dev/null +++ b/crates/bone-render/tests/construction_styling.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; +use bone_render::{ + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, PixelsPerMm, RenderError, + SketchRenderer, SketchScene, SnapshotFrame, Style, ViewportExtent, ViewportPx, decode_png, + encode_png, +}; +use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; +use uom::si::length::millimeter; + +const REAL_GOLDEN: &str = "tests/goldens/construction_real_256.png"; +const DASHED_GOLDEN: &str = "tests/goldens/construction_dashed_256.png"; +const UPDATE_ENV: &str = "BONE_UPDATE_CONSTRUCTION_GOLDENS"; +const DIFF_TOLERANCE: f64 = 16.0 / 255.0; +const DISTINCT_MIN_PIXELS: u32 = 50; + +fn extent() -> ViewportExtent { + ViewportExtent::square(ViewportPx::new(256)) +} + +fn make_context() -> OffscreenContext { + match pollster::block_on(OffscreenContext::new(extent())) { + Ok(ctx) => ctx, + Err(RenderError::NoAdapter(e)) => panic!( + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" + ), + Err(e) => panic!("offscreen context init failed: {e}"), + } +} + +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 add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( + Point2::from_mm(x, y), + ))) else { + panic!("add point"); + }; + (next, id) +} + +fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId, construction: bool) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line( + a, + b, + construction, + ))) else { + panic!("add line"); + }; + next +} + +fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64, construction: bool) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( + center, + Length::new::(radius_mm), + construction, + ))) else { + panic!("add circle"); + }; + next +} + +fn add_arc( + s: Sketch, + center: SketchEntityId, + start: SketchEntityId, + end: SketchEntityId, + construction: bool, +) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::arc( + center, + start, + end, + construction, + ))) else { + panic!("add arc"); + }; + next +} + +fn build_scene(construction: bool) -> SketchScene { + let s = Sketch::new(plane()); + + let (s, la) = add_point(s, -0.9, 0.35); + let (s, lb) = add_point(s, 0.9, 0.35); + let s = add_line(s, la, lb, construction); + + let (s, cc) = add_point(s, -0.5, -0.3); + let s = add_circle(s, cc, 0.25, construction); + + let (s, ac) = add_point(s, 0.5, -0.3); + let (s, astart) = add_point(s, 0.85, -0.3); + let (s, aend) = add_point(s, 0.5, 0.05); + let s = add_arc(s, ac, astart, aend, construction); + + let Ok(scene) = SketchScene::extract(&s) else { + panic!("scene extract"); + }; + scene +} + +fn render_scene(ctx: &OffscreenContext, scene: &SketchScene) -> SnapshotFrame { + let camera = Camera2::new(ctx.extent()).with_zoom(PixelsPerMm::new(100.0)); + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); + let style = Style::default(); + let Ok(frame) = renderer.render(ctx, scene, camera, &style) else { + panic!("SketchRenderer::render failed"); + }; + frame +} + +fn golden_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name) +} + +fn match_or_update_golden(frame: &SnapshotFrame, name: &str) { + let path = golden_path(name); + if std::env::var(UPDATE_ENV).is_ok() { + let Ok(bytes) = encode_png(frame) else { + panic!("encode_png failed"); + }; + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + panic!("create goldens dir {}: {e}", parent.display()); + } + if let Err(e) = std::fs::write(&path, &bytes) { + panic!("write golden {}: {e}", path.display()); + } + return; + } + let Ok(bytes) = std::fs::read(&path) else { + panic!( + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", + path.display() + ); + }; + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { + panic!("failed to decode golden PNG at {}", path.display()); + }; + assert_eq!( + golden_extent, + frame.extent(), + "golden extent drift for {name}" + ); + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); + let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else { + panic!("PixelDiff rejected inputs for {name}"); + }; + assert!( + report.is_clean(), + "{name} drifted: {} mismatches, worst {:?}, backend {}", + report.over_threshold(), + report.worst(), + frame.backend(), + ); +} + +#[test] +fn real_and_dashed_goldens_match() { + let ctx = make_context(); + let real = render_scene(&ctx, &build_scene(false)); + match_or_update_golden(&real, REAL_GOLDEN); + let dashed = render_scene(&ctx, &build_scene(true)); + match_or_update_golden(&dashed, DASHED_GOLDEN); +} + +#[test] +fn construction_render_differs_from_real_render() { + let ctx = make_context(); + let real = render_scene(&ctx, &build_scene(false)); + let dashed = render_scene(&ctx, &build_scene(true)); + assert_eq!(real.extent(), dashed.extent()); + let Ok(report) = PixelDiff::compare(&real, dashed.rgba(), PixelDiffThreshold::EXACT) else { + panic!("PixelDiff rejected inputs"); + }; + assert!( + report.over_threshold() >= DISTINCT_MIN_PIXELS, + "construction styling not distinct from real: only {} pixels differ", + report.over_threshold(), + ); +} diff --git a/crates/bone-render/tests/goldens/arc_circle_zoom100_256.png b/crates/bone-render/tests/goldens/arc_circle_zoom100_256.png new file mode 100644 index 0000000000000000000000000000000000000000..45173bcc3f2f08a301088795570ac6f06a85c180 GIT binary patch literal 35663 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%bAVxBIJAr*{o4=^uKiJW%q z89fk3`fSZXA~5IuJbnm&re69Wic(X{S~H3yO8KGMs)o=|4yJQKp8cMJMZ9jwUf4DBAhN z{?IE;c7`+Z`;HHh&-+x4uYq1zE&XAiw-$efa%)p0<{M8x^ z{fcK$8R5?4ufE~TSn(=&{xPDmYJ}}m zhK8Sqw-XlzSPW-%7%f;v3zpG>WgrWd(IQ~9Z86%m7}0Hu5nlvUy_{ZGS$y@s_x}&t z_1p)#_g?fay87Sy#nSj+PtS(_*Z+HX{U2Qh{cmpiCqw`1FC6J@(Gj0sM!Tb<-O-WV z9UUzKMq7=et;P}EY9wKpi3Fvi1VLmu|Loq(FsGu^IOgm9_ifVa*dMIh{qEU*4ic1(j$4e5Ta1od&~(CE|3rBG z*Xtj?<^Shn`17Oxzl8kXi}FXgH^J6KU|BmuhvhAwKVOaiYr5yK^&VmVFUj`blxx1Y z*NdD#k7qr@9tHyKu&k|*W@WE?DA-@Y(qHpram5Q~`D481cMhDKtbX&(oh9$98QKyb zeE2YdTU@W;dTjZ}Ez51Es@uNowE20`|JlCZ?~?82Govp3fiIvSV!g=o=Y7`ioVe!Q zUjOHmelcG2Nn6lz|M9Kt^-sUX|L3jzY+hd+_iJf<+uXUbawq)4R*SN;x9d!I?|IDP za6MG4n|Iqbvzh0gdmLnb`}Xa}>-B%b|NJ@s-)8^6*ZWWH{eJKC{^PLlMJ#h7ycB5O zeCu~RxHT&jIQ#ngX5clRq@_yrYs2fmhCi>p|NU>}-0PLGzxJ^w?6i55|NmS5&$;)1 z=Kgzg{@)aJ`=6dMI|?RVlhW@B;!^jY_du|}LS_92uJtFf_g>5XJW2oOBpoqn2A;zQ z^8a4T|1|gh&$oa6z5j3bKMKA$2);~<@Vc+cx_!S^?{6!wXfJ=%Soh|5T}%JJ7yV2N zrW`ssS-s+jvfN4SJ%_YEMXmpOYtIw!eJb5Y*%%HRe2Cuvb#~43@;dAN|9|g4zyBIZ z;UIprd+&|zkA?EzIrlu2-X}Oo;`qe-zt-M=(q8w1{m0(>zir=bhptH5aX)*{vFs0e z??2`3Irn;xUhMV6+3|m`#y`~F_vrMG_WB>}aUx`S+Y#*T?e)LQ86F(3|8xAui~fHa zaTAUv9sC>j*fg%WymET^g5wjSX?Fd;$nZDiTtmH^5>%Wf4v>x_3%*Z=bDe=^@jEz znC~Cw{*1Y>4bR%Mot}>t+r3)+V@ChaimbJuaQYoOrqNy4JJO2>O2c<)c}|NrIuqeT8{ ziOVJ*L*u_btvP$WhIdopcH+&iO~y`#)P_#w-rvk#g}DF}cO}O7p=M-hZ!qe>ncn(fOa(Z-v$Ju$pXZ z+`Ff7^WOjHd;jRnOyfVN*8ee*|Izr~|9U9}OH|`uKaG3$^k?7uAA0BJSRMvdfS()X zfBds~;kcavbyX_1g`-oZSMQwu++n+jXe*et(y7%8d z?41Am<{$qOq}7n~U$5KU2P$QrKAWBYOug>W@t;flf3f`QUXZ2=T2KhJyrGtTBAXaDou``^||wl)6u)cbcglEH7!5y^GG z-hYR?%fI5# zz20kg|C8?hV-FuHuK#Sc{^;EDdk6Plg_d2UWnE>^3f8Ar;HfMc^#hoiD-Nx zt)qt9n7lFK!|wgR+3yq_=G}AIdao_CZ32x!v=z*zaeuGI&z1i1q$E^aKl>4lV*=))7A2K|d%t+^H`)Jz zd;gi>`M*NqUT=+)y?y6BG3%ngPd@Vg&$;)Yy1vJ}db9a6P()V->cVyskhxsH(BAXU zx%YqU_^WRmuGnl|oeWNq;;<})8Ub-4=JzUu>p!X2AG=$A|E%`@Pudrv9yfx*l;FaC zhovI_e)RwEdA)xByzr`};SbJPzklGm5nlF_xhk4@YS53z_Wz96?f+L5_i}1nqxJhe z&EiL~ryteLH+%N~-hEt9G47FST+h>@lf~7Tl``gX^Wtb7u|Ie3|7D)}Of~M&#x>Wo zks1|bCLEjXN0ScT|9jVdLT20}Rk_nud!NNa`@3kJ$oiA(|K7^~aO3zsiS>U@tv~U0 z`~72|LFs_tW`^g_pUuvH*#G~Bf90LW{~FCJny){GS@Or7lv?ON2ckLlxfs;gI9~r} zxy`%6{|BV^9DMyzKN3<8q7^vYpMTE(-@51g?>+kOKij=Ox&Gg)^`DwR1uemS2&|Qd z&1;Vo|4DoQ zncw@~SAV90!*~As_4?;$`~TW?Ps8hGey_d%{n=0K!ChQ^G`z;O?$z?TY4FxzWgt;0 z-SuOj{MU;$kJRgo@;{s9p9FQbJ|VhR;INd5!!RadymBVT#A$S)%gEk?+1E) z5Yqpcgv*&1e(!t#`(x$&@2q>CM(?w%|0rI6uKN`+$y$FExc#19|LytDp7TFUAU!fl zTQ84YzZU6#|Mch0`F}R^KT6I&R$lkK{DT9uO@UhX@0_oaxA$uqsCw<%UbTArgZRh9 zIQ+kq{bytQ0e5Ayd4=t~e_xh=Twi*a_c}9Z z=(qSP_xB0yHdEq%Z@vHEvfbO@pA+SOD*iK$!8)TA|5}m1Qe*$O?EMFq*WOzGN!k9B z^PY|irq}Oe#JSXeORqn8w0k#bEJyCYnE9OoTZZzxrRL#(PuG17uWuGs@B7TP{zK89 z^c_FS(-|#33Z?IOwf{b^c?|!7*z&t)_a9yV?^S-mqa%fJ4@Kkp#063MZAatY9Q|=M z{x9#n=e74HgQDf>eb9go;jMmR`9Iq7Pn6eQ|Nf~h|3}rI!}foep_!+?h0J!2lMbxuMJr#J*4BDd1FTI^x z{^|Apf7yH9|K4X#xohv1-JbjB*ZTib?|<)me->&BYQ6Yw$K$-ZPwMqe{eMpIKfg~@ zf%iWw{`c1S^SAfCy)u}fyeAiPOe*|?cLA`P_ z&@kzb?O2AqFk9FAuP&DVn^E`JylytQXQ}`1kv{3;B*-mPTqYtntnYu|y?^BOzIU%b zK48CpX8&b6T|8w^KgYC69 z+Cdw-k+)3Cecz$}>GuA=(V*6|_c-aW5Y?-Tldw_F~)1)j`?&X4aE@SZ|=?bYx{pjon=F`p252sE(39shwf{={MV ze*&O=_e6$fgpQcaQ%(Qp@X{Bgm+boM{l*BLic`V!&cO?4o$20)4pS4j@hcklP_*L5 zVfj=0uM?Fb_s;t?W%7_y7OB zuWT=`i2k>isM7NN*JS%|llT1S-fyA*dz1cAiVil|3!3VhBwqhf{CR5px1|w!(_#79 z%*?Ff)8zTbrq{g`|7;rn`Rbo)qS~sKuec=B;p?*Dl2n^q00M&b6t=^{vZkDXx_e%R?Pr6Mr z`OT55e-JhFR<4Z*2F zJKk=y{<-J;@0@)k zgM)ZaZYaM~D1QHY?fv7+YfhN}Isb=e-Rtdj?e+cWjU+5iMy?t_W$I)8 ze-FM_uK!-;4=Vxfe{Z%w-z@hKh;I(7$jN=ETL0bZPjpUB&WEM(zg+jcy}i$r;hqVDl?a3V z&&l?nNvP!opriyF4#kKU8+%Z5{jl|(!`2@iK*h`<_xdmHNJBmdXV&+FM#CP;-g`K= zVgbT;dN1y+GuJ=xr~f~b$eOs9sXy*#Jia3r_Q9d3sHCd>fA6#UPvH+zzMj%QzTdU- zMDxv`_uenH`?irmVV%Kr-~MWu>i0kAZCQT*)PHyB=ifFou}_-3__Fn%{7r><{Mr=? zmX=m~4;MRrFWXzm_^keq_=1G5UiydsyP>Ex`9FC-lltoLI?I2$r<~=^ocms<|8-+O zD??%H&YY?{k8f0;|GvcbZ`eQkTNc~fMQtX`k(qn%Vg2OZ;_8Tl%qLG?d?`DR8`7h> z|D*SQQgLy2c-_QNT{~Fg`-`(5GyYGwl{*LXn zx3{w$0Hr{t2kP~omV+uEk-omZAFwGKBqN~d`uX$K`+sGF>JD|e7su<~xP#X{%$pC- zbqHs^|A=+P5-a2!mh=DKod3Ll-L_+T)ynDEy@zv&1$fj5aS+VI{D05hR~$2*cQAV2 z)7hW%zgn&T90eXA?XNtu_~SCh3kxg$Jf45xdHp}}Ptp6oTK|cL4l*E>GSJQw_b0HU z|K8ru%wYfTvHdY{8f=1>N=RvNJva@%z5h>^;m1e$|AuzI7Tb3{XNGw1d(HXppR(3} zdu8+IA^%fsv4=TO`%f9QF#2a3_q;T&dwccn?N0;Wf8t=6_ruvAG#9G=bM5_Kvj1$M zae`FVz@q_HUSmCp2C^({-;3^jE#E8pzdu+uI}d9NU~}S~!sD?u_rKT5&;O)3|H%I9 z$BwZb|Ch+z;H_VF7H-olou)H#98QeY9!0&C{1Px0csx*?&^DKSELe3QK62UXM8}|L@`a4|eZA&jYQf z(;{{t0(+mFj1?Ks!4lX>c}N4j4+{IMmG=L=z5nR%J?E=GzW<0>#9>yZ_5Gl^zlU4n zf0tIgNtS1&{#kqGHHXbV?ppu53N*O>nOIjv=kJ|+|IgX`4-VVCai(na3tkOC`v9Or z0xNb-uePjv;$AoBdiCDx&p7=nnErdRvJv-y9H<$ne*n>ZT(2YO^7x#KnH zl5S|$rhD&WH^$z22$!&<4NEI>vXw#HHtC+v(VviH1G_4@6fukrsy^S_4W zA0*a|btoc{12|31mvck(HnNroqR)cM!S1)%Bs$Di%qb61?+UQ_(z|+h|7ZP=sqsHk!R@{0>9k6dby;gc8+#tltA6(^H16rt zAJdUX^q>_ZJT~A7CjOIq{TKC^^1EMc9trZN&Qe1f3?RIH1gWGz9+iXEb+A4dv|Gzs z`8B-06x?zEHJqT!u!yQ)4%Pg7UatsR_jTHO@8#Q{fA9bQdyfLVuS&fJH2CD?LATTl ze!eTLp9YQ2!M2tqr*Z+7!y-xnvJB~J^_|abTdq+U6#zIgQKehf3*OqG)3y3L^Yyvb}L8DEN zLHT`Z+)L3NpU+uW1VVEG)tiv|t3Y+!Gx55I(<@&E&p(yD_uA^@*}sXJrmCCtWA6Q* zwV+J*F)jbwF;FFIs=o@FL#a3H0yQxH@zwahS3#>y3PEe{KUuB+JZsA}1}tR-##kPw zgJvseSJ+d~exCPV{PNF(W(+^Cr+Aq!u0)!>_t)zER-nG`x!Cf%M?rfsHpb`?RnjMI zd{AEhoqxx}Ht7oZ8qNB(&_JMisNR3HH2&AppFijSR}EXO3R=4Edp{iSaqM&KYTH18 znPwON;aUDaHE^{57o$}Hy8kLDFM(!5HtgJa5;Q6GiM{^AZ_oxY98;7Pd#_j5?t2=& zulMj_XYjZwXz_LZYAOaHWSjn`eeZvsoc||N?(d7^AB62cKCZc>Ud!}fzUEnao!R@J z^WGm7p7){R-nZQQvl1nK%zuKfC-JYl=H&Dm$^7qS`RCtmzkhD`p3~Mv=WV}#sJ~cx zJ@z>N-wXXkpU;|q25o7qdH1}|?7uLsGXx-W25{eknv&q1OQ4-)%imXn#`|Zi|2b>@ zv-?@_1_ZRx7>$@LHsINF|My=0`Y-PFC&1%3v}zQ|Lk6bLR~*Z&dA+^P8r(d(xj7xO zp9|C+MoRrqN2~`ga0G8dOFuW~VR+rs=|2zf*VOM?#|&Bg0{0l1OPb-08`Rc1@=PVe zo=4jIl7iu^SSn1hetwo`ozqQ=}gShk1$#5R$R7+%J3cx&bM@@js&pOfup zPFDB781)#m;Q7a~{6AImKD4OASLMQMl190|FWf&bl>hZ`&)4Yvyh}y&Vl2*sR+oHc zw*OGO#{gc=!V?XZo7VP7^NWirHi}mp+P!;h*9aOvbP{@O0h%_O{pSMzUxxkPtM?!O z3toF9*3J68y7zho@4ct7_j*Co@=IS^<$PYf|Ce^n(eN6{{~!4OALvek=SpaCL-t+| zcxa(D4v}v&*$Up31?pWt2XDUqE!Xh$QwL~Z_Gx+D^X)%3+y4%p_veXv;lo3&QQ3Pm zyfo+7R5rQWJes)YkoF#>^@BKX1*48!?=B^3T=Hugw)a#cx&e^bTwT_r{PHwKz z{PPo=*l*pw{TQ?x>&MUe|10bMUay~8{eJIsY|A3ZT`Guu%;gibL#%$_~_OD+Ea10ESUxuS=MSl{h>tvB!6mgS$8sM}6d zxBq$aPWk=XGtY~0T}(se{u*+bgJr%FF%n6maW$-CT1Z7KJlE5qs(?lw=$0Vp1|rOf z3##{>kdr+;o8yfMGPmf#4aZZ4z~-JXdK4$&x~bkGM=pP1%?G5h5zLq%cjE%gaM(mL zyz&S2=m>6{gz5g-Pwuucc&f(T#=^TA3|IF$q ziZYx(TCgD6prZxLXu$%{y_hu`yqQS5&Ktc7meC?$fUjDEEO3K&Rf$?#02|dMe=j<` zbuiktfGjf}!EK8XUj)P!pY46N{{(#7X6XOF$cONh!*-T_j%Oi3DP(aitQR!ewjgE} z8D1=nc3Or(r)9(!ETcuh=uOU}H#zSay~%k5w*o+?`sAdsgHRY$9g(e9}FS#J0&1~H3gMu)nG)lm0n5inW= zj1~c(nMOyXj$*7;!8^@5j3&HE7-k|t>1cO!v^zT59UaK-=!brZww_A~j62scFfcH9 My85}Sb4q9e0OfP9vj6}9 literal 0 HcmV?d00001 diff --git a/crates/bone-render/tests/goldens/construction_dashed_256.png b/crates/bone-render/tests/goldens/construction_dashed_256.png new file mode 100644 index 0000000000000000000000000000000000000000..4c3ee829b9e85ebd775c60658fa58ab254b0dae3 GIT binary patch literal 33441 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%Za8J;eVAr*{o4=^uKiJW%q z89fk3`fSZXA~5IuJbnm&re69Wic(X{S~H3yO8KGMs)o=|4yJQKp8cMJMZ9jwUf4DBAhN z{?IE;c7`+Z`;HHh&-+x4uYq1zE&XAiw-$efa%)p0<{M8x^ z{fcK$8R5?4ufE~TSn(=&{xPDmYJ}}m zhK8Sqw-XlzSPW-%7%f;v3zpG>WgrWd(IQ~9Z86%m7}0Hu5nlvUy_{ZGS$y@s_x}&t z_1p)#_g?fay87Sy#nSj+PtS(_*Z+HX{U2Qh{cmpiCqw`1FC6J@(Gj0sM!Tb<-O-WV z9UUzKMq7=et;P}EY9wKpi3Fvi1VLmu|Loq(FsGu^IOgm9_ifVa*dMIh{qEU*4ic1(j$4e5Ta1odj81ruuA~`V zNdsLZg=Mh|-c>?FeVKX1|93hJ>T!>F!vB8X{jlvd z5v_y5T<{P-yU-_Y*YV*4)fCzvbc_Aubgf}cNMjsI)9=dkr2Vg4`4 z_TQ9izPQ(moIekD5UkvWFBgR`x+7(cAuel3TKf6?d7t$=C$4#ue=f=YZM*OP=KB-b ztG=KA7pTO=Fthjl|2O4N8t?txTt9{V*T3_1?s6~g+W&U1TmX_tfq0`5q+0I9Z~Nb} zl?%%Mzmb31NPS~&Wt%aadM}1#Fcur3F^_N|7N>V=Fr=7K-qYLW?dSN}zw#eubc+AZ?@YWW*gd7HYYe;D0lc03ab_eiQ$*{ds zq_n2%TS%&fiP{50O3&k;GL}J%f#0z+TCj}tafZ<%V3=;&Cgt!O^MF<=N98~YF@|2ucizMm5r?tJ`V z|8)8OXNP}o2YCv-ubb-0x4yf#mv`S6@BJOyYj1C7I{OS5il+L-q@|^6 zUQVwQ%>TVDpSQuBw}FrKfd9WI{+~PN|In0?mHn8Ho-W~g>-A4;ylnpIn*8V9ocX)v z?f<)0{%Lvrlhc)m@n)(QiuYfY#y#Kq79)I;Gv-e&;{@L38)6+c$gD0QE!xO2wOku#WSAFQ-FLmAai>gKCVdtAR zb-ec9j=n$3y!TUdoxyjTx9@j6e_4P2)d!*P(ddB{CoL^4^ShmwL2vI3L3eleKh^NE z1ig&hfArWn&AxBviuY5iL~peFGyA_tL*d?(+AYV+>rX|0aFG9Zw*J)LTNWRDGv8}lCxxNB|G4)4r|h3U<$v0zapbQ5Pwl$@>iecu&-?Zr636%6YuC5i z|6lt4Nq_yf*+0L-OJQQ1+dcWZeM|Y@HS!PN z?4R%aE4lxzh36F_(kG&uj`chU!o5pZRo49fp7&2~<*(C;|9^1(31*ymw!VeP6G9YN z-QC-<9sonc87-B8Q!agcH~+VA&iq|{@xQM5KU`V=Q`+VN+T>oPqu0xf+$XMx8its8 z0=q}i|9aH_p49)KegEf8@}0Qny7(WR+soHIKED4;?at?!_2-MxOT+W$+v`8i-&Frk zZr}II_ob|VPKOsjM2&`BuiozeaO3))+iJcq-^2P_{N2O*uSuFEg^x_Smfm%ap99LQ zHumtU3tAxj-1%;y{<+)rkIIYQukC+)eBY0~q&l>>*Z1Cc?Yg=V2CI8ol@WopvKHnAr zJ*tY7BWlY3ZF!&m`iN=G@8kOw|A%2s>epk67yr~RI?Q{Wx!`(i`RDkDfA{|?|M}0q z+B>X$_n&-&`!?rx*B?Cp>H7X(d;k2@ue1JtQ}5s1NCv+>M2`~T-4tyK9v<<@7r_iJX!qoG-TeGt9pTU8(MJvbARI@J z*2V9$=KUZ4#edBHb7^}Wm;c|>_aDS3qh$knJIVIP(fD5T{~y*rvbOuz8zC3-WWb#S z?#<}`U-tg=_4*ImpVp%fJN(-U>P}3t|9=$Hq}_QR-u@tAwbQ%r3-5nuw|UrKKk@#b zednLYlUai|+P%oGYtR3=;`$?4`l@u?_iOe3w(a%5%NZUVum5u#yrLp*0yG!Kzi!_5 ztbE7ckM)Q2>prU&A-Y+(b0>}!3203QmItf%Lzi+K_;aKGuLgMhaQ`v#3T=2J%ak2UeR9usIl(N@w%4& ze=qtU{ftEGwZ{Lw8h_Y&&tdD24)))Y?GJ$mX%|&t9$rX9+ZBF5A!4Yo=(4Z*hd;1d zI_mvDOZztCpN;1$)!%I=T>Z?OZ~bluw`PTc_0E^eq$}#+l{_?3WXtcgir7q;d#B_w z@17MD4%nSPk8B)fTM^$8jeqVG9*^}n$b9ppuld~r7^BJWc0ATw@}Tj}lV!8>9=LwR zJeV+!@Dhij{pCNNyifalLv!En;CoH=YoSSfSfret_h;9Am|pZbIj(b1W%J+L>)9FV z{v5CG0q36h$K-WE>em06CV%Gi{+GW$eU<;y^(Py>lUcu(|Nk@lr|0WGTK{}J|8E*G znS*+RU*D@iV_y0H9>yOnulXar(Pqbq{m0-1KCB3e$eH`#@4i3bKYp(NFOm26xBY`U zXf`CeXAC*Tl)SSui}wF~W*F8!9nUB;QA1jYUJIFxe}n;8mVOY^3x}6tSR2nH{;)^W z>vuB#b>AsC{FUKO>GivR(yQ<5|L1+T_v`zgt?U15{4=lI%>Q3%UG0bN&vWJfSN%!f z{i8gc(c+^}`i@uo@8jtw{J#n6vCW@ecli60-}Y~HBW2%x+7B8Qd;gEs{&f8RSKmK9 z-~Zz+ZUYJT74CSw{4rtuf984rw(mczUvpUr5UmV^)y}ZfiN&viM>~((|2~UU3lcf_ zMM)ONcG5AtYeSFT1AOEK-Y!GR>?AaNskhih|HR?AZ(l23#s6c|@1kci`8j>hVfA|R z>vlgj{`oO|pZ<57x9=-48epG`L1Sy%{~vk(%8#)x*`dvX*_x={wH@ywM{t?s)K+5*e zy7msZ)AjfB_YZ&P|18^L_6af0MUS8qpDJ?WBu3kHp1kIU$>LX`PL!v|P~V^GIeY#o z#_ah061`{wO_gw$+t01#czT4@}-zoba9pC?k8Qd4E_`m*N?3`!k>t8~e zmH%wvZ4Y`(uH0Wer7yhpHUG2o^&f9X>P^4%K2iU7)t#?P_NP=9OcYPsSlT2l_j8lY zSN;0t-@0Pl;Og1_&&&K}TPN19r)vzu7P;Jce|Y~-|DE@D#C_70{~Bd;fxY(Z`wM5q z?z{)pl}H_LXsft?13ize|2{ik?(wenJr=O}>7PgQ|GCJ$@U{Qpy|du@yO*| z|E~^fpS}YzWHTUcMVgYQXE$NEw;^JS&-Omse*)gj4gLQYY3TfYIbzVAg#@Le`wK?z z?ijthWAyG0@RA!)(+11f2-36(db4bF;AQms9(uNeM%xyMd!y*uF+)t|4$C3{ZQ(-k z)%yM2_2ps>!uo}6tN;DJ3m=mdcO*gSh#%1%y%%isUa$eZ7mV;aP^58QcyF2T{518Z z;IP$b12O=Dt(8ZQ|7wr_8O;#) zrZ;ZV>VNT0`hPF!=db%!|5Cp0X+C4cM)NAqb-(I4M@OVaN2Eqaq((=iMn|Mz%}^}M zJw{uNSjIs{TaD0DYw59OVDPP&LCO>KnB%5#$B~3#CK8m6j!2DOC_Q?i^gv!H{YSa^ V=%l6QU)&i$z|+;wWt~$(69DJBn@Iow literal 0 HcmV?d00001 diff --git a/crates/bone-render/tests/goldens/construction_real_256.png b/crates/bone-render/tests/goldens/construction_real_256.png new file mode 100644 index 0000000000000000000000000000000000000000..b241e21dfcac709bb798a69b01cd9f77c5a13e17 GIT binary patch literal 34025 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%Z48$4YcLn;{G9$;Re5;^VI z$N%N;dQY5LUgVv;F8B5}D9Y@aH_dDPJR@VJcR$OvXH_k0x6Z!mfBUxjS?&!HIt&qd z)Bm_v+&C=9bs%{DFOz@DI%3=m(@v}Y7ZmGeWjOtG(tnQbqf8A?i%!wNO4`Vfurub9eB#Ckh7B=#Kl||+uFr7mo?T{K1KhyFa^C_N=KZ>| z{38|vB6P$Ubf$YFIW5fJk|E*W2VEo~Jg&twypr+4w{M=Gnc#kv`?jH->BE)fcD>lV zi|MzjQ;ZCs+w+NYEf&MM8@6rtpCz6E3#<=g{Z|$W$4DhIJw|%pa!RG5q|Ht-+Fue;)Cvc~&nODSc;LpQo;#`ZxaHa)s z-%ftU3OCT^O(H+*gO&cZQg{;{+-YB@Ff%;kzyF9R*J3eToFO-VzKQ+;n1K%y`KvV; z{A*ub{((rjaM!`p2|OlNFPC93sQ)2GlxwjVzK7w(tn56K|E_sE9#%2D+x0qc-LLoM zAK#y2DtM%NuHb6@e(Ag&at*uR?V9~xi3Ft?)xVciGQyq7Uwy-wvEo(m{9{CA)d<_C z3=KaIZznDcuo%wjFj}yT7A&I$%Rm+^qeZ}I+hVkBF{0ZRBfbczdO5wWviRzM@BbgP z>$wkf@4e_2|uo}LZ;umAV(`ail1`rq92Plo>2UpUg+q9Z=NjCMyyyQ3q! zJ33kfjJ6s_Ta6>S)kwlH6A4O33zpG>Wwc-!(FMzB5inW=j1~c-MZid&m)iAgmh{>B ziSSvy)&G9S{@J~mVNOM-am?5I@7tu;u|HV1``xqs93&_m9k&=Aw-_C_7@hDQT}d;# zk_Ng+3d>>_ysLzU{z{tB?!`#&UaZ^s%VvPZge`MR zBq6uQO<4c$)%u5T@Bfo!`0-KxzoFf)#r9p|PcT=??P0)~1wVhj8voaH&tdC5!u(&7 z?Y}A4d~vTAIe#ARAXvE#UoHw?bVte>LtNI7wDj}&^FHf$PF!*xP3rbPHErxCF>E+n z&*I5&??w5e+@CO)?2fLg9q?7PI18fD^{<#K^WjA;e0}ri z!urvL^`i^xv8}-$Em%f+Cu7id2Z-7OLrQD9zGZaB4#svMJe{1;w#CqHTkKJY|9dt5 z@a_G7trfYGcV|H&y>gD1c z3?$q?vav6}Qz#zvVa^%;{~!22FHyIhxc~R={Z9+~t7UfY-pv4SO2YexB=vxa+0l7F zJ2|;I`TqvS153rNAH8_dQNJF2pySV-!sD?X2bnz$e1(^WyqiAr&tdyN%=Z63+n+xF z=Zt^x@3-4OBX*inKU!_$o|ndTZ?E3H{b}I)PaF*MemMI-Q?GlZ{&Vg9U$Xyf;ei8< zOzh(qf(K2n#~ePr=gjF3Z22GB_Pl$&&o2Gk9L5Kaj&j!=FR!t_|7-32Lk}M+{ttti zLxYk#|Etyd&rvnMme*_bSDsn?aT()1=<|3vGe?HLw-vU>lo z?26yp>)9FV{v5CG0S_XuTGsus$p3RJ|IF`w?|*+z(*O2|;m^JIf92-?S~>s7<$2#s z{%wT^4E0k@QgU*${@+Xbk8W>%v?LPnH9ln?C z-TCT&>!%6qn?LWp?_K*Qnb9F8QE#@~cZ+ZLKHFyc@1Od=T@Qw z5gK^nPo_o79qIr7!@uBhuldK=`(NjR6Az*|#kw7qqV{$4-gB?_h|T}^a{j4gxz8DU zG8sxVpipl1c!u$T--rwCVuK(fH`hTn5ynFY_0j`0_q{p&>689{E}LH$ z`=9N4y)HR9nYm%{V&!)`pUchr(4zhUl>YxbjsK^*d$;(``_OQNd;I&Y8#f-@u+L#A zczLwX!piE={cL!>0t-`U@`(%SuX&>U^K1Nn-(!W3rpEsiwW$84^F10i+DOvaCEBj# zJx{#%iO7F(lt1%&&$-tZz8k;W9;0{j?%mX#HcSZ{Z#?+%@v)8=_d9c*?7G+6>$>xQ zPRoCm|NmP)B_CiGVJ@_-rhGARMsWRxc}P=&6xi=h}g6q|9aQ!b&vi3e>s2i_U*?@T3-0@P$leq40Zo`k3QS~=dL(xUZc#%&tK91y`ucz6aRlR>bv2A zM#Nzm+pFN&`u(qU@1Jn*d+-^O49P5euK$9UX}jO=n_XVLz5H3He~l8noPfG6u)j*g z{sXi9nXlL5&zIL;FaPv%{=b!fvf&jvF=2lk+!C;R|9Rh@+q?I2*MCy4KLb7{guLX^ zt)=wi=luV!aj%xf%>oCeTe6O@mi=tp<2Qe9Uz5U5jK z|LFdhmjBc2-JZ{Uac{52wHC@$29lK6uj_8N>9GH`*#6=1x;O4WU-bX;_@_)}D)&}u zvj6wk{^>F4{71U?9-aMhdjB8pdAHHZ^vXQ_-<$N0pZ|MDzu@`Y@(-uh|LICm`#%Yq zjEFlC=7Vee7u7w_tM`eo|Iwv?es1}_gZrjLBs?2+jbno@-|8si( z1B^+k^XEZT*~eo0@7$0i@@rZCnf)p7%7~arpq#wCPd|>^PXy)vM{DoB(*5zhilj*@ zShZN0X!rl~{t4eJ=YN0NZ1;j0l*TRNCcr}w8sbfRe$L*{djFH|{bTU_xW@pVZ;6~^ z`>^l*@4A{-%j>Mxf0SB(3f#7~CGQ-XxC!A^OT(XmTYu}`|MIi@d)a=fcp5wqAqjks z!TkR}<4^qFbN=_IZTY{i{W&@Rk0GMTNA$e2&BspvXZ-&^^jADDuVep5wwu*$UuwR2 z`}XrE{r^UPyx}z@MFJpTkz5`~2BA8)y@E8IWM-v86O=Ir$v)A(OY8UuVr;#!|Pi?Df*G?Mz~`j zWRDE;vP`~5Q(eKy;HSy|MdPVY^x8@ zcO(7VT>IvDos9gih4SaL_dPmY{P0laKXZ%9Pa^fdme-#%uX}v`GpMckCw=#i@^nUv zk3#7?UfqAb_TH_u2jvyyh|d z2V%?bo~_?@-2PwZpR4hIRYA4Xq5o?3Kbq~2fV%6SW*oQa`k#Ze2IcpeG~<5{<>ws` zue~V#LH7Q~w|}DV6deA_aHsV8-9O@v1dq+(|Khd&%c(z4{QsHce-z6<25vqfDm!TX zp}%T+?ak?rpVz$;ueiOvHXGa)VJD?ZgbfdI9*F<{^?u=b+wUKa<^N&(6HQ+IVsoLq z?s@s=s`cNiY+g8;e_oHCPX3wB``B~)C#b=8?|be2*>4&e{Zcn>n!(4K`sNXfS9@j8c7WKpVaaXZLj}b z{xM1aTSi%V`OoX*l^#3qe|)U|qrJYu2Gq|vaQ^?F^B=K<@V!50?>B+^H;<3Ty)k_A zF78Pkv>>c+A+kaNo$1E7;Kf3q-$iDYb_TDs5f9Fa3Yit>kaA&@5{qJ4tkAJVd z&;MbP{?A8q?CbmLd*S5+v@Tp<`07gLzi;{f=Y6l}FMn)a|Ji&8qPI!HQYHqarrY`Z zyU+i(%*ldd;cdd?z3rpcf0)`MYv;etY_H-Ex@yFpKAI)FR%Yz zUUC0>ZT`FMP;or9433pzh=vx#2nO(So%cWb-nZm}mKIF`j|#>=CNl-?|IxkwjOq25 z)2H|UIsIY%R){4w_TaI6>ph39KRVcdOSV7cUjN1Y;~!|=`yW;R?|J>v+V6Ky&))Y^ zx_Cc4&Ec+-{y^OQf$#kh=D#=k|8jsA03lVskQxu`!YS9UWGxda+JFDw+xttW9LnBv zZ1qQk-ymb}4$zfda6j$;US02y-Fv<6?RBEE_RpP<`*u%gV&61*cHXYW=jrfL3=)RW z!SkCZea-J4=*C**5|P9A|GmB6TeJ0gMQ`}yc<2aFW!<|SkM)*3XngZz+3Y+-i+yX| z`|tl6u1SGP){}psnF-&3#pmMVvgL}b{3+_@cT1Qt))S)mPy8x*os7SAAI0luX0O{h zZF>FB=?@Wpz#7x=-pAH|f6o8!`u%SAbo1KF=Ff;JYJWl&bUu{5*UA2Cqx^3{@KQ^R zc)^+u$&R1*o4xN@_QwzH_AT{mp*aEn;?nxH%Wa;%{PXVpKfd?B_q{($Ok8n) zx(ZqY`uFyJW(H7S^B6csHo;Oa^q^6p25ies>wC!?(fnQeNWIQT{%fH8*_A7HFV*#&1$aJdoyl$xK;s-%I2B-~Zkx|3EeV(^1R%e=`52 z;8ij-BH|xkS$X--+x!1)-+z>Qe+)FfQ9w*_1@|Jn361T1S0ctV=-ayZ*-z#)oP&S; zFXJC?@BhmMjp27=X-0r1A#C@2%ieFsUwNXrVgWROi5sxIq5k=A zutAq6vG>2m{Vy${$qp_pf1)wF?;{d=nuO0KjnhDwd&ycQ*P2nl_*G-|0l)&IU4_LY22%& zmEGGbjQ@So|KEhs?y<3lk3N4eum5~~&&%2S3`rb(rmz_QIo*Y&{?GCHQ@!T*jzsS{ zI{V}F`k&%RLt4=G#htissquaDfA5TcaL)Su1JEebNuPhJ&{BY;?l##K;-VM3`anZb zk3eJk&9I?nq_GM}E8pe(>mIeY)lY|x0tlrv9Up`E@zcsi%pRuqv}hW?L&k8u}$ zzgzw}@BO!Ppz&(-ey+`{!2U<X%Cw$Bt-om!0%lY}+E0%^=rS5zGd*A%C zXLZ3Nwf_IV_&+|>`WbeZzTZ5Xqo7H&4{iBB++^hBKFGcQ_zpaB|6h!*Dfa!x&iOx1 z)?9I~HDLe^(Vlr;d+#|ohyAJE^XBwEnfV_j=O5JG_eh)hgK7Ndt2HmD*BSmtER!H% z5R2?w_5Nz9?ED{{^N+CC9ALjxYJ1t_)9n2}Xa70k|Hq>Ki+lZ%pP!#sB>K&@I=gz$ zsns9K^1pur)wFtB%X%Ik0Ij3>1e#6nd3<93bx4~2Bv16L2spyX8-RtJ&`-a$5ER#4 za095a7Nvg!2?MkL1<9Kt!&b!5HUD7g^3k;vhsPOG6N`$89DU{`^#5PIe|IAp{PrA? zT=(mJIi5)xJW459A%|~h3QHJ}*%n4Lrm?KArbFit@8uS_$4jxKM)La3NSDaKlQXpK z`k$CpK}az{-Z%}C0r2DuA1fU#SVr%Fq3{kE!b1zAMF70xHd+K=T*yU_b}yFF93B(& z=poX$Z873o0oP-S7yr~Rg0-QFuh#GHt}hpB5Y{hjTmA3%U8F@SBq$}go@KOcfw)d@ zv~2;shj6rDffe!e7-}DFTa30X&^GQNRkovTiy?o-?(;GKw^}l#0(AF%_rqGx2bx-pdD>j-}d9M3a&pG1TqNAJ31(hWi|w|1e>Vc+DL6&^469j8312N2=Bs^u+E?CJ)8Khh#v+WM-qma rNKiW3wis<&j9yHxKY(qEKgxa2vbQYcx46N;z`)??>gTe~DWM4f>vKr= literal 0 HcmV?d00001 diff --git a/crates/bone-render/tests/goldens/grid_empty_256.png b/crates/bone-render/tests/goldens/grid_empty_256.png new file mode 100644 index 0000000000000000000000000000000000000000..09592440549259e675548136bd6f1c4fdecb663a GIT binary patch literal 30827 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%Y%MxHK?Ar*{o4=^uKiJW%q z89fk3`fSZXA~5IuJbnm&re69Wic(X{S~H3yO8KGMs)o=|4yJQKp8cMJMZ9jwUf4DBAhN z{?IE;c7`+Z`;HHh&-+x4uYq1zE&XAiw-$efa%)p0<{M8x^ z{fcK$8R5?4ufE~TSn(=&{xPDmYJ}}m zhK8Sqw-XlzSPW-%7%f;v3zpG>WgrWd(IQ~9Z86%m7}0Hu5nlvUy_{ZGS$y@s_x}&t z_1p)#_g?fay87Sy#nSj+PtS(_*Z+HX{U2Qh{cmpiCqw`1FC6J@(Gj0sM!Tb<-O-WV z9UUzKMq7=et;P}EY9wKpi3Fvi1VLmu|Loq(FsGu^IOgm9_ifVa*dMIh{qEU*4ic1(j$4e5Ta1odj81ruuA~`V zNdsLZg=Mh|-c>?FeVKX1|93hJ>T!>F!vB8X{jlvd zNa)1?>O27m%QIbjj7|LjBPlK%+Z=;CpOF$1O(p>WppzBz_a% z{_Dq%u^s=H$lTzqUv}i;MZy1Z@ZEgyeU-57ZSY;0)ZYte8~404u6uj+?(I(l-+$s@ znD@ik|CxH-BlVwa@BfnhXA9qphTC;m_s3Dx`HtRu?)4tA`Tt(dKb0)^Ib%;ILy3mm z{~zife$M~j`sZ5yU%vlgxZOj;(9$(CGy8E#|F6=$Z@KrS81khUWVssV|2aATnXvuG zU<)g&NB5IpL)q{hwD2+#z7G-WmeO}`tBSAw=YFclpZ2_Z|MXusjx#mvI22=M`>ycK z-sjbq=GAG3|J~m}q%umnzWa|JJH{6OYiazE+xy-|Ge7WUUf|65VY2$u$A z-Ty?x{emZfP<`j__NuwpD|PMuUbdfl+~(QEipvZy0&M<$^ndW#{y#S)!LG&Q9$0pw zeuAyu14^*@-^=n(FTVFwkiq6-`@A#$e~$Qnir)X#`cL(Mgx2?4H*P$*VV}cL@bYLM zIBjN=n>Hy)P4z9K+ntB}c4vx;bNA7W$)hJ8jGlNf#7{gJ@kIa$3%f{AI=TjIbPd?( z8nDqdV54imV4V{zgB*BIiW==+jCLk189fk3`fSZXA~5IuJbnm&re69Wic(X{S~H3yO8KGMs)o=|4yJQKp8cMJMZ9jwUf4DBAhN z{?IE;c7`+Z`;HHh&-+x4uYq1zE&XAiw-$efa%)p0<{M8x^ z{fcK$8R5?4ufE~TSn(=&{xPDmYJ}}m zhK8Sqw-XlzSPW-%7%f;v3zpG>WgrWd(IQ~9Z86%m7}0Hu5nlvUy_{ZGS$y@s_x}&t z_1p)#_g?fay87Sy#nSj+PtS(_*Z+HX{U2Qh{cmpiCqw`1FC6J@(Gj0sM!Tb<-O-WV z9UUzKMq7=et;P}EY9wKpi3Fvi1VLmu|Loq(FsGu^IOgm9_ifVa*dMIh{qEU*4ic1(j$4e5Ta1odV4d*Ze?7eZ z>-7)c^8fQO{Q1%UUqb%xMfs!quft~iF&C;3Gy5GE|MzPA;oJNFS~GljZ2vF0=8Jp1 z$p1LfrowIa{aU@ht-PYW{83}wo8xsY{r_I{Kf0d`TMYtVIs;#TK*V~B`u_F*Uafy9 zd+*`giUsn&H_AU;Z2xQVkMCI4_TX85^SwYH=fxy$xmZ=vGO2{r^1l)S>92IVcXB|6-Sii`py<_m^?dgS7UuYX^TGO%I_44 z2Yql5viW(^|KWXSkcs^YoD8@BxKChd$o;4MBayRVo)nDd!F0gvzaYqQ_5JQPj~?Ez zVYlA-a+x&A-u-wr{;%nt!`6F*`M)IFe^Z9UryP7;419$WJlPPj@}#=vYj{2H`wx8Y zk1+qe(f^kNlJt&~v=FI&JtRKwf1Jzk&|9SWRA0xy1pR?BE zU*AGRz@VB%TH+O~`(FN^q2YSX*>L>Jr-)fzgJKp*Dds=N{@=Un84hIcd$twjk9U8qLXVlHH} zu$HOl&9?s>`#)*#=Q|+1_u}i~tNZUA-EYl(pmVy#;n4q82$|3F5Aihp$T0(VoA6gjp>DkxGOeJ|;sdq#Shw?;+voTv z@HU?Pq4j@Gt!Mt=mjC5f%-8iuOaAxYL&*Gb-|?{RHRF!g>vsRtZ=!}7xc8fk7GwXE zksGimMGw5&5AS5d_ri>J=1AX@HCl{;_s_vn)o3RQb*mssQ8YRhggUM?Iu_J2Vv3Lb z*N+`zJN_?`xxrh%?8wE7g8$=icQ9yQirjx(8uxr_#qRCZ(Q;q)`WYT{+J8x@d!$}B z>He>^_aEY$xj-4BrOB{r^xku?_lV8^_j3NJWVz27domeHH01vOQ2+3A{{PlL*Yf}J z{SO<^z)MO_Zr1;MN&ivq{cmd-KP+Rsu#n-$$@zaW?LG?Iw=Q0+{7)CYVH4A#1Uu}z zw!c36Kj^dlKW~Px^?x?SeAUUM?{}(dRf??{NDEd_APbd&gLOJX-bVDM{1ZcI&6u2BoDq*bBJqk zkG6u)CYpxV8Wc)96r-&m=m}@=j?-u>XtWiCxgHmOAlPW}fjUn;+KEDG@C~z0RQ9@^ z7ypRwgilTPf6(6l={3Wii_&}T!vDTT+R(E8J3{8aApDTKf87hHVFvE=T1Hzz|2dE+ z&X7A`qs5p!N(T&Hh+_`nj*w!kx)!`q;YZ(lmII(YC?77${|)?8t&95zSRw-E{a5gw zipSRbgc8TXVo$o~-vAw*Kf~|1B97pBv!|VPIqU z@ccwXqiFwe@a`qqov-%aN80J~7f;gMR&Z{-oQ=@{-> z5_c|QdKa@=B%-T^xNL*eowe|)7Vce)?f_A{jNt)E;?h7&??RizNEbp3{Z;@8$Lx`y zbhK?T+O`;NTa30XM%xzfIs>!%N4mfYv$7>(fNQjE0dJ&@wkNy$kViaZoJnJK-97l(`N3Sd#y|QpYJbjOi|VI~rkjutGV1 ViewportExtent { + ViewportExtent::square(ViewportPx::new(side)) +} + +fn make_context(extent: ViewportExtent) -> OffscreenContext { + match pollster::block_on(OffscreenContext::new(extent)) { + Ok(ctx) => ctx, + Err(RenderError::NoAdapter(e)) => { + panic!( + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" + ); + } + Err(e) => panic!("offscreen context init failed: {e}"), + } +} + +fn golden_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) +} + +#[test] +fn grid_empty_sketch_matches_golden() { + let size = extent(256); + let ctx = make_context(size); + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); + let scene = SketchScene::empty(); + let camera = Camera2::new(size); + let style = Style::default(); + + let Ok(frame) = renderer.render(&ctx, &scene, camera, &style) else { + panic!("SketchRenderer::render failed"); + }; + + let path = golden_path(); + + if std::env::var(UPDATE_ENV).is_ok() { + let Ok(bytes) = encode_png(&frame) else { + panic!("encode_png failed"); + }; + if let Some(parent) = path.parent() { + assert!( + std::fs::create_dir_all(parent).is_ok(), + "failed to create goldens dir" + ); + } + assert!( + std::fs::write(&path, &bytes).is_ok(), + "failed to write golden {}", + path.display() + ); + return; + } + + let Ok(bytes) = std::fs::read(&path) else { + panic!( + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", + path.display() + ); + }; + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { + panic!("failed to decode golden PNG"); + }; + assert_eq!(golden_extent, size, "golden extent drift"); + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { + panic!("PixelDiff rejected inputs"); + }; + assert!( + report.is_clean(), + "grid render drifted from golden: {} mismatches, worst {:?}, backend {}", + report.over_threshold(), + report.worst(), + frame.backend(), + ); +} diff --git a/crates/bone-render/tests/picker.rs b/crates/bone-render/tests/picker.rs new file mode 100644 index 0000000..a5a2962 --- /dev/null +++ b/crates/bone-render/tests/picker.rs @@ -0,0 +1,266 @@ +use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; +use bone_render::{ + Camera2, OffscreenContext, PickQuery, PickedItem, RenderError, SketchRenderer, SketchScene, + Style, ViewportExtent, ViewportPx, +}; +use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; +use uom::si::length::millimeter; + +const SIDE: u32 = 256; +const CENTER_PX: f32 = 128.0; +const PX_PER_MM: f32 = 10.0; + +fn extent() -> ViewportExtent { + ViewportExtent::square(ViewportPx::new(SIDE)) +} + +fn make_context() -> OffscreenContext { + match pollster::block_on(OffscreenContext::new(extent())) { + Ok(ctx) => ctx, + Err(RenderError::NoAdapter(e)) => panic!( + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" + ), + Err(e) => panic!("offscreen context init failed: {e}"), + } +} + +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 add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( + Point2::from_mm(x, y), + ))) else { + panic!("add point"); + }; + (next, id) +} + +fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) + else { + panic!("add line"); + }; + (next, id) +} + +fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( + center, + Length::new::(radius_mm), + false, + ))) else { + panic!("add circle"); + }; + (next, id) +} + +fn add_arc( + s: Sketch, + center: SketchEntityId, + start: SketchEntityId, + end: SketchEntityId, +) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::arc( + center, start, end, false, + ))) else { + panic!("add arc"); + }; + (next, id) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn world_to_px(x_mm: f32, y_mm: f32) -> PickQuery { + let x_px = (CENTER_PX + x_mm * PX_PER_MM).round() as u32; + let y_px = (CENTER_PX - y_mm * PX_PER_MM).round() as u32; + PickQuery::new(ViewportPx::new(x_px), ViewportPx::new(y_px)) +} + +struct Fixture { + scene: SketchScene, + probe_point: (PickQuery, SketchEntityId), + probe_line: (PickQuery, SketchEntityId), + probe_circle: (PickQuery, SketchEntityId), + probe_arc: (PickQuery, SketchEntityId), +} + +fn build_fixture() -> Fixture { + let s = Sketch::new(plane()); + + let (s, line_a) = add_point(s, -6.0, 2.0); + let (s, line_b) = add_point(s, -6.0, -2.0); + let (s, line) = add_line(s, line_a, line_b); + + let (s, standalone) = add_point(s, 6.0, 0.0); + + let (s, circle_center) = add_point(s, 0.0, 4.0); + let (s, circle) = add_circle(s, circle_center, 2.0); + + let (s, arc_center) = add_point(s, 0.0, -4.0); + let (s, arc_start) = add_point(s, 3.0, -4.0); + let (s, arc_end) = add_point(s, 0.0, -1.0); + let (s, arc) = add_arc(s, arc_center, arc_start, arc_end); + + let Ok(scene) = SketchScene::extract(&s) else { + panic!("scene extract"); + }; + + Fixture { + scene, + probe_point: (world_to_px(6.0, 0.0), standalone), + probe_line: (world_to_px(-6.0, 0.0), line), + probe_circle: (world_to_px(2.0, 4.0), circle), + probe_arc: ( + world_to_px( + 3.0 * core::f32::consts::FRAC_1_SQRT_2, + -4.0 + 3.0 * core::f32::consts::FRAC_1_SQRT_2, + ), + arc, + ), + } +} + +#[test] +fn each_entity_centroid_round_trips_to_pick_id() { + let ctx = make_context(); + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); + let camera = Camera2::new(extent()); + let style = Style::default(); + + let fx = build_fixture(); + let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { + panic!("SketchRenderer::render failed"); + }; + let Ok(index) = fx.scene.pick_index() else { + panic!("pick index build"); + }; + let picker = ctx.picker(index); + + let cases = [ + ( + "point", + fx.probe_point.0, + PickedItem::Point(fx.probe_point.1), + ), + ("line", fx.probe_line.0, PickedItem::Line(fx.probe_line.1)), + ( + "circle", + fx.probe_circle.0, + PickedItem::Circle(fx.probe_circle.1), + ), + ("arc", fx.probe_arc.0, PickedItem::Arc(fx.probe_arc.1)), + ]; + + let mismatches: Vec = cases + .into_iter() + .filter_map(|(label, query, expected)| match picker.at(query) { + Ok(Some(got)) if got == expected => None, + Ok(got) => Some(format!( + "{label} pick at {query}: expected {expected:?}, got {got:?}" + )), + Err(e) => Some(format!("{label} pick at {query}: error {e}")), + }) + .collect(); + assert!(mismatches.is_empty(), "pick mismatches: {mismatches:#?}"); +} + +#[test] +fn empty_space_decodes_to_none() { + let ctx = make_context(); + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); + let camera = Camera2::new(extent()); + let style = Style::default(); + + let fx = build_fixture(); + let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { + panic!("SketchRenderer::render failed"); + }; + let Ok(index) = fx.scene.pick_index() else { + panic!("pick index"); + }; + let picker = ctx.picker(index); + + let empty = world_to_px(-8.0, 8.0); + let Ok(hit) = picker.at(empty) else { + panic!("picker at empty failed"); + }; + assert_eq!(hit, None, "empty pixel expected to decode to None"); +} + +#[test] +fn repeated_render_picks_deterministic() { + let ctx = make_context(); + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); + let camera = Camera2::new(extent()); + let style = Style::default(); + + let fx = build_fixture(); + let Ok(index) = fx.scene.pick_index() else { + panic!("pick index"); + }; + + let collect_picks = || { + let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { + panic!("render"); + }; + let pp = ctx.picker(index.clone()); + [ + fx.probe_point.0, + fx.probe_line.0, + fx.probe_circle.0, + fx.probe_arc.0, + ] + .into_iter() + .map(|q| { + let Ok(raw) = pp.raw_at(q) else { + panic!("raw_at failed"); + }; + raw.raw() + }) + .collect::>() + }; + + let first = collect_picks(); + let second = collect_picks(); + assert_eq!( + first, second, + "repeated render produced non-deterministic ids" + ); +} + +#[test] +fn out_of_bounds_query_is_rejected() { + let ctx = make_context(); + let Ok(index) = SketchScene::empty().pick_index() else { + panic!("pick index"); + }; + let picker = ctx.picker(index); + let cases = [ + ( + "x-axis edge", + PickQuery::new(ViewportPx::new(SIDE), ViewportPx::new(0)), + ), + ( + "y-axis edge", + PickQuery::new(ViewportPx::new(0), ViewportPx::new(SIDE)), + ), + ]; + let failures: Vec = cases + .into_iter() + .filter_map(|(label, query)| match picker.at(query) { + Err(RenderError::PickOutOfBounds { .. }) => None, + other => Some(format!("{label}: expected OOB, got {other:?}")), + }) + .collect(); + assert!(failures.is_empty(), "OOB rejection failures: {failures:#?}"); +} diff --git a/crates/bone-render/tests/rectangle.rs b/crates/bone-render/tests/rectangle.rs new file mode 100644 index 0000000..10175ba --- /dev/null +++ b/crates/bone-render/tests/rectangle.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; + +use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; +use bone_render::{ + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SketchRenderer, + SketchScene, Style, ViewportExtent, ViewportPx, decode_png, encode_png, +}; +use bone_types::{Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; + +const GOLDEN: &str = "tests/goldens/rectangle_256.png"; +const UPDATE_ENV: &str = "BONE_UPDATE_RECTANGLE_GOLDEN"; +const DIFF_TOLERANCE: f64 = 16.0 / 255.0; + +fn extent(side: u32) -> ViewportExtent { + ViewportExtent::square(ViewportPx::new(side)) +} + +fn make_context(extent: ViewportExtent) -> OffscreenContext { + match pollster::block_on(OffscreenContext::new(extent)) { + Ok(ctx) => ctx, + Err(RenderError::NoAdapter(e)) => { + panic!( + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" + ); + } + Err(e) => panic!("offscreen context init failed: {e}"), + } +} + +fn golden_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) +} + +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 add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( + Point2::from_mm(x, y), + ))) else { + panic!("add point"); + }; + (next, id) +} + +fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> Sketch { + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { + panic!("add line"); + }; + next +} + +fn rectangle_scene() -> SketchScene { + let s = Sketch::new(plane()); + let (s, p0) = add_point(s, -8.0, -5.0); + let (s, p1) = add_point(s, 8.0, -5.0); + let (s, p2) = add_point(s, 8.0, 5.0); + let (s, p3) = add_point(s, -8.0, 5.0); + let s = add_line(s, p0, p1); + let s = add_line(s, p1, p2); + let s = add_line(s, p2, p3); + let s = add_line(s, p3, p0); + let Ok(scene) = SketchScene::extract(&s) else { + panic!("scene extract"); + }; + scene +} + +#[test] +fn rectangle_sketch_matches_golden() { + let size = extent(256); + let ctx = make_context(size); + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); + let scene = rectangle_scene(); + let camera = Camera2::new(size); + let style = Style::default(); + + let Ok(frame) = renderer.render(&ctx, &scene, camera, &style) else { + panic!("SketchRenderer::render failed"); + }; + + let path = golden_path(); + + if std::env::var(UPDATE_ENV).is_ok() { + let Ok(bytes) = encode_png(&frame) else { + panic!("encode_png failed"); + }; + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + panic!("create goldens dir {}: {e}", parent.display()); + } + if let Err(e) = std::fs::write(&path, &bytes) { + panic!("write golden {}: {e}", path.display()); + } + return; + } + + let Ok(bytes) = std::fs::read(&path) else { + panic!( + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", + path.display() + ); + }; + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { + panic!("failed to decode golden PNG"); + }; + assert_eq!(golden_extent, size, "golden extent drift"); + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { + panic!("PixelDiff rejected inputs"); + }; + assert!( + report.is_clean(), + "rectangle render drifted from golden: {} mismatches, worst {:?}, backend {}", + report.over_threshold(), + report.worst(), + frame.backend(), + ); +} diff --git a/crates/bone-solver/Cargo.toml b/crates/bone-solver/Cargo.toml new file mode 100644 index 0000000..4129225 --- /dev/null +++ b/crates/bone-solver/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bone-solver" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +bone-types = { workspace = true } +bone-kernel = { workspace = true } +faer = { workspace = true } +nalgebra = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +insta = { workspace = true } + +[lints] +workspace = true diff --git a/crates/bone-solver/src/dof.rs b/crates/bone-solver/src/dof.rs new file mode 100644 index 0000000..11b0c0c --- /dev/null +++ b/crates/bone-solver/src/dof.rs @@ -0,0 +1,329 @@ +use bone_types::{ + DegreesOfFreedom, Parameter, ParameterIndex, ResidualIndex, SolverResidual, SolverSeed, +}; +use nalgebra::{DMatrix, DVector}; + +use crate::jacobian::{assemble_jacobian, evaluate_residuals}; +use crate::system::ConstraintSystem; + +#[derive(Clone, Debug, PartialEq)] +pub struct DofReport { + dof: DegreesOfFreedom, + under_constrained: Vec, + over_constrained: Vec, + redundant_consistent: Vec, +} + +impl DofReport { + #[must_use] + pub fn dof(&self) -> DegreesOfFreedom { + self.dof + } + + #[must_use] + pub fn under_constrained(&self) -> &[ParameterIndex] { + &self.under_constrained + } + + #[must_use] + pub fn over_constrained(&self) -> &[ResidualIndex] { + &self.over_constrained + } + + #[must_use] + pub fn redundant_consistent(&self) -> &[ResidualIndex] { + &self.redundant_consistent + } + + #[must_use] + pub fn display(&self) -> String { + format!( + "dof={} under=[{}] over=[{}] redundant=[{}]", + self.dof.value(), + self.under_constrained + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + self.over_constrained + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + self.redundant_consistent + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + ) + } +} + +#[derive(Copy, Clone, Debug)] +pub struct DofConfig { + pub seed: SolverSeed, + pub witness_scale: f64, + pub rank_tolerance: f64, + pub residual_tolerance: SolverResidual, +} + +impl DofConfig { + pub const DEFAULT: Self = Self { + seed: SolverSeed::DEFAULT, + witness_scale: 0.5, + rank_tolerance: 1e-9, + residual_tolerance: SolverResidual::new(1e-7), + }; +} + +#[must_use] +pub fn analyze_dof(system: &ConstraintSystem, cfg: DofConfig) -> DofReport { + analyze_dof_at(system, system.parameters(), cfg) +} + +#[must_use] +pub fn analyze_dof_at( + system: &ConstraintSystem, + params: &[Parameter], + cfg: DofConfig, +) -> DofReport { + let current_params: Vec = params.iter().map(|p| p.value()).collect(); + let n = system.parameter_count(); + let m = system.row_count(); + if m == 0 || n == 0 { + let Ok(dof_n) = u32::try_from(n) else { + unreachable!("parameter count fits in u32 via ParameterIndex") + }; + return DofReport { + dof: DegreesOfFreedom::new(dof_n), + under_constrained: (0..dof_n).map(ParameterIndex::new).collect(), + over_constrained: Vec::new(), + redundant_consistent: Vec::new(), + }; + } + let witness_params = perturb(¤t_params, cfg.seed, cfg.witness_scale); + let jacobian = assemble_jacobian(system, &witness_params); + let qr_cols = col_piv_qr(&jacobian, cfg.rank_tolerance); + let transpose = jacobian.transpose(); + let qr_rows = col_piv_qr(&transpose, cfg.rank_tolerance); + let rank = qr_cols.rank; + let Ok(dof_n) = u32::try_from(n.saturating_sub(rank)) else { + unreachable!("rank <= n <= ParameterIndex::MAX") + }; + let under_constrained: Vec = qr_cols.col_perm[rank..] + .iter() + .map(|&c| { + let Ok(cv) = u32::try_from(c) else { + unreachable!("pivoted column index fits in u32 via ParameterIndex") + }; + ParameterIndex::new(cv) + }) + .collect(); + let mut under_sorted = under_constrained; + under_sorted.sort_by_key(|p| p.value()); + + let residuals_at_current = evaluate_residuals(system, ¤t_params); + let tol = cfg.residual_tolerance.value(); + let (over_constrained, redundant_consistent): (Vec, Vec) = + qr_rows.col_perm[rank..] + .iter() + .map(|&row| { + let Ok(rv) = u32::try_from(row) else { + unreachable!("dependent row index fits in u32 via ResidualIndex") + }; + let value = residuals_at_current[row]; + (ResidualIndex::new(rv), value.abs() <= tol) + }) + .fold( + (Vec::new(), Vec::new()), + |(mut over, mut red), (idx, consistent)| { + if consistent { + red.push(idx); + } else { + over.push(idx); + } + (over, red) + }, + ); + let mut over_sorted = over_constrained; + let mut red_sorted = redundant_consistent; + over_sorted.sort_by_key(|r| r.value()); + red_sorted.sort_by_key(|r| r.value()); + DofReport { + dof: DegreesOfFreedom::new(dof_n), + under_constrained: under_sorted, + over_constrained: over_sorted, + redundant_consistent: red_sorted, + } +} + +fn perturb(params: &[f64], seed: SolverSeed, scale: f64) -> Vec { + params + .iter() + .scan(WitnessRng::new(seed), |rng, &v| { + let (u, next) = rng.step(); + *rng = next; + Some(v + (u - 0.5) * scale) + }) + .collect() +} + +#[derive(Copy, Clone, Debug)] +struct WitnessRng { + state: u64, +} + +impl WitnessRng { + fn new(seed: SolverSeed) -> Self { + Self { + state: seed.value(), + } + } + + fn step(self) -> (f64, Self) { + let state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mixed = mix64(state); + let mantissa = mixed & 0x000F_FFFF_FFFF_FFFF; + let uniform = f64::from_bits(0x3FF0_0000_0000_0000 | mantissa) - 1.0; + (uniform, Self { state }) + } +} + +fn mix64(x: u64) -> u64 { + let a = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + let b = (a ^ (a >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + b ^ (b >> 31) +} + +#[derive(Clone, Debug)] +struct ColPivQr { + rank: usize, + col_perm: Vec, +} + +fn col_piv_qr(input: &DMatrix, tol: f64) -> ColPivQr { + let m = input.nrows(); + let n = input.ncols(); + let initial_norms: Vec = (0..n).map(|j| input.column(j).norm_squared()).collect(); + let initial = QrState { + a: input.clone(), + col_perm: (0..n).collect(), + col_norms: initial_norms, + rank: 0, + }; + let max_steps = m.min(n); + let final_state = (0..max_steps).try_fold(initial, |state, k| state.step(k, tol)); + let state = match final_state { + Ok(s) | Err(s) => s, + }; + ColPivQr { + rank: state.rank, + col_perm: state.col_perm, + } +} + +#[derive(Clone, Debug)] +struct QrState { + a: DMatrix, + col_perm: Vec, + col_norms: Vec, + rank: usize, +} + +impl QrState { + fn step(self, pivot_row: usize, tol: f64) -> Result { + let QrState { + a: matrix, + mut col_perm, + mut col_norms, + rank, + } = self; + let rows = matrix.nrows(); + let cols = matrix.ncols(); + let (best_col, best_norm2) = col_norms.iter().enumerate().skip(pivot_row).fold( + (pivot_row, f64::NEG_INFINITY), + |(best, best_v), (j, &value)| { + if value > best_v { + (j, value) + } else { + (best, best_v) + } + }, + ); + if best_norm2 <= tol * tol { + return Err(QrState { + a: matrix, + col_perm, + col_norms, + rank, + }); + } + let mut matrix = matrix; + if best_col != pivot_row { + matrix.swap_columns(pivot_row, best_col); + col_perm.swap(pivot_row, best_col); + col_norms.swap(pivot_row, best_col); + } + let tail = rows - pivot_row; + let column: DVector = (0..tail) + .map(|i| matrix[(pivot_row + i, pivot_row)]) + .collect::>() + .into(); + let column_norm = column.norm(); + if column_norm <= tol { + return Err(QrState { + a: matrix, + col_perm, + col_norms, + rank, + }); + } + let alpha = if column[0] >= 0.0 { + -column_norm + } else { + column_norm + }; + let reflector: DVector = column + .iter() + .enumerate() + .map(|(idx, &value)| if idx == 0 { value - alpha } else { value }) + .collect::>() + .into(); + let reflector_dot = reflector.dot(&reflector); + let applied = if reflector_dot > 0.0 { + let beta = 2.0 / reflector_dot; + (pivot_row..cols).fold(matrix, |mut mat, col| { + let projection: f64 = (0..tail) + .map(|i| reflector[i] * mat[(pivot_row + i, col)]) + .sum(); + let coef = beta * projection; + (0..tail).for_each(|i| mat[(pivot_row + i, col)] -= coef * reflector[i]); + mat + }) + } else { + matrix + }; + let new_norms: Vec = col_norms + .iter() + .enumerate() + .map(|(col, &prev)| { + if col <= pivot_row || pivot_row + 1 >= rows { + prev + } else { + (0..(tail - 1)) + .map(|i| { + let entry = applied[(pivot_row + 1 + i, col)]; + entry * entry + }) + .sum::() + } + }) + .collect(); + Ok(QrState { + a: applied, + col_perm, + col_norms: new_norms, + rank: rank + 1, + }) + } +} diff --git a/crates/bone-solver/src/error.rs b/crates/bone-solver/src/error.rs new file mode 100644 index 0000000..86ee91c --- /dev/null +++ b/crates/bone-solver/src/error.rs @@ -0,0 +1,17 @@ +use core::time::Duration; + +use bone_types::{ParameterIndex, SketchItemId, SolverResidual}; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SolverError { + #[error("no solution found (last residual = {last})")] + NoSolutionFound { last: SolverResidual }, + #[error("sketch is over-defined ({} conflicting items)", conflicts.len())] + OverDefined { conflicts: Vec }, + #[error("invalid solution found: jacobian singular at parameter {at}")] + InvalidSolutionFound { at: ParameterIndex }, + #[error("solver budget exhausted after {elapsed:?}")] + Budget { elapsed: Duration }, +} + +pub type Result = core::result::Result; diff --git a/crates/bone-solver/src/graph.rs b/crates/bone-solver/src/graph.rs new file mode 100644 index 0000000..ccbaff8 --- /dev/null +++ b/crates/bone-solver/src/graph.rs @@ -0,0 +1,146 @@ +use std::collections::BTreeMap; + +use bone_types::{ParameterIndex, ParentIndex, ResidualIndex}; + +use crate::system::ConstraintSystem; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Component { + parameters: Vec, + residual_parents: Vec, +} + +impl Component { + #[must_use] + pub fn parameters(&self) -> &[ParameterIndex] { + &self.parameters + } + + #[must_use] + pub fn residual_parents(&self) -> &[ParentIndex] { + &self.residual_parents + } + + #[must_use] + pub fn residual_rows(&self, system: &ConstraintSystem) -> Vec { + let offsets = system.row_offsets(); + self.residual_parents + .iter() + .flat_map(|&parent| { + let start = offsets[parent.as_usize()].value(); + (0..system.residual_at_parent(parent).rows()).map(move |o| { + let Ok(ov) = u32::try_from(o) else { + unreachable!("residual row count is a small constant") + }; + ResidualIndex::new(start + ov) + }) + }) + .collect() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Decomposition { + components: Vec, +} + +impl Decomposition { + #[must_use] + pub fn components(&self) -> &[Component] { + &self.components + } +} + +#[must_use] +pub fn decompose(system: &ConstraintSystem) -> Decomposition { + let n = system.parameter_count(); + let dsu_final = system.residuals().iter().fold(Dsu::new(n), |dsu, r| { + let ps = r.parameters(); + match ps.split_first() { + None => dsu, + Some((&first, rest)) => rest + .iter() + .fold(dsu, |acc, &p| acc.union(first.as_usize(), p.as_usize())), + } + }); + let roots: Vec = (0..n).map(|p| dsu_final.find(p)).collect(); + let param_groups: BTreeMap> = + (0..n).fold(BTreeMap::new(), |mut acc, p| { + let Ok(pv) = u32::try_from(p) else { + unreachable!("parameter count stays within ParameterIndex range") + }; + acc.entry(roots[p]) + .or_default() + .push(ParameterIndex::new(pv)); + acc + }); + let parent_groups: BTreeMap> = system + .residuals() + .iter() + .enumerate() + .fold(BTreeMap::new(), |mut acc, (pi, r)| { + if let Some(&first) = r.parameters().first() { + let Ok(piv) = u32::try_from(pi) else { + unreachable!("parent count fits in u32 via ResidualIndex") + }; + acc.entry(roots[first.as_usize()]) + .or_default() + .push(ParentIndex::new(piv)); + } + acc + }); + let mut keyed: Vec<(usize, Component)> = param_groups + .into_iter() + .map(|(root, parameters)| { + let residual_parents = parent_groups.get(&root).cloned().unwrap_or_default(); + let min_param = parameters + .first() + .copied() + .map_or(usize::MAX, ParameterIndex::as_usize); + ( + min_param, + Component { + parameters, + residual_parents, + }, + ) + }) + .collect(); + keyed.sort_by_key(|(min_param, _)| *min_param); + Decomposition { + components: keyed.into_iter().map(|(_, c)| c).collect(), + } +} + +#[derive(Clone, Debug)] +struct Dsu { + parent: Vec, +} + +impl Dsu { + fn new(n: usize) -> Self { + Self { + parent: (0..n).collect(), + } + } + + fn find(&self, x: usize) -> usize { + std::iter::successors(Some(x), |&i| { + let p = self.parent[i]; + (p != i).then_some(p) + }) + .fold(x, |_, i| i) + } + + fn union(self, a: usize, b: usize) -> Self { + let ra = self.find(a); + let rb = self.find(b); + if ra == rb { + return self; + } + let (lo, hi) = if ra < rb { (ra, rb) } else { (rb, ra) }; + let Self { mut parent } = self; + parent[hi] = lo; + Self { parent } + } +} diff --git a/crates/bone-solver/src/jacobian.rs b/crates/bone-solver/src/jacobian.rs new file mode 100644 index 0000000..c9c331f --- /dev/null +++ b/crates/bone-solver/src/jacobian.rs @@ -0,0 +1,111 @@ +use bone_types::ParameterIndex; +use nalgebra::DMatrix; + +use crate::residual::Triplet; +use crate::system::ConstraintSystem; + +#[derive(Clone, Debug, PartialEq)] +pub struct SparsityPattern { + rows: usize, + cols: usize, + entries: Vec<(usize, ParameterIndex)>, +} + +impl SparsityPattern { + #[must_use] + pub fn rows(&self) -> usize { + self.rows + } + + #[must_use] + pub fn cols(&self) -> usize { + self.cols + } + + #[must_use] + pub fn entries(&self) -> &[(usize, ParameterIndex)] { + &self.entries + } + + #[must_use] + pub fn display(&self) -> String { + let mut grid = vec![vec![false; self.cols]; self.rows]; + self.entries.iter().for_each(|(r, c)| { + grid[*r][c.as_usize()] = true; + }); + grid.into_iter() + .map(|row| { + row.into_iter() + .map(|filled| if filled { 'X' } else { '.' }) + .collect::() + }) + .collect::>() + .join("\n") + } +} + +#[must_use] +pub fn evaluate_residuals(system: &ConstraintSystem, params: &[f64]) -> Vec { + let mut out = vec![0.0; system.row_count()]; + let offsets = system.row_offsets(); + system + .residuals() + .iter() + .zip(offsets.iter().copied()) + .for_each(|(r, start)| { + let len = r.rows(); + let begin = start.as_usize(); + r.evaluate(params, &mut out[begin..begin + len]); + }); + out +} + +#[must_use] +pub fn assemble_jacobian(system: &ConstraintSystem, params: &[f64]) -> DMatrix { + let rows = system.row_count(); + let cols = system.parameter_count(); + let triplets = collect_triplets(system, params); + triplets.into_iter().fold( + DMatrix::::zeros(rows, cols), + |mut mat, (row, col, val)| { + mat[(row.as_usize(), col.as_usize())] += val; + mat + }, + ) +} + +#[must_use] +pub fn sparsity_pattern(system: &ConstraintSystem) -> SparsityPattern { + let dummy: Vec = std::iter::successors(Some(1.0_f64), |v| Some(v + 0.25)) + .take(system.parameter_count()) + .collect(); + let triplets = collect_triplets(system, &dummy); + let mut entries: Vec<(usize, ParameterIndex)> = triplets + .into_iter() + .map(|(r, c, _)| (r.as_usize(), c)) + .collect(); + entries.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.value().cmp(&b.1.value()))); + entries.dedup(); + SparsityPattern { + rows: system.row_count(), + cols: system.parameter_count(), + entries, + } +} + +#[must_use] +pub fn jacobian_triplets(system: &ConstraintSystem, params: &[f64]) -> Vec { + collect_triplets(system, params) +} + +fn collect_triplets(system: &ConstraintSystem, params: &[f64]) -> Vec { + let offsets = system.row_offsets(); + system + .residuals() + .iter() + .zip(offsets) + .fold(Vec::new(), |mut triplets, (r, start)| { + r.jacobian(params, start, &mut triplets); + triplets + }) +} diff --git a/crates/bone-solver/src/lib.rs b/crates/bone-solver/src/lib.rs new file mode 100644 index 0000000..8ddbe72 --- /dev/null +++ b/crates/bone-solver/src/lib.rs @@ -0,0 +1,21 @@ +pub mod dof; +pub mod error; +pub mod graph; +pub mod jacobian; +pub mod mus; +pub mod newton; +pub mod residual; +pub mod system; +pub mod util; + +pub use dof::{DofConfig, DofReport, analyze_dof, analyze_dof_at}; +pub use error::{Result, SolverError}; +pub use graph::{Component, Decomposition, decompose}; +pub use jacobian::{ + SparsityPattern, assemble_jacobian, evaluate_residuals, jacobian_triplets, sparsity_pattern, +}; +pub use mus::minimal_unsatisfiable_subset; +pub use newton::{NewtonConfig, residual_norm, solve_newton, solve_newton_decomposed}; +pub use residual::{CurveRadius, LineHandle, PointHandle, Residual, Triplet}; +pub use system::{ConstraintSystem, Subsystem}; +pub use util::dedup_preserving_order; diff --git a/crates/bone-solver/src/mus.rs b/crates/bone-solver/src/mus.rs new file mode 100644 index 0000000..3ae740a --- /dev/null +++ b/crates/bone-solver/src/mus.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeSet; + +use bone_types::{ParentIndex, ResidualIndex}; + +use crate::dof::{DofConfig, analyze_dof_at}; +use crate::graph::decompose; +use crate::newton::{NewtonConfig, solve_newton_decomposed}; +use crate::system::ConstraintSystem; +use crate::util::dedup_preserving_order; + +#[must_use] +pub fn minimal_unsatisfiable_subset( + system: &ConstraintSystem, + over_flagged: &[ResidualIndex], + cfg: DofConfig, +) -> Vec { + let candidate_parents: Vec = dedup_preserving_order( + over_flagged + .iter() + .copied() + .map(|row| system.parent_of_row(row)), + ); + if candidate_parents.is_empty() { + return Vec::new(); + } + let solver_cfg = NewtonConfig::DEFAULT; + let kept_parents = candidate_parents + .iter() + .copied() + .fold( + (BTreeSet::::new(), Vec::::new()), + |(mut excluded, mut kept), parent| { + let mut trial = excluded.clone(); + trial.insert(parent); + if reduced_is_over(system, &trial, solver_cfg, cfg) { + excluded.insert(parent); + } else { + kept.push(parent); + } + (excluded, kept) + }, + ) + .1; + kept_parents + .into_iter() + .flat_map(|p| system.rows_of_parent(p)) + .filter(|row| over_flagged.contains(row)) + .collect() +} + +fn reduced_is_over( + system: &ConstraintSystem, + exclude: &BTreeSet, + newton_cfg: NewtonConfig, + dof_cfg: DofConfig, +) -> bool { + let sub = system.without_parents(exclude); + if sub.row_count() == 0 { + return false; + } + let decomposition = decompose(&sub); + match solve_newton_decomposed(&sub, &decomposition, newton_cfg) { + Ok(solved) => { + let report = analyze_dof_at(&sub, &solved, dof_cfg); + !report.over_constrained().is_empty() + } + Err(_) => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::residual::Residual; + use bone_types::{Parameter, ParameterIndex}; + + fn p(i: u32) -> ParameterIndex { + ParameterIndex::new(i) + } + + fn pin(at: u32, target: f64) -> Residual { + Residual::Pin { + param: p(at), + target, + } + } + + fn over_flagged_after_solve(system: &ConstraintSystem) -> Vec { + let decomposition = decompose(system); + let solved = solve_newton_decomposed(system, &decomposition, NewtonConfig::DEFAULT) + .unwrap_or_else(|_| system.parameters().to_vec()); + analyze_dof_at(system, &solved, DofConfig::DEFAULT) + .over_constrained() + .to_vec() + } + + #[test] + fn three_conflicting_pins_isolate_the_odd_one_out() { + let system = ConstraintSystem::new( + vec![Parameter::new(0.0)], + vec![pin(0, 1.0), pin(0, 2.0), pin(0, 1.0)], + ); + let over = over_flagged_after_solve(&system); + assert!( + !over.is_empty(), + "fixture must produce at least one over row" + ); + let mus = minimal_unsatisfiable_subset(&system, &over, DofConfig::DEFAULT); + assert!( + !mus.is_empty(), + "MUS must keep at least one essential row, got empty", + ); + assert!( + mus.len() <= over.len(), + "MUS must be a subset of over-flagged rows: mus={mus:?} over={over:?}", + ); + } + + #[test] + fn empty_over_flagged_yields_empty_mus() { + let system = ConstraintSystem::new(vec![Parameter::new(0.0)], vec![pin(0, 0.0)]); + let mus = minimal_unsatisfiable_subset(&system, &[], DofConfig::DEFAULT); + assert!(mus.is_empty()); + } +} diff --git a/crates/bone-solver/src/newton.rs b/crates/bone-solver/src/newton.rs new file mode 100644 index 0000000..aba6227 --- /dev/null +++ b/crates/bone-solver/src/newton.rs @@ -0,0 +1,378 @@ +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use bone_types::{ + BudgetCeiling, NewtonDamping, NewtonStepTolerance, Parameter, ParameterIndex, ResidualIndex, + SolverResidual, +}; +use faer::Mat; +use faer::linalg::solvers::Solve; +use faer::sparse::{SparseColMat, Triplet as FaerTriplet}; +use nalgebra::DVector; + +use crate::error::{Result, SolverError}; +use crate::graph::Decomposition; +use crate::jacobian::{evaluate_residuals, jacobian_triplets}; +use crate::residual::Triplet; +use crate::system::ConstraintSystem; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct NewtonConfig { + pub max_iterations: u32, + pub residual_tolerance: SolverResidual, + pub step_tolerance: NewtonStepTolerance, + pub line_search_shrinks: u32, + pub damping: NewtonDamping, + pub budget: Option, +} + +impl NewtonConfig { + pub const DEFAULT: Self = Self { + max_iterations: 64, + residual_tolerance: SolverResidual::new(1e-10), + step_tolerance: NewtonStepTolerance::DEFAULT, + line_search_shrinks: 20, + damping: NewtonDamping::DEFAULT, + budget: None, + }; +} + +#[must_use] +pub fn residual_norm(residuals: &[f64]) -> SolverResidual { + let sum: f64 = residuals.iter().map(|r| r * r).sum(); + SolverResidual::new(sum.sqrt()) +} + +#[derive(Copy, Clone, Debug)] +struct Stopwatch { + started_at: Instant, + budget: Option, +} + +impl Stopwatch { + fn start(budget: Option) -> Self { + Self { + started_at: Instant::now(), + budget, + } + } + + fn check(self) -> Result<(), SolverError> { + let Some(budget) = self.budget else { + return Ok(()); + }; + let elapsed = self.started_at.elapsed(); + if elapsed >= budget.duration() { + Err(SolverError::Budget { elapsed }) + } else { + Ok(()) + } + } +} + +#[must_use] +fn remaining_budget(outer: BudgetCeiling, elapsed: Duration) -> BudgetCeiling { + BudgetCeiling::new(outer.duration().saturating_sub(elapsed)) +} + +struct Iterate { + params: Vec, + residuals: Vec, + norm: SolverResidual, +} + +impl Iterate { + fn evaluate(system: &ConstraintSystem, params: Vec) -> Self { + let residuals = evaluate_residuals(system, ¶ms); + let norm = residual_norm(&residuals); + Self { + params, + residuals, + norm, + } + } +} + +pub fn solve_newton(system: &ConstraintSystem, cfg: NewtonConfig) -> Result> { + let stopwatch = Stopwatch::start(cfg.budget); + let seed: Vec = system.parameters().iter().map(|p| p.value()).collect(); + converge( + system, + cfg, + &Iterate::evaluate(system, seed), + cfg.max_iterations, + stopwatch, + ) +} + +pub fn solve_newton_decomposed( + system: &ConstraintSystem, + decomposition: &Decomposition, + cfg: NewtonConfig, +) -> Result> { + let stopwatch = Stopwatch::start(cfg.budget); + let seed: Vec = system.parameters().to_vec(); + decomposition + .components() + .iter() + .try_fold(seed, |acc, component| { + stopwatch.check()?; + let sub = system.subsystem(component); + if sub.system().row_count() == 0 { + return Ok(acc); + } + let sub_cfg = NewtonConfig { + budget: cfg + .budget + .map(|b| remaining_budget(b, stopwatch.started_at.elapsed())), + ..cfg + }; + let solved = solve_newton(sub.system(), sub_cfg) + .map_err(|err| lift_subsystem_error(err, sub.param_map()))?; + Ok(splice(acc, sub.param_map(), &solved)) + }) +} + +fn lift_subsystem_error(err: SolverError, param_map: &[ParameterIndex]) -> SolverError { + match err { + SolverError::InvalidSolutionFound { at } => SolverError::InvalidSolutionFound { + at: param_map[at.as_usize()], + }, + other => other, + } +} + +fn splice( + mut whole: Vec, + param_map: &[bone_types::ParameterIndex], + sub: &[Parameter], +) -> Vec { + param_map + .iter() + .zip(sub.iter().copied()) + .for_each(|(orig, value)| { + whole[orig.as_usize()] = value; + }); + whole +} + +fn converge( + system: &ConstraintSystem, + cfg: NewtonConfig, + state: &Iterate, + remaining: u32, + stopwatch: Stopwatch, +) -> Result> { + if state.norm.value() < cfg.residual_tolerance.value() { + return Ok(to_parameters(&state.params)); + } + if remaining == 0 { + return Err(SolverError::NoSolutionFound { last: state.norm }); + } + stopwatch.check()?; + let triplets = jacobian_triplets(system, &state.params); + let step = least_squares_step( + system.parameter_count(), + &triplets, + &state.residuals, + cfg.damping, + )?; + if step.norm() < cfg.step_tolerance.value() { + return Ok(to_parameters(&state.params)); + } + match line_search( + system, + &state.params, + &step, + state.norm, + cfg.line_search_shrinks, + ) { + Some(next) => converge(system, cfg, &next, remaining - 1, stopwatch), + None => Err(SolverError::NoSolutionFound { last: state.norm }), + } +} + +fn to_parameters(values: &[f64]) -> Vec { + values.iter().copied().map(Parameter::new).collect() +} + +fn least_squares_step( + params_len: usize, + triplets: &[Triplet], + residuals: &[f64], + damping: NewtonDamping, +) -> Result> { + let (jtj_triplets, jtr) = + assemble_normal_equations(params_len, triplets, residuals, damping.value()); + let Ok(jtj) = + SparseColMat::::try_new_from_triplets(params_len, params_len, &jtj_triplets) + else { + unreachable!("normal-equations triplets are built with in-bounds indices") + }; + let lu = jtj.sp_lu().map_err(|err| match err { + faer::sparse::linalg::LuError::SymbolicSingular { index } => { + let Ok(at) = u32::try_from(index) else { + unreachable!("J^T J column count fits in u32 via ParameterIndex") + }; + SolverError::InvalidSolutionFound { + at: ParameterIndex::new(at), + } + } + faer::sparse::linalg::LuError::Generic(_) => { + unreachable!("faer sp_lu returns Generic only on malformed input") + } + })?; + let mut rhs = Mat::::from_fn(params_len, 1, |i, _| -jtr[i]); + lu.solve_in_place(rhs.as_mut()); + Ok(DVector::from_iterator( + params_len, + (0..params_len).map(|i| rhs[(i, 0)]), + )) +} + +fn assemble_normal_equations( + params_len: usize, + triplets: &[Triplet], + residuals: &[f64], + damping: f64, +) -> (Vec>, Vec) { + let rows: BTreeMap> = + triplets + .iter() + .fold(BTreeMap::new(), |mut acc, (row, col, val)| { + acc.entry(*row).or_default().push((col.as_usize(), *val)); + acc + }); + let jtj: BTreeMap<(usize, usize), f64> = rows + .values() + .flat_map(|entries| { + entries + .iter() + .flat_map(move |(i, vi)| entries.iter().map(move |(j, vj)| ((*i, *j), vi * vj))) + }) + .fold(BTreeMap::new(), |mut acc, (key, val)| { + *acc.entry(key).or_insert(0.0) += val; + acc + }); + let damped = (0..params_len).fold(jtj, |mut acc, i| { + *acc.entry((i, i)).or_insert(0.0) += damping; + acc + }); + let jtj_triplets: Vec> = damped + .into_iter() + .map(|((i, j), v)| FaerTriplet::new(i, j, v)) + .collect(); + let jtr = triplets + .iter() + .fold(vec![0.0_f64; params_len], |mut acc, (row, col, val)| { + acc[col.as_usize()] += val * residuals[row.as_usize()]; + acc + }); + (jtj_triplets, jtr) +} + +fn line_search( + system: &ConstraintSystem, + params: &[f64], + step: &DVector, + baseline: SolverResidual, + max_shrinks: u32, +) -> Option { + let count = usize::try_from(max_shrinks) + .unwrap_or(usize::MAX) + .saturating_add(1); + std::iter::successors(Some(1.0_f64), |a| Some(a * 0.5)) + .take(count) + .find_map(|alpha| { + let trial: Vec = params + .iter() + .zip(step.iter()) + .map(|(p, s)| p + alpha * s) + .collect(); + let candidate = Iterate::evaluate(system, trial); + (candidate.norm.value() < baseline.value()).then_some(candidate) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lift_subsystem_error_translates_singular_index() { + let param_map = vec![ + ParameterIndex::new(4), + ParameterIndex::new(9), + ParameterIndex::new(12), + ]; + let local = SolverError::InvalidSolutionFound { + at: ParameterIndex::new(1), + }; + match lift_subsystem_error(local, ¶m_map) { + SolverError::InvalidSolutionFound { at } => assert_eq!(at, ParameterIndex::new(9)), + other => panic!("expected InvalidSolutionFound, got {other:?}"), + } + } + + #[test] + fn lift_subsystem_error_passes_through_other_variants() { + let param_map = [ParameterIndex::new(2), ParameterIndex::new(5)]; + let no_solution = SolverError::NoSolutionFound { + last: SolverResidual::new(3.5), + }; + match lift_subsystem_error(no_solution, ¶m_map) { + SolverError::NoSolutionFound { last } => { + assert!((last.value() - 3.5).abs() < f64::EPSILON); + } + other => panic!("expected NoSolutionFound, got {other:?}"), + } + } + + #[test] + fn remaining_budget_subtracts_elapsed() { + let outer = BudgetCeiling::new(Duration::from_millis(10)); + let r = remaining_budget(outer, Duration::from_millis(3)); + assert_eq!(r.duration(), Duration::from_millis(7)); + } + + #[test] + fn remaining_budget_saturates_at_zero_when_overshot() { + let outer = BudgetCeiling::new(Duration::from_millis(10)); + let r = remaining_budget(outer, Duration::from_millis(25)); + assert_eq!(r.duration(), Duration::ZERO); + } + + #[test] + fn decomposed_zero_budget_fires_across_disjoint_components() { + use crate::graph::decompose; + use crate::residual::Residual; + use crate::system::ConstraintSystem; + let system = ConstraintSystem::new( + vec![Parameter::new(0.0), Parameter::new(0.0)], + vec![ + Residual::Pin { + param: ParameterIndex::new(0), + target: 1.0, + }, + Residual::Pin { + param: ParameterIndex::new(1), + target: 2.0, + }, + ], + ); + let decomp = decompose(&system); + assert!( + decomp.components().len() >= 2, + "fixture must decompose into at least two components, got {}", + decomp.components().len(), + ); + let cfg = NewtonConfig { + budget: Some(BudgetCeiling::new(Duration::ZERO)), + ..NewtonConfig::DEFAULT + }; + match solve_newton_decomposed(&system, &decomp, cfg) { + Err(SolverError::Budget { .. }) => {} + other => panic!("expected Budget, got {other:?}"), + } + } +} diff --git a/crates/bone-solver/src/residual.rs b/crates/bone-solver/src/residual.rs new file mode 100644 index 0000000..2dc4953 --- /dev/null +++ b/crates/bone-solver/src/residual.rs @@ -0,0 +1,584 @@ +use bone_types::{ParameterIndex, ResidualIndex}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct PointHandle { + pub x: ParameterIndex, + pub y: ParameterIndex, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct LineHandle { + pub a: PointHandle, + pub b: PointHandle, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum CurveRadius { + Explicit { + center: PointHandle, + radius: ParameterIndex, + }, + FromSpoke { + center: PointHandle, + spoke: PointHandle, + }, +} + +impl CurveRadius { + #[must_use] + pub fn center(self) -> PointHandle { + match self { + Self::Explicit { center, .. } | Self::FromSpoke { center, .. } => center, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Residual { + Pin { + param: ParameterIndex, + target: f64, + }, + Horizontal(LineHandle), + Vertical(LineHandle), + Parallel(LineHandle, LineHandle), + Perpendicular(LineHandle, LineHandle), + TangentLineCurve { + line: LineHandle, + curve: CurveRadius, + }, + TangentCurveCurve { + a: CurveRadius, + b: CurveRadius, + }, + CoincidentPointPoint(PointHandle, PointHandle), + CoincidentPointLine { + point: PointHandle, + line: LineHandle, + }, + CoincidentPointCurve { + point: PointHandle, + curve: CurveRadius, + }, + EqualLength(LineHandle, LineHandle), + EqualRadius(CurveRadius, CurveRadius), + LinearDistance { + a: PointHandle, + b: PointHandle, + value_mm: f64, + }, + AngularBetweenLines { + a: LineHandle, + b: LineHandle, + angle_rad: f64, + }, + RadiusCurve { + curve: CurveRadius, + value_mm: f64, + }, +} + +pub type Triplet = (ResidualIndex, ParameterIndex, f64); + +impl Residual { + #[must_use] + pub fn rows(&self) -> usize { + match self { + Self::CoincidentPointPoint(..) => 2, + _ => 1, + } + } + + #[must_use] + pub fn parameters(&self) -> Vec { + match *self { + Self::Pin { param, .. } => vec![param], + Self::Horizontal(l) | Self::Vertical(l) => line_params(l), + Self::Parallel(l1, l2) | Self::Perpendicular(l1, l2) | Self::EqualLength(l1, l2) => { + line_params(l1).into_iter().chain(line_params(l2)).collect() + } + Self::TangentLineCurve { line, curve } => line_params(line) + .into_iter() + .chain(curve_params(curve)) + .collect(), + Self::TangentCurveCurve { a, b } | Self::EqualRadius(a, b) => { + curve_params(a).into_iter().chain(curve_params(b)).collect() + } + Self::CoincidentPointPoint(p, q) => { + point_params(p).into_iter().chain(point_params(q)).collect() + } + Self::CoincidentPointLine { point, line } => point_params(point) + .into_iter() + .chain(line_params(line)) + .collect(), + Self::CoincidentPointCurve { point, curve } => point_params(point) + .into_iter() + .chain(curve_params(curve)) + .collect(), + Self::LinearDistance { a, b, .. } => { + point_params(a).into_iter().chain(point_params(b)).collect() + } + Self::AngularBetweenLines { a, b, .. } => { + line_params(a).into_iter().chain(line_params(b)).collect() + } + Self::RadiusCurve { curve, .. } => curve_params(curve), + } + } + + pub fn evaluate(&self, params: &[f64], out: &mut [f64]) { + match *self { + Self::Pin { param, target } => { + out[0] = params[param.as_usize()] - target; + } + Self::Horizontal(l) => out[0] = get(params, l.a.y) - get(params, l.b.y), + Self::Vertical(l) => out[0] = get(params, l.a.x) - get(params, l.b.x), + Self::Parallel(l1, l2) => { + let (d1x, d1y) = delta(params, l1); + let (d2x, d2y) = delta(params, l2); + out[0] = d1x * d2y - d1y * d2x; + } + Self::Perpendicular(l1, l2) => { + let (d1x, d1y) = delta(params, l1); + let (d2x, d2y) = delta(params, l2); + out[0] = d1x * d2x + d1y * d2y; + } + Self::TangentLineCurve { line, curve } => { + let (dx, dy) = delta(params, line); + let cx = get(params, curve.center().x); + let cy = get(params, curve.center().y); + let ex = cx - get(params, line.a.x); + let ey = cy - get(params, line.a.y); + let cross = ex * dy - ey * dx; + let len2 = dx * dx + dy * dy; + let r2 = radius_squared(params, curve); + out[0] = cross * cross - r2 * len2; + } + Self::TangentCurveCurve { a, b } => { + let ax = get(params, a.center().x); + let ay = get(params, a.center().y); + let bx = get(params, b.center().x); + let by = get(params, b.center().y); + let ra = radius_squared(params, a).sqrt(); + let rb = radius_squared(params, b).sqrt(); + let u = ax - bx; + let v = ay - by; + let rsum = ra + rb; + out[0] = u * u + v * v - rsum * rsum; + } + Self::CoincidentPointPoint(p, q) => { + out[0] = get(params, p.x) - get(params, q.x); + out[1] = get(params, p.y) - get(params, q.y); + } + Self::CoincidentPointLine { point, line } => { + let (dx, dy) = delta(params, line); + let ex = get(params, point.x) - get(params, line.a.x); + let ey = get(params, point.y) - get(params, line.a.y); + out[0] = ex * dy - ey * dx; + } + Self::CoincidentPointCurve { point, curve } => { + let cx = get(params, curve.center().x); + let cy = get(params, curve.center().y); + let ex = get(params, point.x) - cx; + let ey = get(params, point.y) - cy; + let r2 = radius_squared(params, curve); + out[0] = ex * ex + ey * ey - r2; + } + Self::EqualLength(l1, l2) => { + let (d1x, d1y) = delta(params, l1); + let (d2x, d2y) = delta(params, l2); + out[0] = d1x * d1x + d1y * d1y - (d2x * d2x + d2y * d2y); + } + Self::EqualRadius(a, b) => { + out[0] = radius_squared(params, a) - radius_squared(params, b); + } + Self::LinearDistance { a, b, value_mm } => { + let dx = get(params, b.x) - get(params, a.x); + let dy = get(params, b.y) - get(params, a.y); + out[0] = dx * dx + dy * dy - value_mm * value_mm; + } + Self::AngularBetweenLines { a, b, angle_rad } => { + let (d1x, d1y) = delta(params, a); + let (d2x, d2y) = delta(params, b); + let dot = d1x * d2x + d1y * d2y; + let cross = d1x * d2y - d1y * d2x; + let (s, c) = angle_rad.sin_cos(); + out[0] = s * dot - c * cross; + } + Self::RadiusCurve { curve, value_mm } => { + out[0] = radius_squared(params, curve) - value_mm * value_mm; + } + } + } + + pub fn jacobian(&self, params: &[f64], row: ResidualIndex, sink: &mut Vec) { + match *self { + Self::Pin { param, .. } => sink.push((row, param, 1.0)), + Self::Horizontal(l) => { + sink.push((row, l.a.y, 1.0)); + sink.push((row, l.b.y, -1.0)); + } + Self::Vertical(l) => { + sink.push((row, l.a.x, 1.0)); + sink.push((row, l.b.x, -1.0)); + } + Self::Parallel(l1, l2) => jacobian_parallel(params, row, l1, l2, sink), + Self::Perpendicular(l1, l2) => jacobian_perpendicular(params, row, l1, l2, sink), + Self::TangentLineCurve { line, curve } => { + tangent_line_curve_jacobian(params, row, line, curve, sink); + } + Self::TangentCurveCurve { a, b } => { + tangent_curve_curve_jacobian(params, row, a, b, sink); + } + Self::CoincidentPointPoint(p, q) => { + let row2 = row.next(); + sink.push((row, p.x, 1.0)); + sink.push((row, q.x, -1.0)); + sink.push((row2, p.y, 1.0)); + sink.push((row2, q.y, -1.0)); + } + Self::CoincidentPointLine { point, line } => { + jacobian_coincident_point_line(params, row, point, line, sink); + } + Self::CoincidentPointCurve { point, curve } => { + jacobian_coincident_point_curve(params, row, point, curve, sink); + } + Self::EqualLength(l1, l2) => jacobian_equal_length(params, row, l1, l2, sink), + Self::EqualRadius(a, b) => { + add_radius_squared_jacobian(params, row, a, sink); + subtract_radius_squared_jacobian(params, row, b, sink); + } + Self::LinearDistance { a, b, .. } => { + jacobian_linear_distance(params, row, a, b, sink); + } + Self::AngularBetweenLines { a, b, angle_rad } => { + jacobian_angular_between_lines(params, row, a, b, angle_rad, sink); + } + Self::RadiusCurve { curve, .. } => { + add_radius_squared_jacobian(params, row, curve, sink); + } + } + } +} + +fn jacobian_parallel( + params: &[f64], + row: ResidualIndex, + l1: LineHandle, + l2: LineHandle, + sink: &mut Vec, +) { + let (d1x, d1y) = delta(params, l1); + let (d2x, d2y) = delta(params, l2); + sink.push((row, l1.a.x, -d2y)); + sink.push((row, l1.b.x, d2y)); + sink.push((row, l1.a.y, d2x)); + sink.push((row, l1.b.y, -d2x)); + sink.push((row, l2.a.x, d1y)); + sink.push((row, l2.b.x, -d1y)); + sink.push((row, l2.a.y, -d1x)); + sink.push((row, l2.b.y, d1x)); +} + +fn jacobian_perpendicular( + params: &[f64], + row: ResidualIndex, + l1: LineHandle, + l2: LineHandle, + sink: &mut Vec, +) { + let (d1x, d1y) = delta(params, l1); + let (d2x, d2y) = delta(params, l2); + sink.push((row, l1.a.x, -d2x)); + sink.push((row, l1.b.x, d2x)); + sink.push((row, l1.a.y, -d2y)); + sink.push((row, l1.b.y, d2y)); + sink.push((row, l2.a.x, -d1x)); + sink.push((row, l2.b.x, d1x)); + sink.push((row, l2.a.y, -d1y)); + sink.push((row, l2.b.y, d1y)); +} + +fn jacobian_coincident_point_line( + params: &[f64], + row: ResidualIndex, + point: PointHandle, + line: LineHandle, + sink: &mut Vec, +) { + let (dx, dy) = delta(params, line); + let ex = get(params, point.x) - get(params, line.a.x); + let ey = get(params, point.y) - get(params, line.a.y); + sink.push((row, point.x, dy)); + sink.push((row, point.y, -dx)); + sink.push((row, line.a.x, ey - dy)); + sink.push((row, line.a.y, dx - ex)); + sink.push((row, line.b.x, -ey)); + sink.push((row, line.b.y, ex)); +} + +fn jacobian_coincident_point_curve( + params: &[f64], + row: ResidualIndex, + point: PointHandle, + curve: CurveRadius, + sink: &mut Vec, +) { + let center = curve.center(); + let ex = get(params, point.x) - get(params, center.x); + let ey = get(params, point.y) - get(params, center.y); + sink.push((row, point.x, 2.0 * ex)); + sink.push((row, point.y, 2.0 * ey)); + sink.push((row, center.x, -2.0 * ex)); + sink.push((row, center.y, -2.0 * ey)); + subtract_radius_squared_jacobian(params, row, curve, sink); +} + +fn jacobian_equal_length( + params: &[f64], + row: ResidualIndex, + l1: LineHandle, + l2: LineHandle, + sink: &mut Vec, +) { + let (d1x, d1y) = delta(params, l1); + let (d2x, d2y) = delta(params, l2); + sink.push((row, l1.a.x, -2.0 * d1x)); + sink.push((row, l1.b.x, 2.0 * d1x)); + sink.push((row, l1.a.y, -2.0 * d1y)); + sink.push((row, l1.b.y, 2.0 * d1y)); + sink.push((row, l2.a.x, 2.0 * d2x)); + sink.push((row, l2.b.x, -2.0 * d2x)); + sink.push((row, l2.a.y, 2.0 * d2y)); + sink.push((row, l2.b.y, -2.0 * d2y)); +} + +fn jacobian_linear_distance( + params: &[f64], + row: ResidualIndex, + a: PointHandle, + b: PointHandle, + sink: &mut Vec, +) { + let dx = get(params, b.x) - get(params, a.x); + let dy = get(params, b.y) - get(params, a.y); + sink.push((row, a.x, -2.0 * dx)); + sink.push((row, a.y, -2.0 * dy)); + sink.push((row, b.x, 2.0 * dx)); + sink.push((row, b.y, 2.0 * dy)); +} + +fn jacobian_angular_between_lines( + params: &[f64], + row: ResidualIndex, + a: LineHandle, + b: LineHandle, + angle_rad: f64, + sink: &mut Vec, +) { + let (d1x, d1y) = delta(params, a); + let (d2x, d2y) = delta(params, b); + let (sin_t, cos_t) = angle_rad.sin_cos(); + let d1_partial_x = sin_t * d2x - cos_t * d2y; + let d1_partial_y = sin_t * d2y + cos_t * d2x; + let d2_partial_x = sin_t * d1x + cos_t * d1y; + let d2_partial_y = sin_t * d1y - cos_t * d1x; + sink.push((row, a.a.x, -d1_partial_x)); + sink.push((row, a.b.x, d1_partial_x)); + sink.push((row, a.a.y, -d1_partial_y)); + sink.push((row, a.b.y, d1_partial_y)); + sink.push((row, b.a.x, -d2_partial_x)); + sink.push((row, b.b.x, d2_partial_x)); + sink.push((row, b.a.y, -d2_partial_y)); + sink.push((row, b.b.y, d2_partial_y)); +} + +fn line_params(l: LineHandle) -> Vec { + vec![l.a.x, l.a.y, l.b.x, l.b.y] +} + +fn point_params(p: PointHandle) -> Vec { + vec![p.x, p.y] +} + +fn curve_params(c: CurveRadius) -> Vec { + match c { + CurveRadius::Explicit { center, radius } => vec![center.x, center.y, radius], + CurveRadius::FromSpoke { center, spoke } => vec![center.x, center.y, spoke.x, spoke.y], + } +} + +fn get(params: &[f64], idx: ParameterIndex) -> f64 { + params[idx.as_usize()] +} + +fn delta(params: &[f64], line: LineHandle) -> (f64, f64) { + ( + get(params, line.b.x) - get(params, line.a.x), + get(params, line.b.y) - get(params, line.a.y), + ) +} + +fn radius_squared(params: &[f64], curve: CurveRadius) -> f64 { + match curve { + CurveRadius::Explicit { radius, .. } => { + let r = get(params, radius); + r * r + } + CurveRadius::FromSpoke { center, spoke } => { + let dx = get(params, spoke.x) - get(params, center.x); + let dy = get(params, spoke.y) - get(params, center.y); + dx * dx + dy * dy + } + } +} + +fn add_radius_squared_jacobian( + params: &[f64], + row: ResidualIndex, + curve: CurveRadius, + sink: &mut Vec, +) { + match curve { + CurveRadius::Explicit { radius, .. } => { + sink.push((row, radius, 2.0 * get(params, radius))); + } + CurveRadius::FromSpoke { center, spoke } => { + let dx = get(params, spoke.x) - get(params, center.x); + let dy = get(params, spoke.y) - get(params, center.y); + sink.push((row, spoke.x, 2.0 * dx)); + sink.push((row, spoke.y, 2.0 * dy)); + sink.push((row, center.x, -2.0 * dx)); + sink.push((row, center.y, -2.0 * dy)); + } + } +} + +fn subtract_radius_squared_jacobian( + params: &[f64], + row: ResidualIndex, + curve: CurveRadius, + sink: &mut Vec, +) { + match curve { + CurveRadius::Explicit { radius, .. } => { + sink.push((row, radius, -2.0 * get(params, radius))); + } + CurveRadius::FromSpoke { center, spoke } => { + let dx = get(params, spoke.x) - get(params, center.x); + let dy = get(params, spoke.y) - get(params, center.y); + sink.push((row, spoke.x, -2.0 * dx)); + sink.push((row, spoke.y, -2.0 * dy)); + sink.push((row, center.x, 2.0 * dx)); + sink.push((row, center.y, 2.0 * dy)); + } + } +} + +fn tangent_line_curve_jacobian( + params: &[f64], + row: ResidualIndex, + line: LineHandle, + curve: CurveRadius, + sink: &mut Vec, +) { + let (dx, dy) = delta(params, line); + let cx = get(params, curve.center().x); + let cy = get(params, curve.center().y); + let ex = cx - get(params, line.a.x); + let ey = cy - get(params, line.a.y); + let cross = ex * dy - ey * dx; + let len2 = dx * dx + dy * dy; + let r2 = radius_squared(params, curve); + let two_cross = 2.0 * cross; + + sink.push(( + row, + line.a.x, + two_cross * (cy - get(params, line.b.y)) + r2 * 2.0 * dx, + )); + sink.push(( + row, + line.a.y, + two_cross * (get(params, line.b.x) - cx) + r2 * 2.0 * dy, + )); + sink.push(( + row, + line.b.x, + two_cross * (get(params, line.a.y) - cy) - r2 * 2.0 * dx, + )); + sink.push(( + row, + line.b.y, + two_cross * (cx - get(params, line.a.x)) - r2 * 2.0 * dy, + )); + sink.push((row, curve.center().x, two_cross * dy)); + sink.push((row, curve.center().y, -two_cross * dx)); + + let neg_len2 = -len2; + match curve { + CurveRadius::Explicit { radius, .. } => { + sink.push((row, radius, neg_len2 * 2.0 * get(params, radius))); + } + CurveRadius::FromSpoke { center, spoke } => { + let sdx = get(params, spoke.x) - get(params, center.x); + let sdy = get(params, spoke.y) - get(params, center.y); + sink.push((row, spoke.x, neg_len2 * 2.0 * sdx)); + sink.push((row, spoke.y, neg_len2 * 2.0 * sdy)); + sink.push((row, center.x, neg_len2 * -2.0 * sdx)); + sink.push((row, center.y, neg_len2 * -2.0 * sdy)); + } + } +} + +fn tangent_curve_curve_jacobian( + params: &[f64], + row: ResidualIndex, + a: CurveRadius, + b: CurveRadius, + sink: &mut Vec, +) { + let ra = radius_squared(params, a).sqrt(); + let rb = radius_squared(params, b).sqrt(); + let rsum = ra + rb; + let u = get(params, a.center().x) - get(params, b.center().x); + let v = get(params, a.center().y) - get(params, b.center().y); + + sink.push((row, a.center().x, 2.0 * u)); + sink.push((row, a.center().y, 2.0 * v)); + sink.push((row, b.center().x, -2.0 * u)); + sink.push((row, b.center().y, -2.0 * v)); + + let coef = -2.0 * rsum; + push_radius_linear_jacobian(params, row, a, coef, sink); + push_radius_linear_jacobian(params, row, b, coef, sink); +} + +fn push_radius_linear_jacobian( + params: &[f64], + row: ResidualIndex, + curve: CurveRadius, + coef: f64, + sink: &mut Vec, +) { + if coef == 0.0 { + return; + } + match curve { + CurveRadius::Explicit { radius, .. } => { + sink.push((row, radius, coef)); + } + CurveRadius::FromSpoke { center, spoke } => { + let dx = get(params, spoke.x) - get(params, center.x); + let dy = get(params, spoke.y) - get(params, center.y); + let len = (dx * dx + dy * dy).sqrt(); + if len == 0.0 { + return; + } + let k = coef / len; + sink.push((row, spoke.x, k * dx)); + sink.push((row, spoke.y, k * dy)); + sink.push((row, center.x, -k * dx)); + sink.push((row, center.y, -k * dy)); + } + } +} diff --git a/crates/bone-solver/src/system.rs b/crates/bone-solver/src/system.rs new file mode 100644 index 0000000..7ebd656 --- /dev/null +++ b/crates/bone-solver/src/system.rs @@ -0,0 +1,254 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use bone_types::{Parameter, ParameterIndex, ParentIndex, ResidualIndex}; + +use crate::graph::Component; +use crate::residual::{CurveRadius, LineHandle, PointHandle, Residual}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ConstraintSystem { + parameters: Vec, + residuals: Vec, +} + +impl ConstraintSystem { + #[must_use] + pub fn new(parameters: Vec, residuals: Vec) -> Self { + Self { + parameters, + residuals, + } + } + + #[must_use] + pub fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + #[must_use] + pub fn residuals(&self) -> &[Residual] { + &self.residuals + } + + #[must_use] + pub fn parameter_count(&self) -> usize { + self.parameters.len() + } + + #[must_use] + pub fn row_count(&self) -> usize { + self.residuals.iter().map(Residual::rows).sum() + } + + #[must_use] + pub fn row_offsets(&self) -> Vec { + self.residuals + .iter() + .scan(0u32, |acc, r| { + let start = ResidualIndex::new(*acc); + let Ok(n) = u32::try_from(r.rows()) else { + unreachable!("Residual::rows is bounded to a small constant") + }; + let Some(next) = acc.checked_add(n) else { + unreachable!("row count fits in u32 by construction of ResidualIndex") + }; + *acc = next; + Some(start) + }) + .collect() + } + + #[must_use] + pub fn parameter_at(&self, idx: ParameterIndex) -> Parameter { + self.parameters[idx.as_usize()] + } + + #[must_use] + pub fn residual_at_parent(&self, parent: ParentIndex) -> &Residual { + &self.residuals[parent.as_usize()] + } + + #[must_use] + pub fn with_extra_residuals(&self, extra: Vec) -> Self { + let mut residuals = self.residuals.clone(); + residuals.extend(extra); + Self { + parameters: self.parameters.clone(), + residuals, + } + } + + #[must_use] + pub fn parent_of_row(&self, row: ResidualIndex) -> ParentIndex { + let target = row.as_usize(); + let found = self + .residuals + .iter() + .scan(0usize, |acc, r| { + let start = *acc; + *acc += r.rows(); + Some((start, *acc)) + }) + .position(|(begin, end)| target >= begin && target < end); + let Some(idx) = found else { + unreachable!("ResidualIndex {row} is past the residual row count") + }; + let Ok(iv) = u32::try_from(idx) else { + unreachable!("parent count fits in u32 via ResidualIndex") + }; + ParentIndex::new(iv) + } + + #[must_use] + pub fn rows_of_parent(&self, parent: ParentIndex) -> Vec { + let idx = parent.as_usize(); + let Some(residual) = self.residuals.get(idx) else { + unreachable!("ParentIndex {parent} out of range for this ConstraintSystem") + }; + let start_usize: usize = self.residuals[..idx].iter().map(Residual::rows).sum(); + let Ok(start) = u32::try_from(start_usize) else { + unreachable!("row count fits in u32 via ResidualIndex") + }; + (0..residual.rows()) + .map(|o| { + let Ok(ov) = u32::try_from(o) else { + unreachable!("residual row count is a small constant") + }; + ResidualIndex::new(start + ov) + }) + .collect() + } + + #[must_use] + pub fn without_parents(&self, exclude: &BTreeSet) -> Self { + let mask: BTreeSet = exclude.iter().copied().map(ParentIndex::as_usize).collect(); + let residuals = self + .residuals + .iter() + .enumerate() + .filter(|(i, _)| !mask.contains(i)) + .map(|(_, r)| r.clone()) + .collect(); + Self { + parameters: self.parameters.clone(), + residuals, + } + } + + #[must_use] + pub fn subsystem(&self, component: &Component) -> Subsystem { + let orig_params: &[ParameterIndex] = component.parameters(); + let remap: BTreeMap = orig_params + .iter() + .copied() + .enumerate() + .map(|(i, orig)| { + let Ok(iv) = u32::try_from(i) else { + unreachable!("component parameter count fits in u32 by construction") + }; + (orig, ParameterIndex::new(iv)) + }) + .collect(); + let parameters: Vec = orig_params + .iter() + .map(|p| self.parameters[p.as_usize()]) + .collect(); + let residuals: Vec = component + .residual_parents() + .iter() + .map(|&parent| remap_residual(self.residual_at_parent(parent), &remap)) + .collect(); + Subsystem { + system: ConstraintSystem::new(parameters, residuals), + param_map: orig_params.to_vec(), + } + } +} + +#[derive(Clone, Debug)] +pub struct Subsystem { + system: ConstraintSystem, + param_map: Vec, +} + +impl Subsystem { + #[must_use] + pub fn system(&self) -> &ConstraintSystem { + &self.system + } + + #[must_use] + pub fn param_map(&self) -> &[ParameterIndex] { + &self.param_map + } +} + +fn remap_residual(r: &Residual, m: &BTreeMap) -> Residual { + let idx = |p: ParameterIndex| -> ParameterIndex { + let Some(&mapped) = m.get(&p) else { + unreachable!("residual parameter missing from component remap (graph bug)") + }; + mapped + }; + let point = |p: PointHandle| PointHandle { + x: idx(p.x), + y: idx(p.y), + }; + let line = |l: LineHandle| LineHandle { + a: point(l.a), + b: point(l.b), + }; + let curve = |c: CurveRadius| match c { + CurveRadius::Explicit { center, radius } => CurveRadius::Explicit { + center: point(center), + radius: idx(radius), + }, + CurveRadius::FromSpoke { center, spoke } => CurveRadius::FromSpoke { + center: point(center), + spoke: point(spoke), + }, + }; + match *r { + Residual::Pin { param, target } => Residual::Pin { + param: idx(param), + target, + }, + Residual::Horizontal(l) => Residual::Horizontal(line(l)), + Residual::Vertical(l) => Residual::Vertical(line(l)), + Residual::Parallel(a, b) => Residual::Parallel(line(a), line(b)), + Residual::Perpendicular(a, b) => Residual::Perpendicular(line(a), line(b)), + Residual::TangentLineCurve { line: l, curve: c } => Residual::TangentLineCurve { + line: line(l), + curve: curve(c), + }, + Residual::TangentCurveCurve { a, b } => Residual::TangentCurveCurve { + a: curve(a), + b: curve(b), + }, + Residual::CoincidentPointPoint(p, q) => Residual::CoincidentPointPoint(point(p), point(q)), + Residual::CoincidentPointLine { point: p, line: l } => Residual::CoincidentPointLine { + point: point(p), + line: line(l), + }, + Residual::CoincidentPointCurve { point: p, curve: c } => Residual::CoincidentPointCurve { + point: point(p), + curve: curve(c), + }, + Residual::EqualLength(a, b) => Residual::EqualLength(line(a), line(b)), + Residual::EqualRadius(a, b) => Residual::EqualRadius(curve(a), curve(b)), + Residual::LinearDistance { a, b, value_mm } => Residual::LinearDistance { + a: point(a), + b: point(b), + value_mm, + }, + Residual::AngularBetweenLines { a, b, angle_rad } => Residual::AngularBetweenLines { + a: line(a), + b: line(b), + angle_rad, + }, + Residual::RadiusCurve { curve: c, value_mm } => Residual::RadiusCurve { + curve: curve(c), + value_mm, + }, + } +} diff --git a/crates/bone-solver/src/util.rs b/crates/bone-solver/src/util.rs new file mode 100644 index 0000000..d78dae3 --- /dev/null +++ b/crates/bone-solver/src/util.rs @@ -0,0 +1,21 @@ +use std::collections::BTreeSet; + +#[must_use] +pub fn dedup_preserving_order(items: I) -> Vec +where + T: Copy + Ord, + I: IntoIterator, +{ + items + .into_iter() + .fold( + (Vec::new(), BTreeSet::::new()), + |(mut acc, mut seen), item| { + if seen.insert(item) { + acc.push(item); + } + (acc, seen) + }, + ) + .0 +} diff --git a/crates/bone-solver/tests/core.rs b/crates/bone-solver/tests/core.rs new file mode 100644 index 0000000..9507a7c --- /dev/null +++ b/crates/bone-solver/tests/core.rs @@ -0,0 +1,196 @@ +use bone_solver::{ + ConstraintSystem, LineHandle, NewtonConfig, PointHandle, Residual, SolverError, + assemble_jacobian, decompose, evaluate_residuals, jacobian_triplets, residual_norm, + solve_newton, solve_newton_decomposed, sparsity_pattern, +}; +use bone_types::{NewtonDamping, Parameter, ParameterIndex, ResidualIndex, SolverResidual}; + +fn p(idx: u32) -> ParameterIndex { + ParameterIndex::new(idx) +} + +fn point(x: u32, y: u32) -> PointHandle { + PointHandle { x: p(x), y: p(y) } +} + +fn parameters(values: &[f64]) -> Vec { + values.iter().copied().map(Parameter::new).collect() +} + +#[test] +fn horizontal_residual_row_and_jacobian_match_hand_derivation() { + let system = ConstraintSystem::new( + parameters(&[0.0, 1.5, 3.0, 4.0]), + vec![Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + })], + ); + assert_eq!(system.row_count(), 1); + let values = evaluate_residuals(&system, &[0.0, 1.5, 3.0, 4.0]); + assert!((values[0] - (1.5 - 4.0)).abs() < 1e-15); + let triplets = jacobian_triplets(&system, &[0.0, 1.5, 3.0, 4.0]); + assert_eq!(triplets.len(), 2); + let zero = ResidualIndex::new(0); + assert!(triplets.contains(&(zero, p(1), 1.0))); + assert!(triplets.contains(&(zero, p(3), -1.0))); +} + +#[test] +fn pin_solves_in_one_newton_step() { + let system = ConstraintSystem::new( + parameters(&[3.0]), + vec![Residual::Pin { + param: p(0), + target: 7.0, + }], + ); + let Ok(out) = solve_newton(&system, NewtonConfig::DEFAULT) else { + panic!("pin is linear, must converge"); + }; + assert!((out[0].value() - 7.0).abs() < 1e-9); +} + +#[test] +fn coincident_point_point_is_two_row_residual() { + let system = ConstraintSystem::new( + parameters(&[0.0, 0.0, 1.0, 2.0]), + vec![Residual::CoincidentPointPoint(point(0, 1), point(2, 3))], + ); + assert_eq!(system.row_count(), 2); + let r = evaluate_residuals(&system, &[0.0, 0.0, 1.0, 2.0]); + assert_eq!(r.len(), 2); + assert!((r[0] - (0.0 - 1.0)).abs() < 1e-15); + assert!((r[1] - (0.0 - 2.0)).abs() < 1e-15); +} + +#[test] +fn sparsity_pattern_drops_duplicate_positions() { + let system = ConstraintSystem::new( + parameters(&[0.0, 0.0]), + vec![Residual::Pin { + param: p(0), + target: 1.0, + }], + ); + let pattern = sparsity_pattern(&system); + assert_eq!(pattern.rows(), 1); + assert_eq!(pattern.cols(), 2); + assert_eq!(pattern.entries().len(), 1); +} + +#[test] +fn default_damping_regularises_rank_deficient_jacobian() { + let system = ConstraintSystem::new( + parameters(&[0.0, 3.0, 5.0, 7.0]), + vec![Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + })], + ); + let Ok(out) = solve_newton(&system, NewtonConfig::DEFAULT) else { + panic!("default damping must regularise rank-deficient J^T J"); + }; + assert!((out[1].value() - out[3].value()).abs() < 1e-9); +} + +#[test] +fn zero_damping_on_rank_deficient_jacobian_does_not_silently_succeed() { + let system = ConstraintSystem::new( + parameters(&[0.0, 3.0, 5.0, 7.0]), + vec![Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + })], + ); + let cfg = NewtonConfig { + damping: NewtonDamping::new(0.0), + ..NewtonConfig::DEFAULT + }; + let Err(err) = solve_newton(&system, cfg) else { + panic!("damping=0 on rank-deficient system must surface an error"); + }; + assert!( + matches!( + err, + SolverError::InvalidSolutionFound { .. } | SolverError::NoSolutionFound { .. } + ), + "expected InvalidSolutionFound or NoSolutionFound, got {err:?}" + ); +} + +#[test] +fn residual_norm_matches_euclidean_length() { + let out = evaluate_residuals( + &ConstraintSystem::new( + parameters(&[3.0]), + vec![Residual::Pin { + param: p(0), + target: 0.0, + }], + ), + &[3.0], + ); + let norm = residual_norm(&out); + assert!((norm.value() - 3.0).abs() < 1e-15); + let _ = SolverResidual::new(norm.value()); +} + +#[test] +fn decomposed_singular_index_is_translated_to_global_parameter() { + let pins: Vec = (0..10) + .map(|i| Residual::Pin { + param: p(i), + target: 0.0, + }) + .collect(); + let residuals: Vec = pins + .into_iter() + .chain(std::iter::once(Residual::Horizontal(LineHandle { + a: point(10, 11), + b: point(12, 13), + }))) + .collect(); + let seed = [0.0; 10] + .iter() + .copied() + .chain([0.0, 5.0, 1.0, 7.0]) + .collect::>(); + let system = ConstraintSystem::new(parameters(&seed), residuals); + let decomp = decompose(&system); + let cfg = NewtonConfig { + damping: NewtonDamping::new(0.0), + ..NewtonConfig::DEFAULT + }; + match solve_newton_decomposed(&system, &decomp, cfg) { + Err(SolverError::InvalidSolutionFound { at }) => { + assert!( + at.as_usize() >= 10, + "expected global parameter index (>=10), got {at:?}" + ); + } + Err(SolverError::NoSolutionFound { .. }) => {} + other => panic!("expected InvalidSolutionFound or NoSolutionFound, got {other:?}"), + } +} + +#[test] +fn assemble_jacobian_and_triplets_agree_on_dense_shape() { + let system = ConstraintSystem::new( + parameters(&[0.0, 0.0, 2.0, 0.0]), + vec![Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + })], + ); + let params = vec![0.0, 0.0, 2.0, 0.0]; + let dense = assemble_jacobian(&system, ¶ms); + assert_eq!(dense.nrows(), 1); + assert_eq!(dense.ncols(), 4); + let triplets = jacobian_triplets(&system, ¶ms); + let sum: f64 = triplets + .iter() + .map(|(r, c, v)| dense[(r.as_usize(), c.as_usize())] - v) + .sum(); + assert!(sum.abs() < 1e-15); +} diff --git a/crates/bone-solver/tests/dof.rs b/crates/bone-solver/tests/dof.rs new file mode 100644 index 0000000..3591226 --- /dev/null +++ b/crates/bone-solver/tests/dof.rs @@ -0,0 +1,153 @@ +use bone_solver::{ConstraintSystem, DofConfig, LineHandle, PointHandle, Residual, analyze_dof}; +use bone_types::{Parameter, ParameterIndex, ResidualIndex}; + +fn p(i: u32) -> ParameterIndex { + ParameterIndex::new(i) +} + +fn point(x: u32, y: u32) -> PointHandle { + PointHandle { x: p(x), y: p(y) } +} + +fn parameters(values: &[f64]) -> Vec { + values.iter().copied().map(Parameter::new).collect() +} + +#[test] +fn fully_constrained_pin_has_zero_dof_and_no_flags() { + let system = ConstraintSystem::new( + parameters(&[0.0]), + vec![Residual::Pin { + param: p(0), + target: 5.0, + }], + ); + let report = analyze_dof(&system, DofConfig::DEFAULT); + assert_eq!(report.dof().value(), 0); + assert!(report.under_constrained().is_empty()); + assert!(report.over_constrained().is_empty()); + assert!(report.redundant_consistent().is_empty()); +} + +#[test] +fn under_constrained_horizontal_reports_free_parameters() { + let system = ConstraintSystem::new( + parameters(&[0.0, 0.0, 1.0, 0.0]), + vec![Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + })], + ); + let report = analyze_dof(&system, DofConfig::DEFAULT); + assert_eq!(report.dof().value(), 3); + assert_eq!(report.under_constrained().len(), 3); + assert!(report.over_constrained().is_empty()); + assert!(report.redundant_consistent().is_empty()); +} + +#[test] +fn redundant_pin_is_consistent_not_conflict() { + let system = ConstraintSystem::new( + parameters(&[5.0]), + vec![ + Residual::Pin { + param: p(0), + target: 5.0, + }, + Residual::Pin { + param: p(0), + target: 5.0, + }, + ], + ); + let report = analyze_dof(&system, DofConfig::DEFAULT); + assert_eq!(report.dof().value(), 0); + assert!(report.over_constrained().is_empty()); + assert_eq!(report.redundant_consistent().len(), 1); + let flagged = report.redundant_consistent()[0]; + assert!(flagged == ResidualIndex::new(0) || flagged == ResidualIndex::new(1)); +} + +#[test] +fn dof_fixture_matrix_snapshot() { + let fully_constrained = ConstraintSystem::new( + parameters(&[0.0]), + vec![Residual::Pin { + param: p(0), + target: 5.0, + }], + ); + let under_constrained = ConstraintSystem::new( + parameters(&[0.0, 0.0, 1.0, 0.0]), + vec![Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + })], + ); + let redundant_consistent = ConstraintSystem::new( + parameters(&[5.0]), + vec![ + Residual::Pin { + param: p(0), + target: 5.0, + }, + Residual::Pin { + param: p(0), + target: 5.0, + }, + ], + ); + let conflicting = ConstraintSystem::new( + parameters(&[3.0]), + vec![ + Residual::Pin { + param: p(0), + target: 3.0, + }, + Residual::Pin { + param: p(0), + target: 7.0, + }, + ], + ); + let bundle = [ + ("fully_constrained", fully_constrained), + ("under_constrained", under_constrained), + ("redundant_consistent", redundant_consistent), + ("conflicting", conflicting), + ]; + let rendered: String = bundle + .iter() + .map(|(label, system)| { + format!( + "{label}: {}", + analyze_dof(system, DofConfig::DEFAULT).display() + ) + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("dof_matrix", rendered); +} + +#[test] +fn conflicting_pins_yield_over_constrained_row() { + let system = ConstraintSystem::new( + parameters(&[3.0]), + vec![ + Residual::Pin { + param: p(0), + target: 3.0, + }, + Residual::Pin { + param: p(0), + target: 7.0, + }, + ], + ); + let report = analyze_dof(&system, DofConfig::DEFAULT); + assert_eq!(report.dof().value(), 0); + assert_eq!(report.over_constrained().len(), 1); + assert!(report.redundant_consistent().is_empty()); + let flagged = report.over_constrained()[0]; + assert!(flagged == ResidualIndex::new(0) || flagged == ResidualIndex::new(1)); +} diff --git a/crates/bone-solver/tests/graph.rs b/crates/bone-solver/tests/graph.rs new file mode 100644 index 0000000..53e05cf --- /dev/null +++ b/crates/bone-solver/tests/graph.rs @@ -0,0 +1,107 @@ +use bone_solver::{ConstraintSystem, LineHandle, PointHandle, Residual, decompose}; +use bone_types::{Parameter, ParameterIndex, ParentIndex, ResidualIndex}; + +fn p(i: u32) -> ParameterIndex { + ParameterIndex::new(i) +} + +fn parent(i: u32) -> ParentIndex { + ParentIndex::new(i) +} + +fn point(x: u32, y: u32) -> PointHandle { + PointHandle { x: p(x), y: p(y) } +} + +fn parameters(values: &[f64]) -> Vec { + values.iter().copied().map(Parameter::new).collect() +} + +#[test] +fn disjoint_residuals_yield_separate_components() { + let system = ConstraintSystem::new( + parameters(&[0.0, 0.0, 1.0, 0.0, 10.0, 0.0, 11.0, 0.0]), + vec![ + Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + }), + Residual::Horizontal(LineHandle { + a: point(4, 5), + b: point(6, 7), + }), + ], + ); + let decomp = decompose(&system); + assert_eq!(decomp.components().len(), 2); + let first = &decomp.components()[0]; + let second = &decomp.components()[1]; + assert_eq!( + first + .parameters() + .iter() + .map(|p| p.value()) + .collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!( + second + .parameters() + .iter() + .map(|p| p.value()) + .collect::>(), + vec![4, 5, 6, 7] + ); + assert_eq!(first.residual_parents(), &[parent(0)]); + assert_eq!(second.residual_parents(), &[parent(1)]); + assert_eq!(first.residual_rows(&system), vec![ResidualIndex::new(0)]); + assert_eq!(second.residual_rows(&system), vec![ResidualIndex::new(1)]); +} + +#[test] +fn shared_parameter_merges_components() { + let system = ConstraintSystem::new( + parameters(&[0.0, 0.0, 1.0, 0.0, 2.0, 0.0]), + vec![ + Residual::Horizontal(LineHandle { + a: point(0, 1), + b: point(2, 3), + }), + Residual::Horizontal(LineHandle { + a: point(2, 3), + b: point(4, 5), + }), + ], + ); + let decomp = decompose(&system); + assert_eq!(decomp.components().len(), 1); + let only = &decomp.components()[0]; + assert_eq!(only.parameters().len(), 6); + assert_eq!(only.residual_parents(), &[parent(0), parent(1)]); +} + +#[test] +fn isolated_parameter_forms_its_own_component() { + let system = ConstraintSystem::new( + parameters(&[0.0, 0.0, 3.0]), + vec![Residual::Pin { + param: p(0), + target: 1.0, + }], + ); + let decomp = decompose(&system); + assert_eq!(decomp.components().len(), 3); + decomp + .components() + .iter() + .enumerate() + .for_each(|(i, comp)| { + let Ok(iv) = u32::try_from(i) else { + unreachable!() + }; + assert_eq!(comp.parameters(), &[ParameterIndex::new(iv)]); + }); + assert_eq!(decomp.components()[0].residual_parents(), &[parent(0)]); + assert!(decomp.components()[1].residual_parents().is_empty()); + assert!(decomp.components()[2].residual_parents().is_empty()); +} diff --git a/crates/bone-solver/tests/snapshots/dof__dof_matrix.snap b/crates/bone-solver/tests/snapshots/dof__dof_matrix.snap new file mode 100644 index 0000000..9c2d8b0 --- /dev/null +++ b/crates/bone-solver/tests/snapshots/dof__dof_matrix.snap @@ -0,0 +1,8 @@ +--- +source: crates/bone-solver/tests/dof.rs +expression: rendered +--- +fully_constrained: dof=0 under=[] over=[] redundant=[] +under_constrained: dof=3 under=[p#0,p#2,p#3] over=[] redundant=[] +redundant_consistent: dof=0 under=[] over=[] redundant=[r#1] +conflicting: dof=0 under=[] over=[r#1] redundant=[] diff --git a/crates/bone-types/Cargo.toml b/crates/bone-types/Cargo.toml new file mode 100644 index 0000000..cdca93a --- /dev/null +++ b/crates/bone-types/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bone-types" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +nalgebra = { workspace = true } +serde = { workspace = true } +slotmap = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } +uom = { workspace = true } + +[dev-dependencies] +insta = { workspace = true } + +[features] +testing = ["dep:tracing", "dep:tracing-subscriber"] + +[lints] +workspace = true diff --git a/crates/bone-types/src/dimensioned_serde.rs b/crates/bone-types/src/dimensioned_serde.rs new file mode 100644 index 0000000..c2c3a5c --- /dev/null +++ b/crates/bone-types/src/dimensioned_serde.rs @@ -0,0 +1,27 @@ +pub mod length_si { + use serde::{Deserialize, Deserializer, Serializer}; + use uom::si::f64::Length; + use uom::si::length::meter; + + pub fn serialize(value: &Length, s: S) -> Result { + s.serialize_f64(value.get::()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + f64::deserialize(d).map(Length::new::) + } +} + +pub mod angle_si { + use serde::{Deserialize, Deserializer, Serializer}; + use uom::si::angle::radian; + use uom::si::f64::Angle; + + pub fn serialize(value: &Angle, s: S) -> Result { + s.serialize_f64(value.get::()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + f64::deserialize(d).map(Angle::new::) + } +} diff --git a/crates/bone-types/src/lib.rs b/crates/bone-types/src/lib.rs new file mode 100644 index 0000000..abac6c7 --- /dev/null +++ b/crates/bone-types/src/lib.rs @@ -0,0 +1,340 @@ +pub use uom::si::angle::{degree, radian}; +pub use uom::si::f64::{Angle, Length}; +pub use uom::si::length::millimeter; + +pub mod dimensioned_serde; +pub mod schema; +pub mod solver; +pub mod space; + +pub use schema::{SchemaHeader, SchemaVersion}; +pub use solver::{ + BudgetCeiling, DegreesOfFreedom, NewtonDamping, NewtonStepTolerance, Parameter, ParameterIndex, + ParentIndex, ResidualIndex, SketchItemId, SolverResidual, SolverSeed, +}; +pub use space::{Point2, Point3, SketchPlaneBasis, UnitVec2, UnitVec3, Vec2}; + +#[cfg(feature = "testing")] +pub mod testing; + +#[derive(Debug, thiserror::Error)] +pub enum TypesError { + #[error("sketch plane axes not orthogonal: |x·y|={0}")] + NonOrthogonalPlaneAxes(f64), + #[error("axis vector is zero-length")] + ZeroLengthAxis, +} + +pub type Result = core::result::Result; + +slotmap::new_key_type! { + pub struct FaceId; + pub struct EdgeId; + pub struct VertexId; + pub struct ShellId; + pub struct SolidId; + pub struct LoopId; + pub struct WireId; + pub struct FeatureId; + pub struct NodeId; + pub struct DocumentId; + pub struct SketchId; + pub struct SketchEntityId; + pub struct SketchRelationId; + pub struct SketchDimensionId; + pub struct SketchParameterId; +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct Tolerance(f64); + +impl Tolerance { + #[must_use] + pub const fn new(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } +} + +impl core::fmt::Display for Tolerance { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "tol={}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct AngleTolerance(f64); + +impl AngleTolerance { + pub const ZERO: Self = Self(0.0); + + #[must_use] + pub const fn from_radians(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub const fn radians(self) -> f64 { + self.0 + } + + #[must_use] + pub fn from_arc_length(linear: Tolerance, radius: Length) -> Self { + let r = radius.get::().abs(); + if r > linear.value() { + Self(linear.value() / r) + } else { + Self(linear.value()) + } + } +} + +impl core::fmt::Display for AngleTolerance { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "tol_angle={} rad", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::{ + Angle, AngleTolerance, DegreesOfFreedom, DocumentId, EdgeId, FaceId, FeatureId, Length, + LoopId, NodeId, Parameter, Point2, Point3, ShellId, SketchDimensionId, SketchEntityId, + SketchId, SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, + Tolerance, UnitVec2, UnitVec3, Vec2, VertexId, WireId, degree, millimeter, radian, + }; + use slotmap::Key; + + #[test] + fn id_null_is_null() { + assert!(FaceId::null().is_null()); + assert!(EdgeId::null().is_null()); + assert!(VertexId::null().is_null()); + assert!(ShellId::null().is_null()); + assert!(SolidId::null().is_null()); + assert!(LoopId::null().is_null()); + assert!(WireId::null().is_null()); + assert!(FeatureId::null().is_null()); + assert!(NodeId::null().is_null()); + assert!(DocumentId::null().is_null()); + assert!(SketchId::null().is_null()); + assert!(SketchEntityId::null().is_null()); + assert!(SketchRelationId::null().is_null()); + assert!(SketchDimensionId::null().is_null()); + assert!(SketchParameterId::null().is_null()); + } + + #[test] + fn ids_are_distinct_types() { + let _f: FaceId = FaceId::null(); + let _e: EdgeId = EdgeId::null(); + let _s: SketchEntityId = SketchEntityId::null(); + let _r: SketchRelationId = SketchRelationId::null(); + } + + #[test] + fn length_mm_roundtrip() { + let l = Length::new::(12.5); + assert!((l.get::() - 12.5).abs() < f64::EPSILON); + } + + #[test] + fn angle_deg_converts_to_rad() { + let a = Angle::new::(180.0); + assert!((a.get::() - core::f64::consts::PI).abs() < 1e-12); + } + + #[test] + fn tolerance_display() { + let t = Tolerance::new(1e-6); + assert_eq!(format!("{t}"), "tol=0.000001"); + assert!((t.value() - 1e-6).abs() < f64::EPSILON); + } + + #[test] + fn angle_tolerance_from_arc_length_scales_by_radius() { + let linear = Tolerance::new(1e-3); + let r = Length::new::(10.0); + let a = AngleTolerance::from_arc_length(linear, r); + assert!((a.radians() - 1e-4).abs() < 1e-15); + } + + #[test] + fn angle_tolerance_degenerate_radius_falls_back_to_linear() { + let linear = Tolerance::new(1e-3); + let r = Length::new::(0.0); + let a = AngleTolerance::from_arc_length(linear, r); + assert!((a.radians() - 1e-3).abs() < 1e-15); + } + + #[test] + fn angle_tolerance_zero_constant() { + assert!(AngleTolerance::ZERO.radians().abs() < f64::EPSILON); + } + + #[test] + fn point2_minus_point2_is_vec2() { + let a = Point2::from_mm(10.0, 5.0); + let b = Point2::from_mm(4.0, 2.0); + let d: Vec2 = a - b; + assert!((d.x().get::() - 6.0).abs() < 1e-12); + assert!((d.y().get::() - 3.0).abs() < 1e-12); + } + + #[test] + fn point2_plus_vec2_is_point2() { + let p = Point2::from_mm(1.0, 2.0); + let v = Vec2::from_mm(10.0, 20.0); + let q: Point2 = p + v; + assert!((q.x().get::() - 11.0).abs() < 1e-12); + assert!((q.y().get::() - 22.0).abs() < 1e-12); + } + + #[test] + fn vec2_perp_and_dot() { + let v = Vec2::from_mm(3.0, 4.0); + assert!((v.norm_mm() - 5.0).abs() < 1e-12); + let p = v.perp_ccw(); + assert!((p.x().get::() + 4.0).abs() < 1e-12); + assert!((p.y().get::() - 3.0).abs() < 1e-12); + assert!(v.dot_mm2(p).abs() < 1e-12); + assert!((v.cross_z_mm2(Vec2::from_mm(1.0, 0.0)) + 4.0).abs() < 1e-12); + } + + #[test] + fn vec2_try_normalize_rejects_zero() { + let v = Vec2::zero(); + let r = v.try_normalize(Tolerance::new(1e-9)); + assert!(r.is_err()); + } + + #[test] + fn vec2_try_normalize_unit() { + let v = Vec2::from_mm(0.0, 7.0); + let Ok(u) = v.try_normalize(Tolerance::new(1e-9)) else { + panic!("nonzero"); + }; + let (ux, uy) = u.components(); + assert!(ux.abs() < 1e-12); + assert!((uy - 1.0).abs() < 1e-12); + } + + #[test] + fn unit_vec2_axes_are_orthogonal() { + let x = UnitVec2::x_axis(); + let y = UnitVec2::y_axis(); + assert!(x.dot(y).abs() < 1e-15); + assert_eq!(x.perp_ccw().components(), y.components()); + } + + #[test] + fn unit_vec2_rejects_zero_axis() { + let r = UnitVec2::try_from_components(0.0, 0.0, Tolerance::new(1e-9)); + assert!(r.is_err()); + } + + #[test] + fn unit_vec2_into_vec_scales_by_length() { + let u = UnitVec2::x_axis(); + let v = u.into_vec(Length::new::(3.0)); + assert!((v.x().get::() - 3.0).abs() < 1e-12); + assert!(v.y().get::().abs() < 1e-12); + } + + #[test] + fn vec2_scalar_and_negation() { + let v = Vec2::from_mm(3.0, -4.0); + let w = 2.0 * (-v); + assert!((w.x().get::() + 6.0).abs() < 1e-12); + assert!((w.y().get::() - 8.0).abs() < 1e-12); + } + + #[test] + fn point3_roundtrip() { + let p = Point3::from_mm(1.5, 2.5, 3.5); + assert!((p.x().get::() - 1.5).abs() < 1e-12); + assert!((p.y().get::() - 2.5).abs() < 1e-12); + assert!((p.z().get::() - 3.5).abs() < 1e-12); + } + + #[test] + fn sketch_plane_basis_accepts_orthogonal_axes() { + let Ok(basis) = SketchPlaneBasis::new( + Point3::origin(), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + Tolerance::new(1e-9), + ) else { + panic!("x and y unit axes are orthogonal"); + }; + let (nx, ny, nz) = basis.normal().components(); + assert!((nx).abs() < 1e-12); + assert!((ny).abs() < 1e-12); + assert!((nz - 1.0).abs() < 1e-12); + } + + #[test] + fn sketch_plane_basis_reorthonormalizes_within_tolerance() { + let Ok(nearly) = UnitVec3::try_from_components(1e-10, 1.0, 0.0) else { + panic!("input vector is nonzero"); + }; + let Ok(basis) = SketchPlaneBasis::new( + Point3::origin(), + UnitVec3::x_axis(), + nearly, + Tolerance::new(1e-9), + ) else { + panic!("within tolerance"); + }; + let dot_after = basis.x_axis().dot(basis.y_axis()).abs(); + assert!( + dot_after < 1e-15, + "re-orthonormalized y should be orthogonal, got {dot_after}" + ); + } + + #[test] + fn sketch_plane_basis_rejects_non_orthogonal_axes() { + let Ok(skew) = UnitVec3::try_from_components(1.0, 1.0, 0.0) else { + panic!("input vector is nonzero"); + }; + let result = SketchPlaneBasis::new( + Point3::origin(), + UnitVec3::x_axis(), + skew, + Tolerance::new(1e-9), + ); + assert!(result.is_err()); + } + + #[test] + fn unit_vec3_rejects_zero_axis() { + let r = UnitVec3::try_from_components(0.0, 0.0, 0.0); + assert!(r.is_err()); + } + + #[test] + fn parameter_display_and_value() { + let p = Parameter::new(0.25); + assert_eq!(format!("{p}"), "param=0.25"); + assert!((p.value() - 0.25).abs() < f64::EPSILON); + } + + #[test] + fn degrees_of_freedom_display_and_value() { + let d = DegreesOfFreedom::new(7); + assert_eq!(format!("{d}"), "dof=7"); + assert_eq!(d.value(), 7); + } + + #[test] + fn solver_residual_display_and_value() { + let r = SolverResidual::new(1.5e-6); + assert_eq!(format!("{r}"), "res=0.0000015"); + assert!((r.value() - 1.5e-6).abs() < f64::EPSILON); + } +} diff --git a/crates/bone-types/src/schema.rs b/crates/bone-types/src/schema.rs new file mode 100644 index 0000000..18a03ac --- /dev/null +++ b/crates/bone-types/src/schema.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SchemaVersion { + pub major: u32, + pub minor: u32, +} + +impl SchemaVersion { + #[must_use] + pub const fn new(major: u32, minor: u32) -> Self { + Self { major, minor } + } +} + +impl core::fmt::Display for SchemaVersion { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}.{}", self.major, self.minor) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SchemaHeader { + pub name: String, + pub version: SchemaVersion, +} + +impl SchemaHeader { + pub const BONE_DOCUMENT_NAME: &'static str = "bone-document"; + pub const BONE_DOCUMENT_MAJOR: u32 = 1; + pub const BONE_DOCUMENT_MINOR: u32 = 0; + + #[must_use] + pub fn bone_document() -> Self { + Self { + name: Self::BONE_DOCUMENT_NAME.to_owned(), + version: SchemaVersion::new(Self::BONE_DOCUMENT_MAJOR, Self::BONE_DOCUMENT_MINOR), + } + } + + #[must_use] + pub fn is_bone_document(&self) -> bool { + self.name == Self::BONE_DOCUMENT_NAME + } +} + +impl core::fmt::Display for SchemaHeader { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{} v{}", self.name, self.version) + } +} diff --git a/crates/bone-types/src/solver.rs b/crates/bone-types/src/solver.rs new file mode 100644 index 0000000..167d82b --- /dev/null +++ b/crates/bone-types/src/solver.rs @@ -0,0 +1,254 @@ +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] +pub struct Parameter(f64); + +impl Parameter { + #[must_use] + pub const fn new(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } +} + +impl core::fmt::Display for Parameter { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "param={}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DegreesOfFreedom(u32); + +impl DegreesOfFreedom { + #[must_use] + pub const fn new(value: u32) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u32 { + self.0 + } +} + +impl core::fmt::Display for DegreesOfFreedom { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "dof={}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SolverSeed(u64); + +impl SolverSeed { + pub const DEFAULT: Self = Self(0x5E61_D000_7E57_5EEDu64); + + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +impl core::fmt::Display for SolverSeed { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "seed={:#018x}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ParameterIndex(u32); + +impl ParameterIndex { + #[must_use] + pub const fn new(value: u32) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u32 { + self.0 + } + + #[must_use] + pub const fn as_usize(self) -> usize { + self.0 as usize + } +} + +impl core::fmt::Display for ParameterIndex { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "p#{}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ParentIndex(u32); + +impl ParentIndex { + #[must_use] + pub const fn new(value: u32) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u32 { + self.0 + } + + #[must_use] + pub const fn as_usize(self) -> usize { + self.0 as usize + } +} + +impl core::fmt::Display for ParentIndex { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "parent#{}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ResidualIndex(u32); + +impl ResidualIndex { + #[must_use] + pub const fn new(value: u32) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u32 { + self.0 + } + + #[must_use] + pub const fn as_usize(self) -> usize { + self.0 as usize + } + + #[must_use] + pub const fn next(self) -> Self { + Self(self.0.saturating_add(1)) + } +} + +impl core::fmt::Display for ResidualIndex { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "r#{}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SketchItemId { + Relation(crate::SketchRelationId), + Dimension(crate::SketchDimensionId), + Entity(crate::SketchEntityId), +} + +impl core::fmt::Display for SketchItemId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Relation(id) => write!(f, "relation({id:?})"), + Self::Dimension(id) => write!(f, "dimension({id:?})"), + Self::Entity(id) => write!(f, "entity({id:?})"), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct SolverResidual(f64); + +impl SolverResidual { + #[must_use] + pub const fn new(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } +} + +impl core::fmt::Display for SolverResidual { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "res={}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct NewtonDamping(f64); + +impl NewtonDamping { + pub const DEFAULT: Self = Self(1e-12); + + #[must_use] + pub const fn new(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } +} + +impl core::fmt::Display for NewtonDamping { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "damping={}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub struct NewtonStepTolerance(f64); + +impl NewtonStepTolerance { + pub const DEFAULT: Self = Self(1e-14); + + #[must_use] + pub const fn new(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } +} + +impl core::fmt::Display for NewtonStepTolerance { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "step_tol={}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BudgetCeiling(core::time::Duration); + +impl BudgetCeiling { + pub const FRAME_16MS: Self = Self(core::time::Duration::from_millis(16)); + + #[must_use] + pub const fn new(duration: core::time::Duration) -> Self { + Self(duration) + } + + #[must_use] + pub const fn duration(self) -> core::time::Duration { + self.0 + } +} + +impl core::fmt::Display for BudgetCeiling { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "budget={:?}", self.0) + } +} diff --git a/crates/bone-types/src/space.rs b/crates/bone-types/src/space.rs new file mode 100644 index 0000000..109625f --- /dev/null +++ b/crates/bone-types/src/space.rs @@ -0,0 +1,539 @@ +use nalgebra::{Point2 as NPoint2, Point3 as NPoint3, Unit, Vector2 as NVec2, Vector3 as NVec3}; +use serde::{Deserialize, Serialize}; +use uom::si::f64::Length; +use uom::si::length::millimeter; + +use crate::{Result, Tolerance, TypesError}; + +#[must_use] +fn mm(value: f64) -> Length { + Length::new::(value) +} + +#[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] +#[serde(from = "Point2Wire", into = "Point2Wire")] +pub struct Point2(NPoint2); + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename = "Point2", deny_unknown_fields)] +struct Point2Wire { + x: f64, + y: f64, +} + +impl From for Point2Wire { + fn from(p: Point2) -> Self { + Self { x: p.0.x, y: p.0.y } + } +} + +impl From for Point2 { + fn from(w: Point2Wire) -> Self { + Self(NPoint2::new(w.x, w.y)) + } +} + +impl Point2 { + #[must_use] + pub fn origin() -> Self { + Self(NPoint2::origin()) + } + + #[must_use] + pub fn from_mm(x: f64, y: f64) -> Self { + Self(NPoint2::new(x, y)) + } + + #[must_use] + pub fn from_lengths(x: Length, y: Length) -> Self { + Self(NPoint2::new(x.get::(), y.get::())) + } + + #[must_use] + pub fn x(self) -> Length { + mm(self.0.x) + } + + #[must_use] + pub fn y(self) -> Length { + mm(self.0.y) + } + + #[must_use] + pub fn coords_mm(self) -> (f64, f64) { + (self.0.x, self.0.y) + } +} + +impl core::fmt::Debug for Point2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "Point2({} mm, {} mm)", self.0.x, self.0.y) + } +} + +impl core::fmt::Display for Point2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "({} mm, {} mm)", self.0.x, self.0.y) + } +} + +impl core::ops::Sub for Point2 { + type Output = Vec2; + fn sub(self, rhs: Self) -> Vec2 { + Vec2(self.0 - rhs.0) + } +} + +impl core::ops::Add for Point2 { + type Output = Self; + fn add(self, rhs: Vec2) -> Self { + Self(self.0 + rhs.0) + } +} + +impl core::ops::Sub for Point2 { + type Output = Self; + fn sub(self, rhs: Vec2) -> Self { + Self(self.0 - rhs.0) + } +} + +#[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] +#[serde(from = "Vec2Wire", into = "Vec2Wire")] +pub struct Vec2(NVec2); + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename = "Vec2", deny_unknown_fields)] +struct Vec2Wire { + x: f64, + y: f64, +} + +impl From for Vec2Wire { + fn from(v: Vec2) -> Self { + Self { x: v.0.x, y: v.0.y } + } +} + +impl From for Vec2 { + fn from(w: Vec2Wire) -> Self { + Self(NVec2::new(w.x, w.y)) + } +} + +impl Vec2 { + #[must_use] + pub fn zero() -> Self { + Self(NVec2::zeros()) + } + + #[must_use] + pub fn from_mm(x: f64, y: f64) -> Self { + Self(NVec2::new(x, y)) + } + + #[must_use] + pub fn from_lengths(x: Length, y: Length) -> Self { + Self(NVec2::new(x.get::(), y.get::())) + } + + #[must_use] + pub fn x(self) -> Length { + mm(self.0.x) + } + + #[must_use] + pub fn y(self) -> Length { + mm(self.0.y) + } + + #[must_use] + pub fn coords_mm(self) -> (f64, f64) { + (self.0.x, self.0.y) + } + + #[must_use] + pub fn dot_mm2(self, other: Self) -> f64 { + self.0.dot(&other.0) + } + + #[must_use] + pub fn cross_z_mm2(self, other: Self) -> f64 { + self.0.x * other.0.y - self.0.y * other.0.x + } + + #[must_use] + pub fn norm_squared_mm2(self) -> f64 { + self.0.norm_squared() + } + + #[must_use] + pub fn norm_mm(self) -> f64 { + self.0.norm() + } + + #[must_use] + pub fn norm(self) -> Length { + mm(self.0.norm()) + } + + #[must_use] + pub fn perp_ccw(self) -> Self { + Self(NVec2::new(-self.0.y, self.0.x)) + } + + pub fn try_normalize(self, tolerance: Tolerance) -> Result { + Unit::try_new(self.0, tolerance.value()) + .map(UnitVec2) + .ok_or(TypesError::ZeroLengthAxis) + } +} + +impl core::fmt::Debug for Vec2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "Vec2({} mm, {} mm)", self.0.x, self.0.y) + } +} + +impl core::fmt::Display for Vec2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "<{} mm, {} mm>", self.0.x, self.0.y) + } +} + +impl core::ops::Add for Vec2 { + type Output = Self; + fn add(self, rhs: Self) -> Self { + Self(self.0 + rhs.0) + } +} + +impl core::ops::Sub for Vec2 { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + Self(self.0 - rhs.0) + } +} + +impl core::ops::Mul for Vec2 { + type Output = Self; + fn mul(self, rhs: f64) -> Self { + Self(self.0 * rhs) + } +} + +impl core::ops::Mul for f64 { + type Output = Vec2; + fn mul(self, rhs: Vec2) -> Vec2 { + Vec2(rhs.0 * self) + } +} + +impl core::ops::Neg for Vec2 { + type Output = Self; + fn neg(self) -> Self { + Self(-self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "UnitVec2Wire", into = "UnitVec2Wire")] +pub struct UnitVec2(Unit>); + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename = "UnitVec2", deny_unknown_fields)] +struct UnitVec2Wire { + x: f64, + y: f64, +} + +impl From for UnitVec2Wire { + fn from(u: UnitVec2) -> Self { + Self { x: u.0.x, y: u.0.y } + } +} + +impl TryFrom for UnitVec2 { + type Error = TypesError; + fn try_from(w: UnitVec2Wire) -> Result { + Self::try_from_components(w.x, w.y, Tolerance::new(f64::EPSILON)) + } +} + +impl UnitVec2 { + #[must_use] + pub fn x_axis() -> Self { + Self(Unit::new_unchecked(NVec2::new(1.0, 0.0))) + } + + #[must_use] + pub fn y_axis() -> Self { + Self(Unit::new_unchecked(NVec2::new(0.0, 1.0))) + } + + #[must_use] + pub fn new_unchecked(x: f64, y: f64) -> Self { + Self(Unit::new_unchecked(NVec2::new(x, y))) + } + + pub fn try_from_components(x: f64, y: f64, tolerance: Tolerance) -> Result { + Unit::try_new(NVec2::new(x, y), tolerance.value()) + .map(Self) + .ok_or(TypesError::ZeroLengthAxis) + } + + #[must_use] + pub fn components(self) -> (f64, f64) { + (self.0.x, self.0.y) + } + + #[must_use] + pub fn dot(self, other: Self) -> f64 { + self.0.dot(&other.0) + } + + #[must_use] + pub fn perp_ccw(self) -> Self { + Self(Unit::new_unchecked(NVec2::new(-self.0.y, self.0.x))) + } + + #[must_use] + pub fn into_vec(self, length: Length) -> Vec2 { + let mm_value = length.get::(); + Vec2(self.0.into_inner() * mm_value) + } +} + +impl core::fmt::Display for UnitVec2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "[{}, {}]", self.0.x, self.0.y) + } +} + +#[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] +#[serde(from = "Point3Wire", into = "Point3Wire")] +pub struct Point3(NPoint3); + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename = "Point3", deny_unknown_fields)] +struct Point3Wire { + x: f64, + y: f64, + z: f64, +} + +impl From for Point3Wire { + fn from(p: Point3) -> Self { + Self { + x: p.0.x, + y: p.0.y, + z: p.0.z, + } + } +} + +impl From for Point3 { + fn from(w: Point3Wire) -> Self { + Self(NPoint3::new(w.x, w.y, w.z)) + } +} + +impl Point3 { + #[must_use] + pub fn origin() -> Self { + Self(NPoint3::origin()) + } + + #[must_use] + pub fn from_mm(x: f64, y: f64, z: f64) -> Self { + Self(NPoint3::new(x, y, z)) + } + + #[must_use] + pub fn from_lengths(x: Length, y: Length, z: Length) -> Self { + Self(NPoint3::new( + x.get::(), + y.get::(), + z.get::(), + )) + } + + #[must_use] + pub fn x(self) -> Length { + Length::new::(self.0.x) + } + + #[must_use] + pub fn y(self) -> Length { + Length::new::(self.0.y) + } + + #[must_use] + pub fn z(self) -> Length { + Length::new::(self.0.z) + } +} + +impl core::fmt::Debug for Point3 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "Point3({} mm, {} mm, {} mm)", + self.0.x, self.0.y, self.0.z, + ) + } +} + +impl core::fmt::Display for Point3 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "({} mm, {} mm, {} mm)", self.0.x, self.0.y, self.0.z) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "UnitVec3Wire", into = "UnitVec3Wire")] +pub struct UnitVec3(Unit>); + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename = "UnitVec3", deny_unknown_fields)] +struct UnitVec3Wire { + x: f64, + y: f64, + z: f64, +} + +impl From for UnitVec3Wire { + fn from(u: UnitVec3) -> Self { + Self { + x: u.0.x, + y: u.0.y, + z: u.0.z, + } + } +} + +impl TryFrom for UnitVec3 { + type Error = TypesError; + fn try_from(w: UnitVec3Wire) -> Result { + Self::try_from_components(w.x, w.y, w.z) + } +} + +impl UnitVec3 { + #[must_use] + pub fn x_axis() -> Self { + Self(Unit::new_unchecked(NVec3::new(1.0, 0.0, 0.0))) + } + + #[must_use] + pub fn y_axis() -> Self { + Self(Unit::new_unchecked(NVec3::new(0.0, 1.0, 0.0))) + } + + #[must_use] + pub fn z_axis() -> Self { + Self(Unit::new_unchecked(NVec3::new(0.0, 0.0, 1.0))) + } + + pub fn try_from_components(x: f64, y: f64, z: f64) -> Result { + Unit::try_new(NVec3::new(x, y, z), f64::EPSILON) + .map(Self) + .ok_or(TypesError::ZeroLengthAxis) + } + + #[must_use] + pub fn components(self) -> (f64, f64, f64) { + (self.0.x, self.0.y, self.0.z) + } + + #[must_use] + pub fn dot(self, other: Self) -> f64 { + self.0.dot(&other.0) + } +} + +impl core::fmt::Display for UnitVec3 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "[{}, {}, {}]", self.0.x, self.0.y, self.0.z) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "SketchPlaneBasisWire", into = "SketchPlaneBasisWire")] +pub struct SketchPlaneBasis { + origin: Point3, + x: UnitVec3, + y: UnitVec3, +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename = "SketchPlaneBasis", deny_unknown_fields)] +struct SketchPlaneBasisWire { + origin: Point3, + x_axis: UnitVec3, + y_axis: UnitVec3, +} + +impl From for SketchPlaneBasisWire { + fn from(b: SketchPlaneBasis) -> Self { + Self { + origin: b.origin, + x_axis: b.x, + y_axis: b.y, + } + } +} + +impl TryFrom for SketchPlaneBasis { + type Error = TypesError; + fn try_from(w: SketchPlaneBasisWire) -> Result { + Self::new(w.origin, w.x_axis, w.y_axis, Tolerance::new(1e-9)) + } +} + +impl SketchPlaneBasis { + pub fn new(origin: Point3, x: UnitVec3, y: UnitVec3, tolerance: Tolerance) -> Result { + let dot = x.dot(y); + if dot.abs() > tolerance.value() { + return Err(TypesError::NonOrthogonalPlaneAxes(dot.abs())); + } + let y_projected = y.0.into_inner() - x.0.into_inner() * dot; + let y_orthonormal = + UnitVec3(Unit::try_new(y_projected, f64::EPSILON).ok_or(TypesError::ZeroLengthAxis)?); + Ok(Self { + origin, + x, + y: y_orthonormal, + }) + } + + #[must_use] + pub fn origin(self) -> Point3 { + self.origin + } + + #[must_use] + pub fn x_axis(self) -> UnitVec3 { + self.x + } + + #[must_use] + pub fn y_axis(self) -> UnitVec3 { + self.y + } + + #[must_use] + pub fn normal(self) -> UnitVec3 { + UnitVec3(Unit::new_normalize(self.x.0.cross(&self.y.0))) + } +} + +impl core::fmt::Display for SketchPlaneBasis { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "basis{{ o={}, x={}, y={} }}", + self.origin, self.x, self.y, + ) + } +} diff --git a/crates/bone-types/src/testing.rs b/crates/bone-types/src/testing.rs new file mode 100644 index 0000000..fe2c50a --- /dev/null +++ b/crates/bone-types/src/testing.rs @@ -0,0 +1,55 @@ +use std::sync::Once; + +use tracing_subscriber::EnvFilter; + +static INIT: Once = Once::new(); + +pub fn init_test_tracing() { + INIT.call_once(|| { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("bone_types=debug")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_test_writer() + .try_init(); + }); +} + +#[derive(Debug, thiserror::Error)] +#[error("non-deterministic output across two invocations of the same closure")] +pub struct NonDeterministic; + +pub fn check_bit_identical T>(f: F) -> Result<(), NonDeterministic> { + let first = f(); + let second = f(); + if first == second { + Ok(()) + } else { + Err(NonDeterministic) + } +} + +#[cfg(test)] +mod tests { + use super::{check_bit_identical, init_test_tracing}; + + #[test] + fn deterministic_closure_is_ok() { + assert!(check_bit_identical(|| 42_u64).is_ok()); + assert!(check_bit_identical(|| (1_u32, 2_u32, 3_u32)).is_ok()); + } + + #[test] + fn non_deterministic_closure_is_err() { + let counter = std::sync::atomic::AtomicU64::new(0); + let result = + check_bit_identical(|| counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + assert!(result.is_err()); + } + + #[test] + fn init_test_tracing_idempotent() { + init_test_tracing(); + init_test_tracing(); + } +} diff --git a/crates/bone-types/tests/snapshots/surface__dimensioned_surface.snap b/crates/bone-types/tests/snapshots/surface__dimensioned_surface.snap new file mode 100644 index 0000000..4124f7c --- /dev/null +++ b/crates/bone-types/tests/snapshots/surface__dimensioned_surface.snap @@ -0,0 +1,16 @@ +--- +source: crates/bone-types/tests/surface.rs +expression: surface +--- +length_mm_in=12.5 +length_mm_out=12.5 +length_m_out=0.0125 +angle_deg_in=180 +angle_deg_out=180 +angle_rad_out=3.1415926535897931 +tolerance_value=0.000001 +tolerance_display=tol=0.000001 +angle_tol_direct_rad=0.0001 +angle_tol_direct_display=tol_angle=0.0001 rad +angle_tol_scaled_rad=0.0000001 +angle_tol_zero_rad=0 diff --git a/crates/bone-types/tests/snapshots/surface__id_debug_surface.snap b/crates/bone-types/tests/snapshots/surface__id_debug_surface.snap new file mode 100644 index 0000000..fd6cdb4 --- /dev/null +++ b/crates/bone-types/tests/snapshots/surface__id_debug_surface.snap @@ -0,0 +1,23 @@ +--- +source: crates/bone-types/tests/surface.rs +expression: surface +--- +FaceId::null = FaceId(null) +EdgeId::null = EdgeId(null) +VertexId::null = VertexId(null) +ShellId::null = ShellId(null) +SolidId::null = SolidId(null) +LoopId::null = LoopId(null) +WireId::null = WireId(null) +FeatureId::null = FeatureId(null) +NodeId::null = NodeId(null) +DocumentId::null = DocumentId(null) +SketchId::null = SketchId(null) +SketchEntityId::null = SketchEntityId(null) +SketchRelationId::null = SketchRelationId(null) +SketchDimensionId::null = SketchDimensionId(null) +SketchParameterId::null = SketchParameterId(null) +FaceId::default = FaceId(null) +FaceId::null.is_null = true +EdgeId::null.is_null = true +SketchEntityId::null.is_null = true diff --git a/crates/bone-types/tests/snapshots/surface__solver_surface.snap b/crates/bone-types/tests/snapshots/surface__solver_surface.snap new file mode 100644 index 0000000..3a932a2 --- /dev/null +++ b/crates/bone-types/tests/snapshots/surface__solver_surface.snap @@ -0,0 +1,26 @@ +--- +source: crates/bone-types/tests/surface.rs +expression: surface +--- +parameter_debug = Parameter(0.25) +parameter_display = param=0.25 +parameter_value = 0.25 +dof_debug = DegreesOfFreedom(3) +dof_display = dof=3 +dof_value = 3 +residual_debug = SolverResidual(1.5e-6) +residual_display = res=0.0000015 +residual_value = 0.0000015 +damping_display = damping=0.000000000001 +damping_value = 0.000000000001 +step_tol_display = step_tol=0.00000000000001 +step_tol_value = 0.00000000000001 +parent_debug = ParentIndex(4) +parent_display = parent#4 +parent_value = 4 +budget_debug = BudgetCeiling(16ms) +budget_display = budget=16ms +budget_duration = 16ms +item_relation = relation(SketchRelationId(null)) +item_dimension = dimension(SketchDimensionId(null)) +item_entity = entity(SketchEntityId(null)) diff --git a/crates/bone-types/tests/snapshots/surface__space_surface.snap b/crates/bone-types/tests/snapshots/surface__space_surface.snap new file mode 100644 index 0000000..3fec6b8 --- /dev/null +++ b/crates/bone-types/tests/snapshots/surface__space_surface.snap @@ -0,0 +1,31 @@ +--- +source: crates/bone-types/tests/surface.rs +expression: surface +--- +point2_debug = Point2(1.5 mm, -2.5 mm) +point2_display = (1.5 mm, -2.5 mm) +point2_x_mm = 1.5 +point2_y_mm = -2.5 +vec2_debug = Vec2(3 mm, 4 mm) +vec2_display = <3 mm, 4 mm> +vec2_x_mm = 3 +vec2_y_mm = 4 +point2_sub_mm = (9.000000000000002, 5) +vec2_perp_ccw = <-4 mm, 3 mm> +vec2_norm_mm = 5 +unit2_x_debug = UnitVec2([[1.0, 0.0]]) +unit2_x_display= [1, 0] +unit2_y_display= [0, 1] +unit2_perp_y = (-0, 1) +point3_debug = Point3(1 mm, 2 mm, 3 mm) +point3_display = (1 mm, 2 mm, 3 mm) +point3_x_mm = 1 +point3_y_mm = 2 +point3_z_mm = 3 +unit_x_debug = UnitVec3([[1.0, 0.0, 0.0]]) +unit_x_display = [1, 0, 0] +unit_y_display = [0, 1, 0] +unit_z_display = [0, 0, 1] +basis_debug = SketchPlaneBasis { origin: Point3(1 mm, 2 mm, 3 mm), x: UnitVec3([[1.0, 0.0, 0.0]]), y: UnitVec3([[0.0, 1.0, 0.0]]) } +basis_display = basis{ o=(1 mm, 2 mm, 3 mm), x=[1, 0, 0], y=[0, 1, 0] } +basis_normal = [0, 0, 1] diff --git a/crates/bone-types/tests/surface.rs b/crates/bone-types/tests/surface.rs new file mode 100644 index 0000000..a5286e2 --- /dev/null +++ b/crates/bone-types/tests/surface.rs @@ -0,0 +1,197 @@ +use bone_types::{ + Angle, AngleTolerance, BudgetCeiling, DegreesOfFreedom, DocumentId, EdgeId, FaceId, FeatureId, + Length, LoopId, NewtonDamping, NewtonStepTolerance, NodeId, Parameter, ParentIndex, Point2, + Point3, ShellId, SketchDimensionId, SketchEntityId, SketchId, SketchItemId, SketchParameterId, + SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, Tolerance, UnitVec2, UnitVec3, + Vec2, VertexId, WireId, degree, millimeter, radian, +}; +use slotmap::Key; + +#[test] +fn id_debug_surface() { + let surface = format!( + "FaceId::null = {:?}\n\ + EdgeId::null = {:?}\n\ + VertexId::null = {:?}\n\ + ShellId::null = {:?}\n\ + SolidId::null = {:?}\n\ + LoopId::null = {:?}\n\ + WireId::null = {:?}\n\ + FeatureId::null = {:?}\n\ + NodeId::null = {:?}\n\ + DocumentId::null = {:?}\n\ + SketchId::null = {:?}\n\ + SketchEntityId::null = {:?}\n\ + SketchRelationId::null = {:?}\n\ + SketchDimensionId::null = {:?}\n\ + SketchParameterId::null = {:?}\n\ + FaceId::default = {:?}\n\ + FaceId::null.is_null = {}\n\ + EdgeId::null.is_null = {}\n\ + SketchEntityId::null.is_null = {}", + FaceId::null(), + EdgeId::null(), + VertexId::null(), + ShellId::null(), + SolidId::null(), + LoopId::null(), + WireId::null(), + FeatureId::null(), + NodeId::null(), + DocumentId::null(), + SketchId::null(), + SketchEntityId::null(), + SketchRelationId::null(), + SketchDimensionId::null(), + SketchParameterId::null(), + FaceId::default(), + FaceId::null().is_null(), + EdgeId::null().is_null(), + SketchEntityId::null().is_null(), + ); + insta::assert_snapshot!(surface); +} + +#[test] +fn dimensioned_surface() { + use uom::si::length::meter; + let l = Length::new::(12.5); + let a = Angle::new::(180.0); + let t = Tolerance::new(1e-6); + let at_direct = AngleTolerance::from_radians(1e-4); + let at_scaled = AngleTolerance::from_arc_length(t, Length::new::(10.0)); + let at_zero = AngleTolerance::ZERO; + let surface = format!( + "length_mm_in={mm_in}\n\ + length_mm_out={mm_out}\n\ + length_m_out={m_out}\n\ + angle_deg_in=180\n\ + angle_deg_out={deg_out}\n\ + angle_rad_out={rad_out:.16}\n\ + tolerance_value={tol_val}\n\ + tolerance_display={tol_disp}\n\ + angle_tol_direct_rad={atd_rad}\n\ + angle_tol_direct_display={atd_disp}\n\ + angle_tol_scaled_rad={ats_rad}\n\ + angle_tol_zero_rad={atz_rad}", + mm_in = 12.5, + mm_out = l.get::(), + m_out = l.get::(), + deg_out = a.get::(), + rad_out = a.get::(), + tol_val = t.value(), + tol_disp = t, + atd_rad = at_direct.radians(), + atd_disp = at_direct, + ats_rad = at_scaled.radians(), + atz_rad = at_zero.radians(), + ); + insta::assert_snapshot!(surface); +} + +#[test] +fn space_surface() { + let p2 = Point2::from_mm(1.5, -2.5); + let v2 = Vec2::from_mm(3.0, 4.0); + let p3 = Point3::from_mm(1.0, 2.0, 3.0); + let u2x = UnitVec2::x_axis(); + let u2y = UnitVec2::y_axis(); + let ux = UnitVec3::x_axis(); + let uy = UnitVec3::y_axis(); + let uz = UnitVec3::z_axis(); + let Ok(basis) = SketchPlaneBasis::new(p3, ux, uy, Tolerance::new(1e-9)) else { + panic!("x and y unit axes are orthogonal"); + }; + let diff = Point2::from_mm(10.0, 7.0) - Point2::from_mm(1.0, 2.0); + + let surface = format!( + "point2_debug = {p2:?}\n\ + point2_display = {p2}\n\ + point2_x_mm = {p2x}\n\ + point2_y_mm = {p2y}\n\ + vec2_debug = {v2:?}\n\ + vec2_display = {v2}\n\ + vec2_x_mm = {v2x}\n\ + vec2_y_mm = {v2y}\n\ + point2_sub_mm = ({dx}, {dy})\n\ + vec2_perp_ccw = {vperp}\n\ + vec2_norm_mm = {vnorm}\n\ + unit2_x_debug = {u2x:?}\n\ + unit2_x_display= {u2x}\n\ + unit2_y_display= {u2y}\n\ + unit2_perp_y = ({u2px}, {u2py})\n\ + point3_debug = {p3:?}\n\ + point3_display = {p3}\n\ + point3_x_mm = {p3x}\n\ + point3_y_mm = {p3y}\n\ + point3_z_mm = {p3z}\n\ + unit_x_debug = {ux:?}\n\ + unit_x_display = {ux}\n\ + unit_y_display = {uy}\n\ + unit_z_display = {uz}\n\ + basis_debug = {basis:?}\n\ + basis_display = {basis}\n\ + basis_normal = {normal}", + p2x = p2.x().get::(), + p2y = p2.y().get::(), + v2x = v2.x().get::(), + v2y = v2.y().get::(), + dx = diff.x().get::(), + dy = diff.y().get::(), + vperp = v2.perp_ccw(), + vnorm = v2.norm_mm(), + u2px = u2x.perp_ccw().components().0, + u2py = u2x.perp_ccw().components().1, + p3x = p3.x().get::(), + p3y = p3.y().get::(), + p3z = p3.z().get::(), + normal = basis.normal(), + ); + insta::assert_snapshot!(surface); +} + +#[test] +fn solver_surface() { + let p = Parameter::new(0.25); + let d = DegreesOfFreedom::new(3); + let r = SolverResidual::new(1.5e-6); + let damp = NewtonDamping::DEFAULT; + let step_tol = NewtonStepTolerance::DEFAULT; + let parent = ParentIndex::new(4); + let budget = BudgetCeiling::FRAME_16MS; + let item_rel = SketchItemId::Relation(SketchRelationId::default()); + let item_dim = SketchItemId::Dimension(SketchDimensionId::default()); + let item_ent = SketchItemId::Entity(SketchEntityId::default()); + let surface = format!( + "parameter_debug = {p:?}\n\ + parameter_display = {p}\n\ + parameter_value = {pv}\n\ + dof_debug = {d:?}\n\ + dof_display = {d}\n\ + dof_value = {dv}\n\ + residual_debug = {r:?}\n\ + residual_display = {r}\n\ + residual_value = {rv}\n\ + damping_display = {damp}\n\ + damping_value = {damp_v}\n\ + step_tol_display = {step_tol}\n\ + step_tol_value = {step_tol_v}\n\ + parent_debug = {parent:?}\n\ + parent_display = {parent}\n\ + parent_value = {parent_v}\n\ + budget_debug = {budget:?}\n\ + budget_display = {budget}\n\ + budget_duration = {budget_d:?}\n\ + item_relation = {item_rel}\n\ + item_dimension = {item_dim}\n\ + item_entity = {item_ent}", + pv = p.value(), + dv = d.value(), + rv = r.value(), + damp_v = damp.value(), + step_tol_v = step_tol.value(), + parent_v = parent.value(), + budget_d = budget.duration(), + ); + insta::assert_snapshot!(surface); +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..041e8b7 --- /dev/null +++ b/justfile @@ -0,0 +1,37 @@ +default: + @just --list + +check: + cargo check --workspace --all-targets --all-features + +build: + cargo build --workspace + +build-release: + cargo build --workspace --release + +run: + cargo run -p bone-app + +test: + cargo test --workspace --all-features + +clippy: + cargo clippy --workspace --all-targets --all-features -- -D warnings + +snap: + cargo insta test --workspace --all-features + +snap-review: + cargo insta review + +snap-accept: + cargo insta accept + +fmt: + cargo fmt --all + +fmt-check: + cargo fmt --all -- --check + +lint: fmt-check clippy diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..6360c18 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.95.0" +components = ["rustfmt", "clippy"]