Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

Latest

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Apr 24, 2026, 4:01 PM +0300) commit 3a33f2e6 parent 5c3aa774 change-id smvonspz
+22717
+4
.gitignore
··· 1 + /target 2 + /result 3 + *.swp 4 + .direnv/
+3489
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "ab_glyph" 7 + version = "0.2.32" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" 10 + dependencies = [ 11 + "ab_glyph_rasterizer", 12 + "owned_ttf_parser", 13 + ] 14 + 15 + [[package]] 16 + name = "ab_glyph_rasterizer" 17 + version = "0.1.10" 18 + source = "registry+https://github.com/rust-lang/crates.io-index" 19 + checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" 20 + 21 + [[package]] 22 + name = "adler2" 23 + version = "2.0.1" 24 + source = "registry+https://github.com/rust-lang/crates.io-index" 25 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 26 + 27 + [[package]] 28 + name = "ahash" 29 + version = "0.8.12" 30 + source = "registry+https://github.com/rust-lang/crates.io-index" 31 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 32 + dependencies = [ 33 + "cfg-if", 34 + "getrandom", 35 + "once_cell", 36 + "version_check", 37 + "zerocopy", 38 + ] 39 + 40 + [[package]] 41 + name = "aho-corasick" 42 + version = "1.1.4" 43 + source = "registry+https://github.com/rust-lang/crates.io-index" 44 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 45 + dependencies = [ 46 + "memchr", 47 + ] 48 + 49 + [[package]] 50 + name = "allocator-api2" 51 + version = "0.2.21" 52 + source = "registry+https://github.com/rust-lang/crates.io-index" 53 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 54 + 55 + [[package]] 56 + name = "android-activity" 57 + version = "0.6.1" 58 + source = "registry+https://github.com/rust-lang/crates.io-index" 59 + checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" 60 + dependencies = [ 61 + "android-properties", 62 + "bitflags 2.11.1", 63 + "cc", 64 + "jni", 65 + "libc", 66 + "log", 67 + "ndk", 68 + "ndk-context", 69 + "ndk-sys", 70 + "num_enum", 71 + "thiserror 2.0.18", 72 + ] 73 + 74 + [[package]] 75 + name = "android-properties" 76 + version = "0.2.2" 77 + source = "registry+https://github.com/rust-lang/crates.io-index" 78 + checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" 79 + 80 + [[package]] 81 + name = "android_system_properties" 82 + version = "0.1.5" 83 + source = "registry+https://github.com/rust-lang/crates.io-index" 84 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 85 + dependencies = [ 86 + "libc", 87 + ] 88 + 89 + [[package]] 90 + name = "approx" 91 + version = "0.5.1" 92 + source = "registry+https://github.com/rust-lang/crates.io-index" 93 + checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" 94 + dependencies = [ 95 + "num-traits", 96 + ] 97 + 98 + [[package]] 99 + name = "arrayref" 100 + version = "0.3.9" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" 103 + 104 + [[package]] 105 + name = "arrayvec" 106 + version = "0.7.6" 107 + source = "registry+https://github.com/rust-lang/crates.io-index" 108 + checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 109 + 110 + [[package]] 111 + name = "as-raw-xcb-connection" 112 + version = "1.0.1" 113 + source = "registry+https://github.com/rust-lang/crates.io-index" 114 + checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" 115 + 116 + [[package]] 117 + name = "ash" 118 + version = "0.38.0+1.3.281" 119 + source = "registry+https://github.com/rust-lang/crates.io-index" 120 + checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" 121 + dependencies = [ 122 + "libloading", 123 + ] 124 + 125 + [[package]] 126 + name = "atomic-waker" 127 + version = "1.1.2" 128 + source = "registry+https://github.com/rust-lang/crates.io-index" 129 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 130 + 131 + [[package]] 132 + name = "autocfg" 133 + version = "1.5.0" 134 + source = "registry+https://github.com/rust-lang/crates.io-index" 135 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 136 + 137 + [[package]] 138 + name = "bit-set" 139 + version = "0.9.1" 140 + source = "registry+https://github.com/rust-lang/crates.io-index" 141 + checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" 142 + dependencies = [ 143 + "bit-vec", 144 + ] 145 + 146 + [[package]] 147 + name = "bit-vec" 148 + version = "0.9.1" 149 + source = "registry+https://github.com/rust-lang/crates.io-index" 150 + checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" 151 + 152 + [[package]] 153 + name = "bitflags" 154 + version = "1.3.2" 155 + source = "registry+https://github.com/rust-lang/crates.io-index" 156 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 157 + 158 + [[package]] 159 + name = "bitflags" 160 + version = "2.11.1" 161 + source = "registry+https://github.com/rust-lang/crates.io-index" 162 + checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 163 + dependencies = [ 164 + "serde_core", 165 + ] 166 + 167 + [[package]] 168 + name = "blake3" 169 + version = "1.8.4" 170 + source = "registry+https://github.com/rust-lang/crates.io-index" 171 + checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" 172 + dependencies = [ 173 + "arrayref", 174 + "arrayvec", 175 + "cc", 176 + "cfg-if", 177 + "constant_time_eq", 178 + "cpufeatures", 179 + ] 180 + 181 + [[package]] 182 + name = "block2" 183 + version = "0.5.1" 184 + source = "registry+https://github.com/rust-lang/crates.io-index" 185 + checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" 186 + dependencies = [ 187 + "objc2 0.5.2", 188 + ] 189 + 190 + [[package]] 191 + name = "block2" 192 + version = "0.6.2" 193 + source = "registry+https://github.com/rust-lang/crates.io-index" 194 + checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" 195 + dependencies = [ 196 + "objc2 0.6.4", 197 + ] 198 + 199 + [[package]] 200 + name = "bone-app" 201 + version = "0.0.0" 202 + dependencies = [ 203 + "bone-document", 204 + "bone-render", 205 + "bone-types", 206 + "pollster", 207 + "thiserror 2.0.18", 208 + "tracing", 209 + "tracing-subscriber", 210 + "uom", 211 + "winit", 212 + ] 213 + 214 + [[package]] 215 + name = "bone-document" 216 + version = "0.0.0" 217 + dependencies = [ 218 + "blake3", 219 + "bone-solver", 220 + "bone-types", 221 + "insta", 222 + "nalgebra", 223 + "proptest", 224 + "ron", 225 + "serde", 226 + "slotmap", 227 + "tempfile", 228 + "thiserror 2.0.18", 229 + "tracing", 230 + ] 231 + 232 + [[package]] 233 + name = "bone-kernel" 234 + version = "0.0.0" 235 + dependencies = [ 236 + "bone-types", 237 + "insta", 238 + "thiserror 2.0.18", 239 + "uom", 240 + ] 241 + 242 + [[package]] 243 + name = "bone-render" 244 + version = "0.0.0" 245 + dependencies = [ 246 + "bone-document", 247 + "bone-kernel", 248 + "bone-types", 249 + "bytemuck", 250 + "png", 251 + "pollster", 252 + "proptest", 253 + "slotmap", 254 + "thiserror 2.0.18", 255 + "tracing", 256 + "uom", 257 + "wgpu", 258 + ] 259 + 260 + [[package]] 261 + name = "bone-solver" 262 + version = "0.0.0" 263 + dependencies = [ 264 + "bone-kernel", 265 + "bone-types", 266 + "faer", 267 + "insta", 268 + "nalgebra", 269 + "thiserror 2.0.18", 270 + ] 271 + 272 + [[package]] 273 + name = "bone-types" 274 + version = "0.0.0" 275 + dependencies = [ 276 + "insta", 277 + "nalgebra", 278 + "serde", 279 + "slotmap", 280 + "thiserror 2.0.18", 281 + "tracing", 282 + "tracing-subscriber", 283 + "uom", 284 + ] 285 + 286 + [[package]] 287 + name = "bumpalo" 288 + version = "3.20.2" 289 + source = "registry+https://github.com/rust-lang/crates.io-index" 290 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 291 + 292 + [[package]] 293 + name = "bytemuck" 294 + version = "1.25.0" 295 + source = "registry+https://github.com/rust-lang/crates.io-index" 296 + checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" 297 + dependencies = [ 298 + "bytemuck_derive", 299 + ] 300 + 301 + [[package]] 302 + name = "bytemuck_derive" 303 + version = "1.10.2" 304 + source = "registry+https://github.com/rust-lang/crates.io-index" 305 + checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" 306 + dependencies = [ 307 + "proc-macro2", 308 + "quote", 309 + "syn 2.0.117", 310 + ] 311 + 312 + [[package]] 313 + name = "byteorder" 314 + version = "1.5.0" 315 + source = "registry+https://github.com/rust-lang/crates.io-index" 316 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 317 + 318 + [[package]] 319 + name = "bytes" 320 + version = "1.11.1" 321 + source = "registry+https://github.com/rust-lang/crates.io-index" 322 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 323 + 324 + [[package]] 325 + name = "calloop" 326 + version = "0.13.0" 327 + source = "registry+https://github.com/rust-lang/crates.io-index" 328 + checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" 329 + dependencies = [ 330 + "bitflags 2.11.1", 331 + "log", 332 + "polling", 333 + "rustix 0.38.44", 334 + "slab", 335 + "thiserror 1.0.69", 336 + ] 337 + 338 + [[package]] 339 + name = "calloop-wayland-source" 340 + version = "0.3.0" 341 + source = "registry+https://github.com/rust-lang/crates.io-index" 342 + checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" 343 + dependencies = [ 344 + "calloop", 345 + "rustix 0.38.44", 346 + "wayland-backend", 347 + "wayland-client", 348 + ] 349 + 350 + [[package]] 351 + name = "cc" 352 + version = "1.2.60" 353 + source = "registry+https://github.com/rust-lang/crates.io-index" 354 + checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" 355 + dependencies = [ 356 + "find-msvc-tools", 357 + "jobserver", 358 + "libc", 359 + "shlex", 360 + ] 361 + 362 + [[package]] 363 + name = "cfg-if" 364 + version = "1.0.4" 365 + source = "registry+https://github.com/rust-lang/crates.io-index" 366 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 367 + 368 + [[package]] 369 + name = "cfg_aliases" 370 + version = "0.2.1" 371 + source = "registry+https://github.com/rust-lang/crates.io-index" 372 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 373 + 374 + [[package]] 375 + name = "codespan-reporting" 376 + version = "0.13.1" 377 + source = "registry+https://github.com/rust-lang/crates.io-index" 378 + checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" 379 + dependencies = [ 380 + "serde", 381 + "termcolor", 382 + "unicode-width", 383 + ] 384 + 385 + [[package]] 386 + name = "combine" 387 + version = "4.6.7" 388 + source = "registry+https://github.com/rust-lang/crates.io-index" 389 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 390 + dependencies = [ 391 + "bytes", 392 + "memchr", 393 + ] 394 + 395 + [[package]] 396 + name = "concurrent-queue" 397 + version = "2.5.0" 398 + source = "registry+https://github.com/rust-lang/crates.io-index" 399 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 400 + dependencies = [ 401 + "crossbeam-utils", 402 + ] 403 + 404 + [[package]] 405 + name = "console" 406 + version = "0.16.3" 407 + source = "registry+https://github.com/rust-lang/crates.io-index" 408 + checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" 409 + dependencies = [ 410 + "encode_unicode", 411 + "libc", 412 + "windows-sys 0.61.2", 413 + ] 414 + 415 + [[package]] 416 + name = "constant_time_eq" 417 + version = "0.4.2" 418 + source = "registry+https://github.com/rust-lang/crates.io-index" 419 + checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" 420 + 421 + [[package]] 422 + name = "core-foundation" 423 + version = "0.9.4" 424 + source = "registry+https://github.com/rust-lang/crates.io-index" 425 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 426 + dependencies = [ 427 + "core-foundation-sys", 428 + "libc", 429 + ] 430 + 431 + [[package]] 432 + name = "core-foundation-sys" 433 + version = "0.8.7" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 436 + 437 + [[package]] 438 + name = "core-graphics" 439 + version = "0.23.2" 440 + source = "registry+https://github.com/rust-lang/crates.io-index" 441 + checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" 442 + dependencies = [ 443 + "bitflags 1.3.2", 444 + "core-foundation", 445 + "core-graphics-types", 446 + "foreign-types", 447 + "libc", 448 + ] 449 + 450 + [[package]] 451 + name = "core-graphics-types" 452 + version = "0.1.3" 453 + source = "registry+https://github.com/rust-lang/crates.io-index" 454 + checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" 455 + dependencies = [ 456 + "bitflags 1.3.2", 457 + "core-foundation", 458 + "libc", 459 + ] 460 + 461 + [[package]] 462 + name = "cpufeatures" 463 + version = "0.3.0" 464 + source = "registry+https://github.com/rust-lang/crates.io-index" 465 + checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" 466 + dependencies = [ 467 + "libc", 468 + ] 469 + 470 + [[package]] 471 + name = "crc32fast" 472 + version = "1.5.0" 473 + source = "registry+https://github.com/rust-lang/crates.io-index" 474 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 475 + dependencies = [ 476 + "cfg-if", 477 + ] 478 + 479 + [[package]] 480 + name = "crossbeam-utils" 481 + version = "0.8.21" 482 + source = "registry+https://github.com/rust-lang/crates.io-index" 483 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 484 + 485 + [[package]] 486 + name = "crunchy" 487 + version = "0.2.4" 488 + source = "registry+https://github.com/rust-lang/crates.io-index" 489 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" 490 + 491 + [[package]] 492 + name = "cursor-icon" 493 + version = "1.2.0" 494 + source = "registry+https://github.com/rust-lang/crates.io-index" 495 + checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" 496 + 497 + [[package]] 498 + name = "defer" 499 + version = "0.2.1" 500 + source = "registry+https://github.com/rust-lang/crates.io-index" 501 + checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8" 502 + 503 + [[package]] 504 + name = "dispatch" 505 + version = "0.2.0" 506 + source = "registry+https://github.com/rust-lang/crates.io-index" 507 + checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" 508 + 509 + [[package]] 510 + name = "dispatch2" 511 + version = "0.3.1" 512 + source = "registry+https://github.com/rust-lang/crates.io-index" 513 + checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" 514 + dependencies = [ 515 + "bitflags 2.11.1", 516 + "objc2 0.6.4", 517 + ] 518 + 519 + [[package]] 520 + name = "dlib" 521 + version = "0.5.3" 522 + source = "registry+https://github.com/rust-lang/crates.io-index" 523 + checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" 524 + dependencies = [ 525 + "libloading", 526 + ] 527 + 528 + [[package]] 529 + name = "document-features" 530 + version = "0.2.12" 531 + source = "registry+https://github.com/rust-lang/crates.io-index" 532 + checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" 533 + dependencies = [ 534 + "litrs", 535 + ] 536 + 537 + [[package]] 538 + name = "downcast-rs" 539 + version = "1.2.1" 540 + source = "registry+https://github.com/rust-lang/crates.io-index" 541 + checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" 542 + 543 + [[package]] 544 + name = "dpi" 545 + version = "0.1.2" 546 + source = "registry+https://github.com/rust-lang/crates.io-index" 547 + checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" 548 + 549 + [[package]] 550 + name = "dyn-stack" 551 + version = "0.13.2" 552 + source = "registry+https://github.com/rust-lang/crates.io-index" 553 + checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" 554 + dependencies = [ 555 + "bytemuck", 556 + "dyn-stack-macros", 557 + ] 558 + 559 + [[package]] 560 + name = "dyn-stack-macros" 561 + version = "0.1.3" 562 + source = "registry+https://github.com/rust-lang/crates.io-index" 563 + checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" 564 + 565 + [[package]] 566 + name = "encode_unicode" 567 + version = "1.0.0" 568 + source = "registry+https://github.com/rust-lang/crates.io-index" 569 + checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 570 + 571 + [[package]] 572 + name = "enum-as-inner" 573 + version = "0.6.1" 574 + source = "registry+https://github.com/rust-lang/crates.io-index" 575 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 576 + dependencies = [ 577 + "heck", 578 + "proc-macro2", 579 + "quote", 580 + "syn 2.0.117", 581 + ] 582 + 583 + [[package]] 584 + name = "equator" 585 + version = "0.2.2" 586 + source = "registry+https://github.com/rust-lang/crates.io-index" 587 + checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea" 588 + dependencies = [ 589 + "equator-macro 0.2.1", 590 + ] 591 + 592 + [[package]] 593 + name = "equator" 594 + version = "0.6.0" 595 + source = "registry+https://github.com/rust-lang/crates.io-index" 596 + checksum = "02da895aab06bbebefb6b2595f6d637b18c9ff629b4cd840965bb3164e4194b0" 597 + dependencies = [ 598 + "equator-macro 0.6.0", 599 + ] 600 + 601 + [[package]] 602 + name = "equator-macro" 603 + version = "0.2.1" 604 + source = "registry+https://github.com/rust-lang/crates.io-index" 605 + checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" 606 + dependencies = [ 607 + "proc-macro2", 608 + "quote", 609 + "syn 2.0.117", 610 + ] 611 + 612 + [[package]] 613 + name = "equator-macro" 614 + version = "0.6.0" 615 + source = "registry+https://github.com/rust-lang/crates.io-index" 616 + checksum = "2b14b339eb76d07f052cdbad76ca7c1310e56173a138095d3bf42a23c06ef5d8" 617 + 618 + [[package]] 619 + name = "equivalent" 620 + version = "1.0.2" 621 + source = "registry+https://github.com/rust-lang/crates.io-index" 622 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 623 + 624 + [[package]] 625 + name = "errno" 626 + version = "0.3.14" 627 + source = "registry+https://github.com/rust-lang/crates.io-index" 628 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 629 + dependencies = [ 630 + "libc", 631 + "windows-sys 0.61.2", 632 + ] 633 + 634 + [[package]] 635 + name = "faer" 636 + version = "0.24.0" 637 + source = "registry+https://github.com/rust-lang/crates.io-index" 638 + checksum = "02d2ecfb80b6f8b0c569e36988a052e64b14d8def9d372390b014e8bf79f299a" 639 + dependencies = [ 640 + "bytemuck", 641 + "dyn-stack", 642 + "equator 0.6.0", 643 + "faer-traits", 644 + "gemm", 645 + "generativity", 646 + "libm", 647 + "nano-gemm", 648 + "num-complex", 649 + "num-traits", 650 + "private-gemm-x86", 651 + "pulp", 652 + "reborrow", 653 + ] 654 + 655 + [[package]] 656 + name = "faer-traits" 657 + version = "0.24.0" 658 + source = "registry+https://github.com/rust-lang/crates.io-index" 659 + checksum = "b87d23ed7ab1f26c0cba0e5b9e061a796fbb7dc170fa8bee6970055a1308bb0f" 660 + dependencies = [ 661 + "bytemuck", 662 + "dyn-stack", 663 + "generativity", 664 + "libm", 665 + "num-complex", 666 + "num-traits", 667 + "pulp", 668 + "qd", 669 + "reborrow", 670 + ] 671 + 672 + [[package]] 673 + name = "fastrand" 674 + version = "2.4.1" 675 + source = "registry+https://github.com/rust-lang/crates.io-index" 676 + checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" 677 + 678 + [[package]] 679 + name = "fdeflate" 680 + version = "0.3.7" 681 + source = "registry+https://github.com/rust-lang/crates.io-index" 682 + checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" 683 + dependencies = [ 684 + "simd-adler32", 685 + ] 686 + 687 + [[package]] 688 + name = "find-msvc-tools" 689 + version = "0.1.9" 690 + source = "registry+https://github.com/rust-lang/crates.io-index" 691 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 692 + 693 + [[package]] 694 + name = "flate2" 695 + version = "1.1.9" 696 + source = "registry+https://github.com/rust-lang/crates.io-index" 697 + checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 698 + dependencies = [ 699 + "crc32fast", 700 + "miniz_oxide", 701 + ] 702 + 703 + [[package]] 704 + name = "foldhash" 705 + version = "0.1.5" 706 + source = "registry+https://github.com/rust-lang/crates.io-index" 707 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 708 + 709 + [[package]] 710 + name = "foldhash" 711 + version = "0.2.0" 712 + source = "registry+https://github.com/rust-lang/crates.io-index" 713 + checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" 714 + 715 + [[package]] 716 + name = "foreign-types" 717 + version = "0.5.0" 718 + source = "registry+https://github.com/rust-lang/crates.io-index" 719 + checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" 720 + dependencies = [ 721 + "foreign-types-macros", 722 + "foreign-types-shared", 723 + ] 724 + 725 + [[package]] 726 + name = "foreign-types-macros" 727 + version = "0.2.3" 728 + source = "registry+https://github.com/rust-lang/crates.io-index" 729 + checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" 730 + dependencies = [ 731 + "proc-macro2", 732 + "quote", 733 + "syn 2.0.117", 734 + ] 735 + 736 + [[package]] 737 + name = "foreign-types-shared" 738 + version = "0.3.1" 739 + source = "registry+https://github.com/rust-lang/crates.io-index" 740 + checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 741 + 742 + [[package]] 743 + name = "futures-core" 744 + version = "0.3.32" 745 + source = "registry+https://github.com/rust-lang/crates.io-index" 746 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 747 + 748 + [[package]] 749 + name = "futures-task" 750 + version = "0.3.32" 751 + source = "registry+https://github.com/rust-lang/crates.io-index" 752 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 753 + 754 + [[package]] 755 + name = "futures-util" 756 + version = "0.3.32" 757 + source = "registry+https://github.com/rust-lang/crates.io-index" 758 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 759 + dependencies = [ 760 + "futures-core", 761 + "futures-task", 762 + "pin-project-lite", 763 + "slab", 764 + ] 765 + 766 + [[package]] 767 + name = "gemm" 768 + version = "0.19.0" 769 + source = "registry+https://github.com/rust-lang/crates.io-index" 770 + checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" 771 + dependencies = [ 772 + "dyn-stack", 773 + "gemm-c32", 774 + "gemm-c64", 775 + "gemm-common", 776 + "gemm-f16", 777 + "gemm-f32", 778 + "gemm-f64", 779 + "num-complex", 780 + "num-traits", 781 + "paste", 782 + "raw-cpuid", 783 + "seq-macro", 784 + ] 785 + 786 + [[package]] 787 + name = "gemm-c32" 788 + version = "0.19.0" 789 + source = "registry+https://github.com/rust-lang/crates.io-index" 790 + checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" 791 + dependencies = [ 792 + "dyn-stack", 793 + "gemm-common", 794 + "num-complex", 795 + "num-traits", 796 + "paste", 797 + "raw-cpuid", 798 + "seq-macro", 799 + ] 800 + 801 + [[package]] 802 + name = "gemm-c64" 803 + version = "0.19.0" 804 + source = "registry+https://github.com/rust-lang/crates.io-index" 805 + checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" 806 + dependencies = [ 807 + "dyn-stack", 808 + "gemm-common", 809 + "num-complex", 810 + "num-traits", 811 + "paste", 812 + "raw-cpuid", 813 + "seq-macro", 814 + ] 815 + 816 + [[package]] 817 + name = "gemm-common" 818 + version = "0.19.0" 819 + source = "registry+https://github.com/rust-lang/crates.io-index" 820 + checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" 821 + dependencies = [ 822 + "bytemuck", 823 + "dyn-stack", 824 + "half", 825 + "libm", 826 + "num-complex", 827 + "num-traits", 828 + "once_cell", 829 + "paste", 830 + "pulp", 831 + "raw-cpuid", 832 + "seq-macro", 833 + "sysctl", 834 + ] 835 + 836 + [[package]] 837 + name = "gemm-f16" 838 + version = "0.19.0" 839 + source = "registry+https://github.com/rust-lang/crates.io-index" 840 + checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" 841 + dependencies = [ 842 + "dyn-stack", 843 + "gemm-common", 844 + "gemm-f32", 845 + "half", 846 + "num-complex", 847 + "num-traits", 848 + "paste", 849 + "raw-cpuid", 850 + "seq-macro", 851 + ] 852 + 853 + [[package]] 854 + name = "gemm-f32" 855 + version = "0.19.0" 856 + source = "registry+https://github.com/rust-lang/crates.io-index" 857 + checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" 858 + dependencies = [ 859 + "dyn-stack", 860 + "gemm-common", 861 + "num-complex", 862 + "num-traits", 863 + "paste", 864 + "raw-cpuid", 865 + "seq-macro", 866 + ] 867 + 868 + [[package]] 869 + name = "gemm-f64" 870 + version = "0.19.0" 871 + source = "registry+https://github.com/rust-lang/crates.io-index" 872 + checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" 873 + dependencies = [ 874 + "dyn-stack", 875 + "gemm-common", 876 + "num-complex", 877 + "num-traits", 878 + "paste", 879 + "raw-cpuid", 880 + "seq-macro", 881 + ] 882 + 883 + [[package]] 884 + name = "generativity" 885 + version = "1.1.0" 886 + source = "registry+https://github.com/rust-lang/crates.io-index" 887 + checksum = "5881e4c3c2433fe4905bb19cfd2b5d49d4248274862b68c27c33d9ba4e13f9ec" 888 + 889 + [[package]] 890 + name = "gethostname" 891 + version = "1.1.0" 892 + source = "registry+https://github.com/rust-lang/crates.io-index" 893 + checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" 894 + dependencies = [ 895 + "rustix 1.1.4", 896 + "windows-link", 897 + ] 898 + 899 + [[package]] 900 + name = "getrandom" 901 + version = "0.3.4" 902 + source = "registry+https://github.com/rust-lang/crates.io-index" 903 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 904 + dependencies = [ 905 + "cfg-if", 906 + "libc", 907 + "r-efi", 908 + "wasip2", 909 + ] 910 + 911 + [[package]] 912 + name = "gl_generator" 913 + version = "0.14.0" 914 + source = "registry+https://github.com/rust-lang/crates.io-index" 915 + checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" 916 + dependencies = [ 917 + "khronos_api", 918 + "log", 919 + "xml-rs", 920 + ] 921 + 922 + [[package]] 923 + name = "glow" 924 + version = "0.17.0" 925 + source = "registry+https://github.com/rust-lang/crates.io-index" 926 + checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" 927 + dependencies = [ 928 + "js-sys", 929 + "slotmap", 930 + "wasm-bindgen", 931 + "web-sys", 932 + ] 933 + 934 + [[package]] 935 + name = "glutin_wgl_sys" 936 + version = "0.6.1" 937 + source = "registry+https://github.com/rust-lang/crates.io-index" 938 + checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" 939 + dependencies = [ 940 + "gl_generator", 941 + ] 942 + 943 + [[package]] 944 + name = "gpu-allocator" 945 + version = "0.28.0" 946 + source = "registry+https://github.com/rust-lang/crates.io-index" 947 + checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" 948 + dependencies = [ 949 + "ash", 950 + "hashbrown 0.16.1", 951 + "log", 952 + "presser", 953 + "thiserror 2.0.18", 954 + "windows", 955 + ] 956 + 957 + [[package]] 958 + name = "gpu-descriptor" 959 + version = "0.3.2" 960 + source = "registry+https://github.com/rust-lang/crates.io-index" 961 + checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" 962 + dependencies = [ 963 + "bitflags 2.11.1", 964 + "gpu-descriptor-types", 965 + "hashbrown 0.15.5", 966 + ] 967 + 968 + [[package]] 969 + name = "gpu-descriptor-types" 970 + version = "0.2.0" 971 + source = "registry+https://github.com/rust-lang/crates.io-index" 972 + checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" 973 + dependencies = [ 974 + "bitflags 2.11.1", 975 + ] 976 + 977 + [[package]] 978 + name = "half" 979 + version = "2.7.1" 980 + source = "registry+https://github.com/rust-lang/crates.io-index" 981 + checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" 982 + dependencies = [ 983 + "bytemuck", 984 + "cfg-if", 985 + "crunchy", 986 + "num-traits", 987 + "zerocopy", 988 + ] 989 + 990 + [[package]] 991 + name = "hashbrown" 992 + version = "0.15.5" 993 + source = "registry+https://github.com/rust-lang/crates.io-index" 994 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 995 + dependencies = [ 996 + "foldhash 0.1.5", 997 + ] 998 + 999 + [[package]] 1000 + name = "hashbrown" 1001 + version = "0.16.1" 1002 + source = "registry+https://github.com/rust-lang/crates.io-index" 1003 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 1004 + dependencies = [ 1005 + "allocator-api2", 1006 + "equivalent", 1007 + "foldhash 0.2.0", 1008 + ] 1009 + 1010 + [[package]] 1011 + name = "hashbrown" 1012 + version = "0.17.0" 1013 + source = "registry+https://github.com/rust-lang/crates.io-index" 1014 + checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" 1015 + 1016 + [[package]] 1017 + name = "heck" 1018 + version = "0.5.0" 1019 + source = "registry+https://github.com/rust-lang/crates.io-index" 1020 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1021 + 1022 + [[package]] 1023 + name = "hermit-abi" 1024 + version = "0.5.2" 1025 + source = "registry+https://github.com/rust-lang/crates.io-index" 1026 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1027 + 1028 + [[package]] 1029 + name = "hexf-parse" 1030 + version = "0.2.1" 1031 + source = "registry+https://github.com/rust-lang/crates.io-index" 1032 + checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" 1033 + 1034 + [[package]] 1035 + name = "indexmap" 1036 + version = "2.14.0" 1037 + source = "registry+https://github.com/rust-lang/crates.io-index" 1038 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" 1039 + dependencies = [ 1040 + "equivalent", 1041 + "hashbrown 0.17.0", 1042 + ] 1043 + 1044 + [[package]] 1045 + name = "insta" 1046 + version = "1.47.2" 1047 + source = "registry+https://github.com/rust-lang/crates.io-index" 1048 + checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" 1049 + dependencies = [ 1050 + "console", 1051 + "once_cell", 1052 + "similar", 1053 + "tempfile", 1054 + ] 1055 + 1056 + [[package]] 1057 + name = "interpol" 1058 + version = "0.2.1" 1059 + source = "registry+https://github.com/rust-lang/crates.io-index" 1060 + checksum = "eb58032ba748f4010d15912a1855a8a0b1ba9eaad3395b0c171c09b3b356ae50" 1061 + dependencies = [ 1062 + "proc-macro2", 1063 + "quote", 1064 + "syn 1.0.109", 1065 + ] 1066 + 1067 + [[package]] 1068 + name = "jni" 1069 + version = "0.22.4" 1070 + source = "registry+https://github.com/rust-lang/crates.io-index" 1071 + checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" 1072 + dependencies = [ 1073 + "cfg-if", 1074 + "combine", 1075 + "jni-macros", 1076 + "jni-sys 0.4.1", 1077 + "log", 1078 + "simd_cesu8", 1079 + "thiserror 2.0.18", 1080 + "walkdir", 1081 + "windows-link", 1082 + ] 1083 + 1084 + [[package]] 1085 + name = "jni-macros" 1086 + version = "0.22.4" 1087 + source = "registry+https://github.com/rust-lang/crates.io-index" 1088 + checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" 1089 + dependencies = [ 1090 + "proc-macro2", 1091 + "quote", 1092 + "rustc_version", 1093 + "simd_cesu8", 1094 + "syn 2.0.117", 1095 + ] 1096 + 1097 + [[package]] 1098 + name = "jni-sys" 1099 + version = "0.3.1" 1100 + source = "registry+https://github.com/rust-lang/crates.io-index" 1101 + checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" 1102 + dependencies = [ 1103 + "jni-sys 0.4.1", 1104 + ] 1105 + 1106 + [[package]] 1107 + name = "jni-sys" 1108 + version = "0.4.1" 1109 + source = "registry+https://github.com/rust-lang/crates.io-index" 1110 + checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" 1111 + dependencies = [ 1112 + "jni-sys-macros", 1113 + ] 1114 + 1115 + [[package]] 1116 + name = "jni-sys-macros" 1117 + version = "0.4.1" 1118 + source = "registry+https://github.com/rust-lang/crates.io-index" 1119 + checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" 1120 + dependencies = [ 1121 + "quote", 1122 + "syn 2.0.117", 1123 + ] 1124 + 1125 + [[package]] 1126 + name = "jobserver" 1127 + version = "0.1.34" 1128 + source = "registry+https://github.com/rust-lang/crates.io-index" 1129 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 1130 + dependencies = [ 1131 + "getrandom", 1132 + "libc", 1133 + ] 1134 + 1135 + [[package]] 1136 + name = "js-sys" 1137 + version = "0.3.95" 1138 + source = "registry+https://github.com/rust-lang/crates.io-index" 1139 + checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" 1140 + dependencies = [ 1141 + "cfg-if", 1142 + "futures-util", 1143 + "once_cell", 1144 + "wasm-bindgen", 1145 + ] 1146 + 1147 + [[package]] 1148 + name = "khronos-egl" 1149 + version = "6.0.0" 1150 + source = "registry+https://github.com/rust-lang/crates.io-index" 1151 + checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" 1152 + dependencies = [ 1153 + "libc", 1154 + "libloading", 1155 + "pkg-config", 1156 + ] 1157 + 1158 + [[package]] 1159 + name = "khronos_api" 1160 + version = "3.1.0" 1161 + source = "registry+https://github.com/rust-lang/crates.io-index" 1162 + checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" 1163 + 1164 + [[package]] 1165 + name = "lazy_static" 1166 + version = "1.5.0" 1167 + source = "registry+https://github.com/rust-lang/crates.io-index" 1168 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1169 + 1170 + [[package]] 1171 + name = "libc" 1172 + version = "0.2.185" 1173 + source = "registry+https://github.com/rust-lang/crates.io-index" 1174 + checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" 1175 + 1176 + [[package]] 1177 + name = "libloading" 1178 + version = "0.8.9" 1179 + source = "registry+https://github.com/rust-lang/crates.io-index" 1180 + checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" 1181 + dependencies = [ 1182 + "cfg-if", 1183 + "windows-link", 1184 + ] 1185 + 1186 + [[package]] 1187 + name = "libm" 1188 + version = "0.2.16" 1189 + source = "registry+https://github.com/rust-lang/crates.io-index" 1190 + checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 1191 + 1192 + [[package]] 1193 + name = "libredox" 1194 + version = "0.1.16" 1195 + source = "registry+https://github.com/rust-lang/crates.io-index" 1196 + checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" 1197 + dependencies = [ 1198 + "bitflags 2.11.1", 1199 + "libc", 1200 + "plain", 1201 + "redox_syscall 0.7.4", 1202 + ] 1203 + 1204 + [[package]] 1205 + name = "linux-raw-sys" 1206 + version = "0.4.15" 1207 + source = "registry+https://github.com/rust-lang/crates.io-index" 1208 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 1209 + 1210 + [[package]] 1211 + name = "linux-raw-sys" 1212 + version = "0.12.1" 1213 + source = "registry+https://github.com/rust-lang/crates.io-index" 1214 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 1215 + 1216 + [[package]] 1217 + name = "litrs" 1218 + version = "1.0.0" 1219 + source = "registry+https://github.com/rust-lang/crates.io-index" 1220 + checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" 1221 + 1222 + [[package]] 1223 + name = "lock_api" 1224 + version = "0.4.14" 1225 + source = "registry+https://github.com/rust-lang/crates.io-index" 1226 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1227 + dependencies = [ 1228 + "scopeguard", 1229 + ] 1230 + 1231 + [[package]] 1232 + name = "log" 1233 + version = "0.4.29" 1234 + source = "registry+https://github.com/rust-lang/crates.io-index" 1235 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 1236 + 1237 + [[package]] 1238 + name = "matchers" 1239 + version = "0.2.0" 1240 + source = "registry+https://github.com/rust-lang/crates.io-index" 1241 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 1242 + dependencies = [ 1243 + "regex-automata", 1244 + ] 1245 + 1246 + [[package]] 1247 + name = "matrixmultiply" 1248 + version = "0.3.10" 1249 + source = "registry+https://github.com/rust-lang/crates.io-index" 1250 + checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" 1251 + dependencies = [ 1252 + "autocfg", 1253 + "rawpointer", 1254 + ] 1255 + 1256 + [[package]] 1257 + name = "memchr" 1258 + version = "2.8.0" 1259 + source = "registry+https://github.com/rust-lang/crates.io-index" 1260 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 1261 + 1262 + [[package]] 1263 + name = "memmap2" 1264 + version = "0.9.10" 1265 + source = "registry+https://github.com/rust-lang/crates.io-index" 1266 + checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" 1267 + dependencies = [ 1268 + "libc", 1269 + ] 1270 + 1271 + [[package]] 1272 + name = "miniz_oxide" 1273 + version = "0.8.9" 1274 + source = "registry+https://github.com/rust-lang/crates.io-index" 1275 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 1276 + dependencies = [ 1277 + "adler2", 1278 + "simd-adler32", 1279 + ] 1280 + 1281 + [[package]] 1282 + name = "naga" 1283 + version = "29.0.1" 1284 + source = "registry+https://github.com/rust-lang/crates.io-index" 1285 + checksum = "aa2630921705b9b01dcdd0b6864b9562ca3c1951eecd0f0c4f5f04f61e412647" 1286 + dependencies = [ 1287 + "arrayvec", 1288 + "bit-set", 1289 + "bitflags 2.11.1", 1290 + "cfg-if", 1291 + "cfg_aliases", 1292 + "codespan-reporting", 1293 + "half", 1294 + "hashbrown 0.16.1", 1295 + "hexf-parse", 1296 + "indexmap", 1297 + "libm", 1298 + "log", 1299 + "num-traits", 1300 + "once_cell", 1301 + "rustc-hash", 1302 + "spirv", 1303 + "thiserror 2.0.18", 1304 + "unicode-ident", 1305 + ] 1306 + 1307 + [[package]] 1308 + name = "nalgebra" 1309 + version = "0.33.3" 1310 + source = "registry+https://github.com/rust-lang/crates.io-index" 1311 + checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" 1312 + dependencies = [ 1313 + "approx", 1314 + "matrixmultiply", 1315 + "num-complex", 1316 + "num-rational", 1317 + "num-traits", 1318 + "simba", 1319 + "typenum", 1320 + ] 1321 + 1322 + [[package]] 1323 + name = "nano-gemm" 1324 + version = "0.2.2" 1325 + source = "registry+https://github.com/rust-lang/crates.io-index" 1326 + checksum = "9e04345dc84b498ff89fe0d38543d1f170da9e43a2c2bcee73a0f9069f72d081" 1327 + dependencies = [ 1328 + "equator 0.2.2", 1329 + "nano-gemm-c32", 1330 + "nano-gemm-c64", 1331 + "nano-gemm-codegen", 1332 + "nano-gemm-core", 1333 + "nano-gemm-f32", 1334 + "nano-gemm-f64", 1335 + "num-complex", 1336 + ] 1337 + 1338 + [[package]] 1339 + name = "nano-gemm-c32" 1340 + version = "0.2.1" 1341 + source = "registry+https://github.com/rust-lang/crates.io-index" 1342 + checksum = "0775b1e2520e64deee8fc78b7732e3091fb7585017c0b0f9f4b451757bbbc562" 1343 + dependencies = [ 1344 + "nano-gemm-codegen", 1345 + "nano-gemm-core", 1346 + "num-complex", 1347 + ] 1348 + 1349 + [[package]] 1350 + name = "nano-gemm-c64" 1351 + version = "0.2.1" 1352 + source = "registry+https://github.com/rust-lang/crates.io-index" 1353 + checksum = "9af49a20d58816e6b5ee65f64142e50edb5eba152678d4bb7377fcbf63f8437a" 1354 + dependencies = [ 1355 + "nano-gemm-codegen", 1356 + "nano-gemm-core", 1357 + "num-complex", 1358 + ] 1359 + 1360 + [[package]] 1361 + name = "nano-gemm-codegen" 1362 + version = "0.2.1" 1363 + source = "registry+https://github.com/rust-lang/crates.io-index" 1364 + checksum = "6cc8d495c791627779477a2cf5df60049f5b165342610eb0d76bee5ff5c5d74c" 1365 + 1366 + [[package]] 1367 + name = "nano-gemm-core" 1368 + version = "0.2.1" 1369 + source = "registry+https://github.com/rust-lang/crates.io-index" 1370 + checksum = "d998dfa644de87a0f8660e5ea511d7cb5c33b5a2d9847b7af57a2565105089f0" 1371 + 1372 + [[package]] 1373 + name = "nano-gemm-f32" 1374 + version = "0.2.1" 1375 + source = "registry+https://github.com/rust-lang/crates.io-index" 1376 + checksum = "879d962e79bc8952e4ad21ca4845a21132540ed3f5e01184b2ff7f720e666523" 1377 + dependencies = [ 1378 + "nano-gemm-codegen", 1379 + "nano-gemm-core", 1380 + ] 1381 + 1382 + [[package]] 1383 + name = "nano-gemm-f64" 1384 + version = "0.2.1" 1385 + source = "registry+https://github.com/rust-lang/crates.io-index" 1386 + checksum = "b9a513473dce7dc00c7e7c318481ca4494034e76997218d8dad51bd9f007a815" 1387 + dependencies = [ 1388 + "nano-gemm-codegen", 1389 + "nano-gemm-core", 1390 + ] 1391 + 1392 + [[package]] 1393 + name = "ndk" 1394 + version = "0.9.0" 1395 + source = "registry+https://github.com/rust-lang/crates.io-index" 1396 + checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" 1397 + dependencies = [ 1398 + "bitflags 2.11.1", 1399 + "jni-sys 0.3.1", 1400 + "log", 1401 + "ndk-sys", 1402 + "num_enum", 1403 + "raw-window-handle", 1404 + "thiserror 1.0.69", 1405 + ] 1406 + 1407 + [[package]] 1408 + name = "ndk-context" 1409 + version = "0.1.1" 1410 + source = "registry+https://github.com/rust-lang/crates.io-index" 1411 + checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" 1412 + 1413 + [[package]] 1414 + name = "ndk-sys" 1415 + version = "0.6.0+11769913" 1416 + source = "registry+https://github.com/rust-lang/crates.io-index" 1417 + checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" 1418 + dependencies = [ 1419 + "jni-sys 0.3.1", 1420 + ] 1421 + 1422 + [[package]] 1423 + name = "nu-ansi-term" 1424 + version = "0.50.3" 1425 + source = "registry+https://github.com/rust-lang/crates.io-index" 1426 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 1427 + dependencies = [ 1428 + "windows-sys 0.61.2", 1429 + ] 1430 + 1431 + [[package]] 1432 + name = "num-bigint" 1433 + version = "0.4.6" 1434 + source = "registry+https://github.com/rust-lang/crates.io-index" 1435 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 1436 + dependencies = [ 1437 + "num-integer", 1438 + "num-traits", 1439 + ] 1440 + 1441 + [[package]] 1442 + name = "num-complex" 1443 + version = "0.4.6" 1444 + source = "registry+https://github.com/rust-lang/crates.io-index" 1445 + checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" 1446 + dependencies = [ 1447 + "bytemuck", 1448 + "num-traits", 1449 + ] 1450 + 1451 + [[package]] 1452 + name = "num-integer" 1453 + version = "0.1.46" 1454 + source = "registry+https://github.com/rust-lang/crates.io-index" 1455 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1456 + dependencies = [ 1457 + "num-traits", 1458 + ] 1459 + 1460 + [[package]] 1461 + name = "num-rational" 1462 + version = "0.4.2" 1463 + source = "registry+https://github.com/rust-lang/crates.io-index" 1464 + checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" 1465 + dependencies = [ 1466 + "num-bigint", 1467 + "num-integer", 1468 + "num-traits", 1469 + ] 1470 + 1471 + [[package]] 1472 + name = "num-traits" 1473 + version = "0.2.19" 1474 + source = "registry+https://github.com/rust-lang/crates.io-index" 1475 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1476 + dependencies = [ 1477 + "autocfg", 1478 + "libm", 1479 + ] 1480 + 1481 + [[package]] 1482 + name = "num_cpus" 1483 + version = "1.17.0" 1484 + source = "registry+https://github.com/rust-lang/crates.io-index" 1485 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 1486 + dependencies = [ 1487 + "hermit-abi", 1488 + "libc", 1489 + ] 1490 + 1491 + [[package]] 1492 + name = "num_enum" 1493 + version = "0.7.6" 1494 + source = "registry+https://github.com/rust-lang/crates.io-index" 1495 + checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" 1496 + dependencies = [ 1497 + "num_enum_derive", 1498 + "rustversion", 1499 + ] 1500 + 1501 + [[package]] 1502 + name = "num_enum_derive" 1503 + version = "0.7.6" 1504 + source = "registry+https://github.com/rust-lang/crates.io-index" 1505 + checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" 1506 + dependencies = [ 1507 + "proc-macro-crate", 1508 + "proc-macro2", 1509 + "quote", 1510 + "syn 2.0.117", 1511 + ] 1512 + 1513 + [[package]] 1514 + name = "objc-sys" 1515 + version = "0.3.5" 1516 + source = "registry+https://github.com/rust-lang/crates.io-index" 1517 + checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" 1518 + 1519 + [[package]] 1520 + name = "objc2" 1521 + version = "0.5.2" 1522 + source = "registry+https://github.com/rust-lang/crates.io-index" 1523 + checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" 1524 + dependencies = [ 1525 + "objc-sys", 1526 + "objc2-encode", 1527 + ] 1528 + 1529 + [[package]] 1530 + name = "objc2" 1531 + version = "0.6.4" 1532 + source = "registry+https://github.com/rust-lang/crates.io-index" 1533 + checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" 1534 + dependencies = [ 1535 + "objc2-encode", 1536 + ] 1537 + 1538 + [[package]] 1539 + name = "objc2-app-kit" 1540 + version = "0.2.2" 1541 + source = "registry+https://github.com/rust-lang/crates.io-index" 1542 + checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" 1543 + dependencies = [ 1544 + "bitflags 2.11.1", 1545 + "block2 0.5.1", 1546 + "libc", 1547 + "objc2 0.5.2", 1548 + "objc2-core-data", 1549 + "objc2-core-image", 1550 + "objc2-foundation 0.2.2", 1551 + "objc2-quartz-core 0.2.2", 1552 + ] 1553 + 1554 + [[package]] 1555 + name = "objc2-cloud-kit" 1556 + version = "0.2.2" 1557 + source = "registry+https://github.com/rust-lang/crates.io-index" 1558 + checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" 1559 + dependencies = [ 1560 + "bitflags 2.11.1", 1561 + "block2 0.5.1", 1562 + "objc2 0.5.2", 1563 + "objc2-core-location", 1564 + "objc2-foundation 0.2.2", 1565 + ] 1566 + 1567 + [[package]] 1568 + name = "objc2-contacts" 1569 + version = "0.2.2" 1570 + source = "registry+https://github.com/rust-lang/crates.io-index" 1571 + checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" 1572 + dependencies = [ 1573 + "block2 0.5.1", 1574 + "objc2 0.5.2", 1575 + "objc2-foundation 0.2.2", 1576 + ] 1577 + 1578 + [[package]] 1579 + name = "objc2-core-data" 1580 + version = "0.2.2" 1581 + source = "registry+https://github.com/rust-lang/crates.io-index" 1582 + checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" 1583 + dependencies = [ 1584 + "bitflags 2.11.1", 1585 + "block2 0.5.1", 1586 + "objc2 0.5.2", 1587 + "objc2-foundation 0.2.2", 1588 + ] 1589 + 1590 + [[package]] 1591 + name = "objc2-core-foundation" 1592 + version = "0.3.2" 1593 + source = "registry+https://github.com/rust-lang/crates.io-index" 1594 + checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" 1595 + dependencies = [ 1596 + "bitflags 2.11.1", 1597 + "dispatch2", 1598 + "objc2 0.6.4", 1599 + ] 1600 + 1601 + [[package]] 1602 + name = "objc2-core-image" 1603 + version = "0.2.2" 1604 + source = "registry+https://github.com/rust-lang/crates.io-index" 1605 + checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" 1606 + dependencies = [ 1607 + "block2 0.5.1", 1608 + "objc2 0.5.2", 1609 + "objc2-foundation 0.2.2", 1610 + "objc2-metal 0.2.2", 1611 + ] 1612 + 1613 + [[package]] 1614 + name = "objc2-core-location" 1615 + version = "0.2.2" 1616 + source = "registry+https://github.com/rust-lang/crates.io-index" 1617 + checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" 1618 + dependencies = [ 1619 + "block2 0.5.1", 1620 + "objc2 0.5.2", 1621 + "objc2-contacts", 1622 + "objc2-foundation 0.2.2", 1623 + ] 1624 + 1625 + [[package]] 1626 + name = "objc2-encode" 1627 + version = "4.1.0" 1628 + source = "registry+https://github.com/rust-lang/crates.io-index" 1629 + checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" 1630 + 1631 + [[package]] 1632 + name = "objc2-foundation" 1633 + version = "0.2.2" 1634 + source = "registry+https://github.com/rust-lang/crates.io-index" 1635 + checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" 1636 + dependencies = [ 1637 + "bitflags 2.11.1", 1638 + "block2 0.5.1", 1639 + "dispatch", 1640 + "libc", 1641 + "objc2 0.5.2", 1642 + ] 1643 + 1644 + [[package]] 1645 + name = "objc2-foundation" 1646 + version = "0.3.2" 1647 + source = "registry+https://github.com/rust-lang/crates.io-index" 1648 + checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" 1649 + dependencies = [ 1650 + "bitflags 2.11.1", 1651 + "objc2 0.6.4", 1652 + "objc2-core-foundation", 1653 + ] 1654 + 1655 + [[package]] 1656 + name = "objc2-link-presentation" 1657 + version = "0.2.2" 1658 + source = "registry+https://github.com/rust-lang/crates.io-index" 1659 + checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" 1660 + dependencies = [ 1661 + "block2 0.5.1", 1662 + "objc2 0.5.2", 1663 + "objc2-app-kit", 1664 + "objc2-foundation 0.2.2", 1665 + ] 1666 + 1667 + [[package]] 1668 + name = "objc2-metal" 1669 + version = "0.2.2" 1670 + source = "registry+https://github.com/rust-lang/crates.io-index" 1671 + checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" 1672 + dependencies = [ 1673 + "bitflags 2.11.1", 1674 + "block2 0.5.1", 1675 + "objc2 0.5.2", 1676 + "objc2-foundation 0.2.2", 1677 + ] 1678 + 1679 + [[package]] 1680 + name = "objc2-metal" 1681 + version = "0.3.2" 1682 + source = "registry+https://github.com/rust-lang/crates.io-index" 1683 + checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" 1684 + dependencies = [ 1685 + "bitflags 2.11.1", 1686 + "block2 0.6.2", 1687 + "objc2 0.6.4", 1688 + "objc2-foundation 0.3.2", 1689 + ] 1690 + 1691 + [[package]] 1692 + name = "objc2-quartz-core" 1693 + version = "0.2.2" 1694 + source = "registry+https://github.com/rust-lang/crates.io-index" 1695 + checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" 1696 + dependencies = [ 1697 + "bitflags 2.11.1", 1698 + "block2 0.5.1", 1699 + "objc2 0.5.2", 1700 + "objc2-foundation 0.2.2", 1701 + "objc2-metal 0.2.2", 1702 + ] 1703 + 1704 + [[package]] 1705 + name = "objc2-quartz-core" 1706 + version = "0.3.2" 1707 + source = "registry+https://github.com/rust-lang/crates.io-index" 1708 + checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" 1709 + dependencies = [ 1710 + "bitflags 2.11.1", 1711 + "objc2 0.6.4", 1712 + "objc2-core-foundation", 1713 + "objc2-foundation 0.3.2", 1714 + "objc2-metal 0.3.2", 1715 + ] 1716 + 1717 + [[package]] 1718 + name = "objc2-symbols" 1719 + version = "0.2.2" 1720 + source = "registry+https://github.com/rust-lang/crates.io-index" 1721 + checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" 1722 + dependencies = [ 1723 + "objc2 0.5.2", 1724 + "objc2-foundation 0.2.2", 1725 + ] 1726 + 1727 + [[package]] 1728 + name = "objc2-ui-kit" 1729 + version = "0.2.2" 1730 + source = "registry+https://github.com/rust-lang/crates.io-index" 1731 + checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" 1732 + dependencies = [ 1733 + "bitflags 2.11.1", 1734 + "block2 0.5.1", 1735 + "objc2 0.5.2", 1736 + "objc2-cloud-kit", 1737 + "objc2-core-data", 1738 + "objc2-core-image", 1739 + "objc2-core-location", 1740 + "objc2-foundation 0.2.2", 1741 + "objc2-link-presentation", 1742 + "objc2-quartz-core 0.2.2", 1743 + "objc2-symbols", 1744 + "objc2-uniform-type-identifiers", 1745 + "objc2-user-notifications", 1746 + ] 1747 + 1748 + [[package]] 1749 + name = "objc2-uniform-type-identifiers" 1750 + version = "0.2.2" 1751 + source = "registry+https://github.com/rust-lang/crates.io-index" 1752 + checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" 1753 + dependencies = [ 1754 + "block2 0.5.1", 1755 + "objc2 0.5.2", 1756 + "objc2-foundation 0.2.2", 1757 + ] 1758 + 1759 + [[package]] 1760 + name = "objc2-user-notifications" 1761 + version = "0.2.2" 1762 + source = "registry+https://github.com/rust-lang/crates.io-index" 1763 + checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" 1764 + dependencies = [ 1765 + "bitflags 2.11.1", 1766 + "block2 0.5.1", 1767 + "objc2 0.5.2", 1768 + "objc2-core-location", 1769 + "objc2-foundation 0.2.2", 1770 + ] 1771 + 1772 + [[package]] 1773 + name = "once_cell" 1774 + version = "1.21.4" 1775 + source = "registry+https://github.com/rust-lang/crates.io-index" 1776 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 1777 + 1778 + [[package]] 1779 + name = "orbclient" 1780 + version = "0.3.53" 1781 + source = "registry+https://github.com/rust-lang/crates.io-index" 1782 + checksum = "12c6933ddbbd16539a7672e697bb8d41ac3a4e99ac43eeb40c07236bd7fcb2dd" 1783 + dependencies = [ 1784 + "libc", 1785 + "libredox", 1786 + ] 1787 + 1788 + [[package]] 1789 + name = "ordered-float" 1790 + version = "5.3.0" 1791 + source = "registry+https://github.com/rust-lang/crates.io-index" 1792 + checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" 1793 + dependencies = [ 1794 + "num-traits", 1795 + ] 1796 + 1797 + [[package]] 1798 + name = "owned_ttf_parser" 1799 + version = "0.25.1" 1800 + source = "registry+https://github.com/rust-lang/crates.io-index" 1801 + checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" 1802 + dependencies = [ 1803 + "ttf-parser", 1804 + ] 1805 + 1806 + [[package]] 1807 + name = "parking_lot" 1808 + version = "0.12.5" 1809 + source = "registry+https://github.com/rust-lang/crates.io-index" 1810 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 1811 + dependencies = [ 1812 + "lock_api", 1813 + "parking_lot_core", 1814 + ] 1815 + 1816 + [[package]] 1817 + name = "parking_lot_core" 1818 + version = "0.9.12" 1819 + source = "registry+https://github.com/rust-lang/crates.io-index" 1820 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 1821 + dependencies = [ 1822 + "cfg-if", 1823 + "libc", 1824 + "redox_syscall 0.5.18", 1825 + "smallvec", 1826 + "windows-link", 1827 + ] 1828 + 1829 + [[package]] 1830 + name = "paste" 1831 + version = "1.0.15" 1832 + source = "registry+https://github.com/rust-lang/crates.io-index" 1833 + checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1834 + 1835 + [[package]] 1836 + name = "percent-encoding" 1837 + version = "2.3.2" 1838 + source = "registry+https://github.com/rust-lang/crates.io-index" 1839 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1840 + 1841 + [[package]] 1842 + name = "pin-project" 1843 + version = "1.1.11" 1844 + source = "registry+https://github.com/rust-lang/crates.io-index" 1845 + checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" 1846 + dependencies = [ 1847 + "pin-project-internal", 1848 + ] 1849 + 1850 + [[package]] 1851 + name = "pin-project-internal" 1852 + version = "1.1.11" 1853 + source = "registry+https://github.com/rust-lang/crates.io-index" 1854 + checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" 1855 + dependencies = [ 1856 + "proc-macro2", 1857 + "quote", 1858 + "syn 2.0.117", 1859 + ] 1860 + 1861 + [[package]] 1862 + name = "pin-project-lite" 1863 + version = "0.2.17" 1864 + source = "registry+https://github.com/rust-lang/crates.io-index" 1865 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 1866 + 1867 + [[package]] 1868 + name = "pkg-config" 1869 + version = "0.3.33" 1870 + source = "registry+https://github.com/rust-lang/crates.io-index" 1871 + checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" 1872 + 1873 + [[package]] 1874 + name = "plain" 1875 + version = "0.2.3" 1876 + source = "registry+https://github.com/rust-lang/crates.io-index" 1877 + checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" 1878 + 1879 + [[package]] 1880 + name = "png" 1881 + version = "0.17.16" 1882 + source = "registry+https://github.com/rust-lang/crates.io-index" 1883 + checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" 1884 + dependencies = [ 1885 + "bitflags 1.3.2", 1886 + "crc32fast", 1887 + "fdeflate", 1888 + "flate2", 1889 + "miniz_oxide", 1890 + ] 1891 + 1892 + [[package]] 1893 + name = "polling" 1894 + version = "3.11.0" 1895 + source = "registry+https://github.com/rust-lang/crates.io-index" 1896 + checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" 1897 + dependencies = [ 1898 + "cfg-if", 1899 + "concurrent-queue", 1900 + "hermit-abi", 1901 + "pin-project-lite", 1902 + "rustix 1.1.4", 1903 + "windows-sys 0.61.2", 1904 + ] 1905 + 1906 + [[package]] 1907 + name = "pollster" 1908 + version = "0.4.0" 1909 + source = "registry+https://github.com/rust-lang/crates.io-index" 1910 + checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" 1911 + 1912 + [[package]] 1913 + name = "portable-atomic" 1914 + version = "1.13.1" 1915 + source = "registry+https://github.com/rust-lang/crates.io-index" 1916 + checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" 1917 + 1918 + [[package]] 1919 + name = "portable-atomic-util" 1920 + version = "0.2.7" 1921 + source = "registry+https://github.com/rust-lang/crates.io-index" 1922 + checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" 1923 + dependencies = [ 1924 + "portable-atomic", 1925 + ] 1926 + 1927 + [[package]] 1928 + name = "ppv-lite86" 1929 + version = "0.2.21" 1930 + source = "registry+https://github.com/rust-lang/crates.io-index" 1931 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1932 + dependencies = [ 1933 + "zerocopy", 1934 + ] 1935 + 1936 + [[package]] 1937 + name = "presser" 1938 + version = "0.3.1" 1939 + source = "registry+https://github.com/rust-lang/crates.io-index" 1940 + checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" 1941 + 1942 + [[package]] 1943 + name = "private-gemm-x86" 1944 + version = "0.1.20" 1945 + source = "registry+https://github.com/rust-lang/crates.io-index" 1946 + checksum = "0af8c3e5087969c323f667ccb4b789fa0954f5aa650550e38e81cf9108be21b5" 1947 + dependencies = [ 1948 + "defer", 1949 + "interpol", 1950 + "num_cpus", 1951 + "raw-cpuid", 1952 + ] 1953 + 1954 + [[package]] 1955 + name = "proc-macro-crate" 1956 + version = "3.5.0" 1957 + source = "registry+https://github.com/rust-lang/crates.io-index" 1958 + checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" 1959 + dependencies = [ 1960 + "toml_edit", 1961 + ] 1962 + 1963 + [[package]] 1964 + name = "proc-macro2" 1965 + version = "1.0.106" 1966 + source = "registry+https://github.com/rust-lang/crates.io-index" 1967 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 1968 + dependencies = [ 1969 + "unicode-ident", 1970 + ] 1971 + 1972 + [[package]] 1973 + name = "profiling" 1974 + version = "1.0.17" 1975 + source = "registry+https://github.com/rust-lang/crates.io-index" 1976 + checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" 1977 + 1978 + [[package]] 1979 + name = "proptest" 1980 + version = "1.11.0" 1981 + source = "registry+https://github.com/rust-lang/crates.io-index" 1982 + checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" 1983 + dependencies = [ 1984 + "bitflags 2.11.1", 1985 + "num-traits", 1986 + "rand", 1987 + "rand_chacha", 1988 + "rand_xorshift", 1989 + "regex-syntax", 1990 + "unarray", 1991 + ] 1992 + 1993 + [[package]] 1994 + name = "pulp" 1995 + version = "0.22.2" 1996 + source = "registry+https://github.com/rust-lang/crates.io-index" 1997 + checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" 1998 + dependencies = [ 1999 + "bytemuck", 2000 + "cfg-if", 2001 + "libm", 2002 + "num-complex", 2003 + "paste", 2004 + "pulp-wasm-simd-flag", 2005 + "raw-cpuid", 2006 + "reborrow", 2007 + "version_check", 2008 + ] 2009 + 2010 + [[package]] 2011 + name = "pulp-wasm-simd-flag" 2012 + version = "0.1.0" 2013 + source = "registry+https://github.com/rust-lang/crates.io-index" 2014 + checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" 2015 + 2016 + [[package]] 2017 + name = "qd" 2018 + version = "0.8.0" 2019 + source = "registry+https://github.com/rust-lang/crates.io-index" 2020 + checksum = "15f1304a5aecdcfe9ee72fbba90aa37b3aa067a69d14cb7f3d9deada0be7c07c" 2021 + dependencies = [ 2022 + "bytemuck", 2023 + "libm", 2024 + "num-traits", 2025 + "pulp", 2026 + ] 2027 + 2028 + [[package]] 2029 + name = "quick-xml" 2030 + version = "0.39.2" 2031 + source = "registry+https://github.com/rust-lang/crates.io-index" 2032 + checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" 2033 + dependencies = [ 2034 + "memchr", 2035 + ] 2036 + 2037 + [[package]] 2038 + name = "quote" 2039 + version = "1.0.45" 2040 + source = "registry+https://github.com/rust-lang/crates.io-index" 2041 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 2042 + dependencies = [ 2043 + "proc-macro2", 2044 + ] 2045 + 2046 + [[package]] 2047 + name = "r-efi" 2048 + version = "5.3.0" 2049 + source = "registry+https://github.com/rust-lang/crates.io-index" 2050 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 2051 + 2052 + [[package]] 2053 + name = "rand" 2054 + version = "0.9.4" 2055 + source = "registry+https://github.com/rust-lang/crates.io-index" 2056 + checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" 2057 + dependencies = [ 2058 + "rand_chacha", 2059 + "rand_core", 2060 + ] 2061 + 2062 + [[package]] 2063 + name = "rand_chacha" 2064 + version = "0.9.0" 2065 + source = "registry+https://github.com/rust-lang/crates.io-index" 2066 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 2067 + dependencies = [ 2068 + "ppv-lite86", 2069 + "rand_core", 2070 + ] 2071 + 2072 + [[package]] 2073 + name = "rand_core" 2074 + version = "0.9.5" 2075 + source = "registry+https://github.com/rust-lang/crates.io-index" 2076 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 2077 + dependencies = [ 2078 + "getrandom", 2079 + ] 2080 + 2081 + [[package]] 2082 + name = "rand_xorshift" 2083 + version = "0.4.0" 2084 + source = "registry+https://github.com/rust-lang/crates.io-index" 2085 + checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" 2086 + dependencies = [ 2087 + "rand_core", 2088 + ] 2089 + 2090 + [[package]] 2091 + name = "range-alloc" 2092 + version = "0.1.5" 2093 + source = "registry+https://github.com/rust-lang/crates.io-index" 2094 + checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" 2095 + 2096 + [[package]] 2097 + name = "raw-cpuid" 2098 + version = "11.6.0" 2099 + source = "registry+https://github.com/rust-lang/crates.io-index" 2100 + checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" 2101 + dependencies = [ 2102 + "bitflags 2.11.1", 2103 + ] 2104 + 2105 + [[package]] 2106 + name = "raw-window-handle" 2107 + version = "0.6.2" 2108 + source = "registry+https://github.com/rust-lang/crates.io-index" 2109 + checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" 2110 + 2111 + [[package]] 2112 + name = "raw-window-metal" 2113 + version = "1.1.0" 2114 + source = "registry+https://github.com/rust-lang/crates.io-index" 2115 + checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" 2116 + dependencies = [ 2117 + "objc2 0.6.4", 2118 + "objc2-core-foundation", 2119 + "objc2-foundation 0.3.2", 2120 + "objc2-quartz-core 0.3.2", 2121 + ] 2122 + 2123 + [[package]] 2124 + name = "rawpointer" 2125 + version = "0.2.1" 2126 + source = "registry+https://github.com/rust-lang/crates.io-index" 2127 + checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" 2128 + 2129 + [[package]] 2130 + name = "reborrow" 2131 + version = "0.5.5" 2132 + source = "registry+https://github.com/rust-lang/crates.io-index" 2133 + checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" 2134 + 2135 + [[package]] 2136 + name = "redox_syscall" 2137 + version = "0.4.1" 2138 + source = "registry+https://github.com/rust-lang/crates.io-index" 2139 + checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" 2140 + dependencies = [ 2141 + "bitflags 1.3.2", 2142 + ] 2143 + 2144 + [[package]] 2145 + name = "redox_syscall" 2146 + version = "0.5.18" 2147 + source = "registry+https://github.com/rust-lang/crates.io-index" 2148 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 2149 + dependencies = [ 2150 + "bitflags 2.11.1", 2151 + ] 2152 + 2153 + [[package]] 2154 + name = "redox_syscall" 2155 + version = "0.7.4" 2156 + source = "registry+https://github.com/rust-lang/crates.io-index" 2157 + checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" 2158 + dependencies = [ 2159 + "bitflags 2.11.1", 2160 + ] 2161 + 2162 + [[package]] 2163 + name = "regex-automata" 2164 + version = "0.4.14" 2165 + source = "registry+https://github.com/rust-lang/crates.io-index" 2166 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 2167 + dependencies = [ 2168 + "aho-corasick", 2169 + "memchr", 2170 + "regex-syntax", 2171 + ] 2172 + 2173 + [[package]] 2174 + name = "regex-syntax" 2175 + version = "0.8.10" 2176 + source = "registry+https://github.com/rust-lang/crates.io-index" 2177 + checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 2178 + 2179 + [[package]] 2180 + name = "renderdoc-sys" 2181 + version = "1.1.0" 2182 + source = "registry+https://github.com/rust-lang/crates.io-index" 2183 + checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" 2184 + 2185 + [[package]] 2186 + name = "ron" 2187 + version = "0.12.1" 2188 + source = "registry+https://github.com/rust-lang/crates.io-index" 2189 + checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" 2190 + dependencies = [ 2191 + "bitflags 2.11.1", 2192 + "once_cell", 2193 + "serde", 2194 + "serde_derive", 2195 + "typeid", 2196 + "unicode-ident", 2197 + ] 2198 + 2199 + [[package]] 2200 + name = "rustc-hash" 2201 + version = "1.1.0" 2202 + source = "registry+https://github.com/rust-lang/crates.io-index" 2203 + checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 2204 + 2205 + [[package]] 2206 + name = "rustc_version" 2207 + version = "0.4.1" 2208 + source = "registry+https://github.com/rust-lang/crates.io-index" 2209 + checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 2210 + dependencies = [ 2211 + "semver", 2212 + ] 2213 + 2214 + [[package]] 2215 + name = "rustix" 2216 + version = "0.38.44" 2217 + source = "registry+https://github.com/rust-lang/crates.io-index" 2218 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 2219 + dependencies = [ 2220 + "bitflags 2.11.1", 2221 + "errno", 2222 + "libc", 2223 + "linux-raw-sys 0.4.15", 2224 + "windows-sys 0.59.0", 2225 + ] 2226 + 2227 + [[package]] 2228 + name = "rustix" 2229 + version = "1.1.4" 2230 + source = "registry+https://github.com/rust-lang/crates.io-index" 2231 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 2232 + dependencies = [ 2233 + "bitflags 2.11.1", 2234 + "errno", 2235 + "libc", 2236 + "linux-raw-sys 0.12.1", 2237 + "windows-sys 0.61.2", 2238 + ] 2239 + 2240 + [[package]] 2241 + name = "rustversion" 2242 + version = "1.0.22" 2243 + source = "registry+https://github.com/rust-lang/crates.io-index" 2244 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2245 + 2246 + [[package]] 2247 + name = "safe_arch" 2248 + version = "0.7.4" 2249 + source = "registry+https://github.com/rust-lang/crates.io-index" 2250 + checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" 2251 + dependencies = [ 2252 + "bytemuck", 2253 + ] 2254 + 2255 + [[package]] 2256 + name = "same-file" 2257 + version = "1.0.6" 2258 + source = "registry+https://github.com/rust-lang/crates.io-index" 2259 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 2260 + dependencies = [ 2261 + "winapi-util", 2262 + ] 2263 + 2264 + [[package]] 2265 + name = "scoped-tls" 2266 + version = "1.0.1" 2267 + source = "registry+https://github.com/rust-lang/crates.io-index" 2268 + checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 2269 + 2270 + [[package]] 2271 + name = "scopeguard" 2272 + version = "1.2.0" 2273 + source = "registry+https://github.com/rust-lang/crates.io-index" 2274 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2275 + 2276 + [[package]] 2277 + name = "sctk-adwaita" 2278 + version = "0.10.1" 2279 + source = "registry+https://github.com/rust-lang/crates.io-index" 2280 + checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" 2281 + dependencies = [ 2282 + "ab_glyph", 2283 + "log", 2284 + "memmap2", 2285 + "smithay-client-toolkit", 2286 + "tiny-skia", 2287 + ] 2288 + 2289 + [[package]] 2290 + name = "semver" 2291 + version = "1.0.28" 2292 + source = "registry+https://github.com/rust-lang/crates.io-index" 2293 + checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" 2294 + 2295 + [[package]] 2296 + name = "seq-macro" 2297 + version = "0.3.6" 2298 + source = "registry+https://github.com/rust-lang/crates.io-index" 2299 + checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" 2300 + 2301 + [[package]] 2302 + name = "serde" 2303 + version = "1.0.228" 2304 + source = "registry+https://github.com/rust-lang/crates.io-index" 2305 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 2306 + dependencies = [ 2307 + "serde_core", 2308 + "serde_derive", 2309 + ] 2310 + 2311 + [[package]] 2312 + name = "serde_core" 2313 + version = "1.0.228" 2314 + source = "registry+https://github.com/rust-lang/crates.io-index" 2315 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 2316 + dependencies = [ 2317 + "serde_derive", 2318 + ] 2319 + 2320 + [[package]] 2321 + name = "serde_derive" 2322 + version = "1.0.228" 2323 + source = "registry+https://github.com/rust-lang/crates.io-index" 2324 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 2325 + dependencies = [ 2326 + "proc-macro2", 2327 + "quote", 2328 + "syn 2.0.117", 2329 + ] 2330 + 2331 + [[package]] 2332 + name = "sharded-slab" 2333 + version = "0.1.7" 2334 + source = "registry+https://github.com/rust-lang/crates.io-index" 2335 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 2336 + dependencies = [ 2337 + "lazy_static", 2338 + ] 2339 + 2340 + [[package]] 2341 + name = "shlex" 2342 + version = "1.3.0" 2343 + source = "registry+https://github.com/rust-lang/crates.io-index" 2344 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 2345 + 2346 + [[package]] 2347 + name = "simba" 2348 + version = "0.9.1" 2349 + source = "registry+https://github.com/rust-lang/crates.io-index" 2350 + checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" 2351 + dependencies = [ 2352 + "approx", 2353 + "num-complex", 2354 + "num-traits", 2355 + "paste", 2356 + "wide", 2357 + ] 2358 + 2359 + [[package]] 2360 + name = "simd-adler32" 2361 + version = "0.3.9" 2362 + source = "registry+https://github.com/rust-lang/crates.io-index" 2363 + checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" 2364 + 2365 + [[package]] 2366 + name = "simd_cesu8" 2367 + version = "1.1.1" 2368 + source = "registry+https://github.com/rust-lang/crates.io-index" 2369 + checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" 2370 + dependencies = [ 2371 + "rustc_version", 2372 + "simdutf8", 2373 + ] 2374 + 2375 + [[package]] 2376 + name = "simdutf8" 2377 + version = "0.1.5" 2378 + source = "registry+https://github.com/rust-lang/crates.io-index" 2379 + checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 2380 + 2381 + [[package]] 2382 + name = "similar" 2383 + version = "2.7.0" 2384 + source = "registry+https://github.com/rust-lang/crates.io-index" 2385 + checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 2386 + 2387 + [[package]] 2388 + name = "slab" 2389 + version = "0.4.12" 2390 + source = "registry+https://github.com/rust-lang/crates.io-index" 2391 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 2392 + 2393 + [[package]] 2394 + name = "slotmap" 2395 + version = "1.1.1" 2396 + source = "registry+https://github.com/rust-lang/crates.io-index" 2397 + checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" 2398 + dependencies = [ 2399 + "serde", 2400 + "version_check", 2401 + ] 2402 + 2403 + [[package]] 2404 + name = "smallvec" 2405 + version = "1.15.1" 2406 + source = "registry+https://github.com/rust-lang/crates.io-index" 2407 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 2408 + 2409 + [[package]] 2410 + name = "smithay-client-toolkit" 2411 + version = "0.19.2" 2412 + source = "registry+https://github.com/rust-lang/crates.io-index" 2413 + checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" 2414 + dependencies = [ 2415 + "bitflags 2.11.1", 2416 + "calloop", 2417 + "calloop-wayland-source", 2418 + "cursor-icon", 2419 + "libc", 2420 + "log", 2421 + "memmap2", 2422 + "rustix 0.38.44", 2423 + "thiserror 1.0.69", 2424 + "wayland-backend", 2425 + "wayland-client", 2426 + "wayland-csd-frame", 2427 + "wayland-cursor", 2428 + "wayland-protocols", 2429 + "wayland-protocols-wlr", 2430 + "wayland-scanner", 2431 + "xkeysym", 2432 + ] 2433 + 2434 + [[package]] 2435 + name = "smol_str" 2436 + version = "0.2.2" 2437 + source = "registry+https://github.com/rust-lang/crates.io-index" 2438 + checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" 2439 + dependencies = [ 2440 + "serde", 2441 + ] 2442 + 2443 + [[package]] 2444 + name = "spirv" 2445 + version = "0.4.0+sdk-1.4.341.0" 2446 + source = "registry+https://github.com/rust-lang/crates.io-index" 2447 + checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" 2448 + dependencies = [ 2449 + "bitflags 2.11.1", 2450 + ] 2451 + 2452 + [[package]] 2453 + name = "static_assertions" 2454 + version = "1.1.0" 2455 + source = "registry+https://github.com/rust-lang/crates.io-index" 2456 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 2457 + 2458 + [[package]] 2459 + name = "strict-num" 2460 + version = "0.1.1" 2461 + source = "registry+https://github.com/rust-lang/crates.io-index" 2462 + checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" 2463 + 2464 + [[package]] 2465 + name = "syn" 2466 + version = "1.0.109" 2467 + source = "registry+https://github.com/rust-lang/crates.io-index" 2468 + checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 2469 + dependencies = [ 2470 + "proc-macro2", 2471 + "quote", 2472 + "unicode-ident", 2473 + ] 2474 + 2475 + [[package]] 2476 + name = "syn" 2477 + version = "2.0.117" 2478 + source = "registry+https://github.com/rust-lang/crates.io-index" 2479 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 2480 + dependencies = [ 2481 + "proc-macro2", 2482 + "quote", 2483 + "unicode-ident", 2484 + ] 2485 + 2486 + [[package]] 2487 + name = "sysctl" 2488 + version = "0.6.0" 2489 + source = "registry+https://github.com/rust-lang/crates.io-index" 2490 + checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" 2491 + dependencies = [ 2492 + "bitflags 2.11.1", 2493 + "byteorder", 2494 + "enum-as-inner", 2495 + "libc", 2496 + "thiserror 1.0.69", 2497 + "walkdir", 2498 + ] 2499 + 2500 + [[package]] 2501 + name = "tempfile" 2502 + version = "3.27.0" 2503 + source = "registry+https://github.com/rust-lang/crates.io-index" 2504 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" 2505 + dependencies = [ 2506 + "fastrand", 2507 + "getrandom", 2508 + "once_cell", 2509 + "rustix 1.1.4", 2510 + "windows-sys 0.61.2", 2511 + ] 2512 + 2513 + [[package]] 2514 + name = "termcolor" 2515 + version = "1.4.1" 2516 + source = "registry+https://github.com/rust-lang/crates.io-index" 2517 + checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 2518 + dependencies = [ 2519 + "winapi-util", 2520 + ] 2521 + 2522 + [[package]] 2523 + name = "thiserror" 2524 + version = "1.0.69" 2525 + source = "registry+https://github.com/rust-lang/crates.io-index" 2526 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2527 + dependencies = [ 2528 + "thiserror-impl 1.0.69", 2529 + ] 2530 + 2531 + [[package]] 2532 + name = "thiserror" 2533 + version = "2.0.18" 2534 + source = "registry+https://github.com/rust-lang/crates.io-index" 2535 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 2536 + dependencies = [ 2537 + "thiserror-impl 2.0.18", 2538 + ] 2539 + 2540 + [[package]] 2541 + name = "thiserror-impl" 2542 + version = "1.0.69" 2543 + source = "registry+https://github.com/rust-lang/crates.io-index" 2544 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 2545 + dependencies = [ 2546 + "proc-macro2", 2547 + "quote", 2548 + "syn 2.0.117", 2549 + ] 2550 + 2551 + [[package]] 2552 + name = "thiserror-impl" 2553 + version = "2.0.18" 2554 + source = "registry+https://github.com/rust-lang/crates.io-index" 2555 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 2556 + dependencies = [ 2557 + "proc-macro2", 2558 + "quote", 2559 + "syn 2.0.117", 2560 + ] 2561 + 2562 + [[package]] 2563 + name = "thread_local" 2564 + version = "1.1.9" 2565 + source = "registry+https://github.com/rust-lang/crates.io-index" 2566 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 2567 + dependencies = [ 2568 + "cfg-if", 2569 + ] 2570 + 2571 + [[package]] 2572 + name = "tiny-skia" 2573 + version = "0.11.4" 2574 + source = "registry+https://github.com/rust-lang/crates.io-index" 2575 + checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" 2576 + dependencies = [ 2577 + "arrayref", 2578 + "arrayvec", 2579 + "bytemuck", 2580 + "cfg-if", 2581 + "log", 2582 + "tiny-skia-path", 2583 + ] 2584 + 2585 + [[package]] 2586 + name = "tiny-skia-path" 2587 + version = "0.11.4" 2588 + source = "registry+https://github.com/rust-lang/crates.io-index" 2589 + checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" 2590 + dependencies = [ 2591 + "arrayref", 2592 + "bytemuck", 2593 + "strict-num", 2594 + ] 2595 + 2596 + [[package]] 2597 + name = "toml_datetime" 2598 + version = "1.1.1+spec-1.1.0" 2599 + source = "registry+https://github.com/rust-lang/crates.io-index" 2600 + checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" 2601 + dependencies = [ 2602 + "serde_core", 2603 + ] 2604 + 2605 + [[package]] 2606 + name = "toml_edit" 2607 + version = "0.25.11+spec-1.1.0" 2608 + source = "registry+https://github.com/rust-lang/crates.io-index" 2609 + checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" 2610 + dependencies = [ 2611 + "indexmap", 2612 + "toml_datetime", 2613 + "toml_parser", 2614 + "winnow", 2615 + ] 2616 + 2617 + [[package]] 2618 + name = "toml_parser" 2619 + version = "1.1.2+spec-1.1.0" 2620 + source = "registry+https://github.com/rust-lang/crates.io-index" 2621 + checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" 2622 + dependencies = [ 2623 + "winnow", 2624 + ] 2625 + 2626 + [[package]] 2627 + name = "tracing" 2628 + version = "0.1.44" 2629 + source = "registry+https://github.com/rust-lang/crates.io-index" 2630 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 2631 + dependencies = [ 2632 + "pin-project-lite", 2633 + "tracing-attributes", 2634 + "tracing-core", 2635 + ] 2636 + 2637 + [[package]] 2638 + name = "tracing-attributes" 2639 + version = "0.1.31" 2640 + source = "registry+https://github.com/rust-lang/crates.io-index" 2641 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 2642 + dependencies = [ 2643 + "proc-macro2", 2644 + "quote", 2645 + "syn 2.0.117", 2646 + ] 2647 + 2648 + [[package]] 2649 + name = "tracing-core" 2650 + version = "0.1.36" 2651 + source = "registry+https://github.com/rust-lang/crates.io-index" 2652 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 2653 + dependencies = [ 2654 + "once_cell", 2655 + "valuable", 2656 + ] 2657 + 2658 + [[package]] 2659 + name = "tracing-log" 2660 + version = "0.2.0" 2661 + source = "registry+https://github.com/rust-lang/crates.io-index" 2662 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 2663 + dependencies = [ 2664 + "log", 2665 + "once_cell", 2666 + "tracing-core", 2667 + ] 2668 + 2669 + [[package]] 2670 + name = "tracing-subscriber" 2671 + version = "0.3.23" 2672 + source = "registry+https://github.com/rust-lang/crates.io-index" 2673 + checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" 2674 + dependencies = [ 2675 + "matchers", 2676 + "nu-ansi-term", 2677 + "once_cell", 2678 + "regex-automata", 2679 + "sharded-slab", 2680 + "smallvec", 2681 + "thread_local", 2682 + "tracing", 2683 + "tracing-core", 2684 + "tracing-log", 2685 + ] 2686 + 2687 + [[package]] 2688 + name = "ttf-parser" 2689 + version = "0.25.1" 2690 + source = "registry+https://github.com/rust-lang/crates.io-index" 2691 + checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" 2692 + 2693 + [[package]] 2694 + name = "typeid" 2695 + version = "1.0.3" 2696 + source = "registry+https://github.com/rust-lang/crates.io-index" 2697 + checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" 2698 + 2699 + [[package]] 2700 + name = "typenum" 2701 + version = "1.20.0" 2702 + source = "registry+https://github.com/rust-lang/crates.io-index" 2703 + checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" 2704 + 2705 + [[package]] 2706 + name = "unarray" 2707 + version = "0.1.4" 2708 + source = "registry+https://github.com/rust-lang/crates.io-index" 2709 + checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 2710 + 2711 + [[package]] 2712 + name = "unicode-ident" 2713 + version = "1.0.24" 2714 + source = "registry+https://github.com/rust-lang/crates.io-index" 2715 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 2716 + 2717 + [[package]] 2718 + name = "unicode-segmentation" 2719 + version = "1.13.2" 2720 + source = "registry+https://github.com/rust-lang/crates.io-index" 2721 + checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" 2722 + 2723 + [[package]] 2724 + name = "unicode-width" 2725 + version = "0.2.2" 2726 + source = "registry+https://github.com/rust-lang/crates.io-index" 2727 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 2728 + 2729 + [[package]] 2730 + name = "uom" 2731 + version = "0.38.0" 2732 + source = "registry+https://github.com/rust-lang/crates.io-index" 2733 + checksum = "a739f83872836c82a4f2527d4e54b37007b3de68cafe7edde95fd695968bf4b9" 2734 + dependencies = [ 2735 + "num-traits", 2736 + "typenum", 2737 + ] 2738 + 2739 + [[package]] 2740 + name = "valuable" 2741 + version = "0.1.1" 2742 + source = "registry+https://github.com/rust-lang/crates.io-index" 2743 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2744 + 2745 + [[package]] 2746 + name = "version_check" 2747 + version = "0.9.5" 2748 + source = "registry+https://github.com/rust-lang/crates.io-index" 2749 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2750 + 2751 + [[package]] 2752 + name = "walkdir" 2753 + version = "2.5.0" 2754 + source = "registry+https://github.com/rust-lang/crates.io-index" 2755 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 2756 + dependencies = [ 2757 + "same-file", 2758 + "winapi-util", 2759 + ] 2760 + 2761 + [[package]] 2762 + name = "wasip2" 2763 + version = "1.0.3+wasi-0.2.9" 2764 + source = "registry+https://github.com/rust-lang/crates.io-index" 2765 + checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" 2766 + dependencies = [ 2767 + "wit-bindgen", 2768 + ] 2769 + 2770 + [[package]] 2771 + name = "wasm-bindgen" 2772 + version = "0.2.118" 2773 + source = "registry+https://github.com/rust-lang/crates.io-index" 2774 + checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" 2775 + dependencies = [ 2776 + "cfg-if", 2777 + "once_cell", 2778 + "rustversion", 2779 + "wasm-bindgen-macro", 2780 + "wasm-bindgen-shared", 2781 + ] 2782 + 2783 + [[package]] 2784 + name = "wasm-bindgen-futures" 2785 + version = "0.4.68" 2786 + source = "registry+https://github.com/rust-lang/crates.io-index" 2787 + checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" 2788 + dependencies = [ 2789 + "js-sys", 2790 + "wasm-bindgen", 2791 + ] 2792 + 2793 + [[package]] 2794 + name = "wasm-bindgen-macro" 2795 + version = "0.2.118" 2796 + source = "registry+https://github.com/rust-lang/crates.io-index" 2797 + checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" 2798 + dependencies = [ 2799 + "quote", 2800 + "wasm-bindgen-macro-support", 2801 + ] 2802 + 2803 + [[package]] 2804 + name = "wasm-bindgen-macro-support" 2805 + version = "0.2.118" 2806 + source = "registry+https://github.com/rust-lang/crates.io-index" 2807 + checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" 2808 + dependencies = [ 2809 + "bumpalo", 2810 + "proc-macro2", 2811 + "quote", 2812 + "syn 2.0.117", 2813 + "wasm-bindgen-shared", 2814 + ] 2815 + 2816 + [[package]] 2817 + name = "wasm-bindgen-shared" 2818 + version = "0.2.118" 2819 + source = "registry+https://github.com/rust-lang/crates.io-index" 2820 + checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" 2821 + dependencies = [ 2822 + "unicode-ident", 2823 + ] 2824 + 2825 + [[package]] 2826 + name = "wayland-backend" 2827 + version = "0.3.15" 2828 + source = "registry+https://github.com/rust-lang/crates.io-index" 2829 + checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" 2830 + dependencies = [ 2831 + "cc", 2832 + "downcast-rs", 2833 + "rustix 1.1.4", 2834 + "scoped-tls", 2835 + "smallvec", 2836 + "wayland-sys", 2837 + ] 2838 + 2839 + [[package]] 2840 + name = "wayland-client" 2841 + version = "0.31.14" 2842 + source = "registry+https://github.com/rust-lang/crates.io-index" 2843 + checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" 2844 + dependencies = [ 2845 + "bitflags 2.11.1", 2846 + "rustix 1.1.4", 2847 + "wayland-backend", 2848 + "wayland-scanner", 2849 + ] 2850 + 2851 + [[package]] 2852 + name = "wayland-csd-frame" 2853 + version = "0.3.0" 2854 + source = "registry+https://github.com/rust-lang/crates.io-index" 2855 + checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" 2856 + dependencies = [ 2857 + "bitflags 2.11.1", 2858 + "cursor-icon", 2859 + "wayland-backend", 2860 + ] 2861 + 2862 + [[package]] 2863 + name = "wayland-cursor" 2864 + version = "0.31.14" 2865 + source = "registry+https://github.com/rust-lang/crates.io-index" 2866 + checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" 2867 + dependencies = [ 2868 + "rustix 1.1.4", 2869 + "wayland-client", 2870 + "xcursor", 2871 + ] 2872 + 2873 + [[package]] 2874 + name = "wayland-protocols" 2875 + version = "0.32.12" 2876 + source = "registry+https://github.com/rust-lang/crates.io-index" 2877 + checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" 2878 + dependencies = [ 2879 + "bitflags 2.11.1", 2880 + "wayland-backend", 2881 + "wayland-client", 2882 + "wayland-scanner", 2883 + ] 2884 + 2885 + [[package]] 2886 + name = "wayland-protocols-plasma" 2887 + version = "0.3.12" 2888 + source = "registry+https://github.com/rust-lang/crates.io-index" 2889 + checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" 2890 + dependencies = [ 2891 + "bitflags 2.11.1", 2892 + "wayland-backend", 2893 + "wayland-client", 2894 + "wayland-protocols", 2895 + "wayland-scanner", 2896 + ] 2897 + 2898 + [[package]] 2899 + name = "wayland-protocols-wlr" 2900 + version = "0.3.12" 2901 + source = "registry+https://github.com/rust-lang/crates.io-index" 2902 + checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" 2903 + dependencies = [ 2904 + "bitflags 2.11.1", 2905 + "wayland-backend", 2906 + "wayland-client", 2907 + "wayland-protocols", 2908 + "wayland-scanner", 2909 + ] 2910 + 2911 + [[package]] 2912 + name = "wayland-scanner" 2913 + version = "0.31.10" 2914 + source = "registry+https://github.com/rust-lang/crates.io-index" 2915 + checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" 2916 + dependencies = [ 2917 + "proc-macro2", 2918 + "quick-xml", 2919 + "quote", 2920 + ] 2921 + 2922 + [[package]] 2923 + name = "wayland-sys" 2924 + version = "0.31.11" 2925 + source = "registry+https://github.com/rust-lang/crates.io-index" 2926 + checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" 2927 + dependencies = [ 2928 + "dlib", 2929 + "log", 2930 + "once_cell", 2931 + "pkg-config", 2932 + ] 2933 + 2934 + [[package]] 2935 + name = "web-sys" 2936 + version = "0.3.95" 2937 + source = "registry+https://github.com/rust-lang/crates.io-index" 2938 + checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" 2939 + dependencies = [ 2940 + "js-sys", 2941 + "wasm-bindgen", 2942 + ] 2943 + 2944 + [[package]] 2945 + name = "web-time" 2946 + version = "1.1.0" 2947 + source = "registry+https://github.com/rust-lang/crates.io-index" 2948 + checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 2949 + dependencies = [ 2950 + "js-sys", 2951 + "wasm-bindgen", 2952 + ] 2953 + 2954 + [[package]] 2955 + name = "wgpu" 2956 + version = "29.0.1" 2957 + source = "registry+https://github.com/rust-lang/crates.io-index" 2958 + checksum = "72c239a9a747bbd379590985bac952c2e53cb19873f7072b3370c6a6a8e06837" 2959 + dependencies = [ 2960 + "arrayvec", 2961 + "bitflags 2.11.1", 2962 + "bytemuck", 2963 + "cfg-if", 2964 + "cfg_aliases", 2965 + "document-features", 2966 + "hashbrown 0.16.1", 2967 + "js-sys", 2968 + "log", 2969 + "naga", 2970 + "parking_lot", 2971 + "portable-atomic", 2972 + "profiling", 2973 + "raw-window-handle", 2974 + "smallvec", 2975 + "static_assertions", 2976 + "wasm-bindgen", 2977 + "wasm-bindgen-futures", 2978 + "web-sys", 2979 + "wgpu-core", 2980 + "wgpu-hal", 2981 + "wgpu-types", 2982 + ] 2983 + 2984 + [[package]] 2985 + name = "wgpu-core" 2986 + version = "29.0.1" 2987 + source = "registry+https://github.com/rust-lang/crates.io-index" 2988 + checksum = "1e80ac6cf1895df6342f87d975162108f9d98772a0d74bc404ab7304ac29469e" 2989 + dependencies = [ 2990 + "arrayvec", 2991 + "bit-set", 2992 + "bit-vec", 2993 + "bitflags 2.11.1", 2994 + "bytemuck", 2995 + "cfg_aliases", 2996 + "document-features", 2997 + "hashbrown 0.16.1", 2998 + "indexmap", 2999 + "log", 3000 + "naga", 3001 + "once_cell", 3002 + "parking_lot", 3003 + "portable-atomic", 3004 + "profiling", 3005 + "raw-window-handle", 3006 + "rustc-hash", 3007 + "smallvec", 3008 + "thiserror 2.0.18", 3009 + "wgpu-core-deps-apple", 3010 + "wgpu-core-deps-emscripten", 3011 + "wgpu-core-deps-windows-linux-android", 3012 + "wgpu-hal", 3013 + "wgpu-naga-bridge", 3014 + "wgpu-types", 3015 + ] 3016 + 3017 + [[package]] 3018 + name = "wgpu-core-deps-apple" 3019 + version = "29.0.0" 3020 + source = "registry+https://github.com/rust-lang/crates.io-index" 3021 + checksum = "43acd053312501689cd92a01a9638d37f3e41a5fd9534875efa8917ee2d11ac0" 3022 + dependencies = [ 3023 + "wgpu-hal", 3024 + ] 3025 + 3026 + [[package]] 3027 + name = "wgpu-core-deps-emscripten" 3028 + version = "29.0.0" 3029 + source = "registry+https://github.com/rust-lang/crates.io-index" 3030 + checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a" 3031 + dependencies = [ 3032 + "wgpu-hal", 3033 + ] 3034 + 3035 + [[package]] 3036 + name = "wgpu-core-deps-windows-linux-android" 3037 + version = "29.0.0" 3038 + source = "registry+https://github.com/rust-lang/crates.io-index" 3039 + checksum = "725d5c006a8c02967b6d93ef04f6537ec4593313e330cfe86d9d3f946eb90f28" 3040 + dependencies = [ 3041 + "wgpu-hal", 3042 + ] 3043 + 3044 + [[package]] 3045 + name = "wgpu-hal" 3046 + version = "29.0.1" 3047 + source = "registry+https://github.com/rust-lang/crates.io-index" 3048 + checksum = "89a47aef47636562f3937285af4c44b4b5b404b46577471411cc5313a921da7e" 3049 + dependencies = [ 3050 + "android_system_properties", 3051 + "arrayvec", 3052 + "ash", 3053 + "bit-set", 3054 + "bitflags 2.11.1", 3055 + "block2 0.6.2", 3056 + "bytemuck", 3057 + "cfg-if", 3058 + "cfg_aliases", 3059 + "glow", 3060 + "glutin_wgl_sys", 3061 + "gpu-allocator", 3062 + "gpu-descriptor", 3063 + "hashbrown 0.16.1", 3064 + "js-sys", 3065 + "khronos-egl", 3066 + "libc", 3067 + "libloading", 3068 + "log", 3069 + "naga", 3070 + "ndk-sys", 3071 + "objc2 0.6.4", 3072 + "objc2-core-foundation", 3073 + "objc2-foundation 0.3.2", 3074 + "objc2-metal 0.3.2", 3075 + "objc2-quartz-core 0.3.2", 3076 + "once_cell", 3077 + "ordered-float", 3078 + "parking_lot", 3079 + "portable-atomic", 3080 + "portable-atomic-util", 3081 + "profiling", 3082 + "range-alloc", 3083 + "raw-window-handle", 3084 + "raw-window-metal", 3085 + "renderdoc-sys", 3086 + "smallvec", 3087 + "thiserror 2.0.18", 3088 + "wasm-bindgen", 3089 + "wayland-sys", 3090 + "web-sys", 3091 + "wgpu-naga-bridge", 3092 + "wgpu-types", 3093 + "windows", 3094 + "windows-core", 3095 + ] 3096 + 3097 + [[package]] 3098 + name = "wgpu-naga-bridge" 3099 + version = "29.0.1" 3100 + source = "registry+https://github.com/rust-lang/crates.io-index" 3101 + checksum = "7b4684f4410da0cf95a4cb63bb5edaac022461dedb6adf0b64d0d9b5f6890d51" 3102 + dependencies = [ 3103 + "naga", 3104 + "wgpu-types", 3105 + ] 3106 + 3107 + [[package]] 3108 + name = "wgpu-types" 3109 + version = "29.0.1" 3110 + source = "registry+https://github.com/rust-lang/crates.io-index" 3111 + checksum = "ec2675540fb1a5cfa5ef122d3d5f390e2c75711a0b946410f2d6ac3a0f77d1f6" 3112 + dependencies = [ 3113 + "bitflags 2.11.1", 3114 + "bytemuck", 3115 + "js-sys", 3116 + "log", 3117 + "raw-window-handle", 3118 + "web-sys", 3119 + ] 3120 + 3121 + [[package]] 3122 + name = "wide" 3123 + version = "0.7.33" 3124 + source = "registry+https://github.com/rust-lang/crates.io-index" 3125 + checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" 3126 + dependencies = [ 3127 + "bytemuck", 3128 + "safe_arch", 3129 + ] 3130 + 3131 + [[package]] 3132 + name = "winapi-util" 3133 + version = "0.1.11" 3134 + source = "registry+https://github.com/rust-lang/crates.io-index" 3135 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 3136 + dependencies = [ 3137 + "windows-sys 0.61.2", 3138 + ] 3139 + 3140 + [[package]] 3141 + name = "windows" 3142 + version = "0.62.2" 3143 + source = "registry+https://github.com/rust-lang/crates.io-index" 3144 + checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" 3145 + dependencies = [ 3146 + "windows-collections", 3147 + "windows-core", 3148 + "windows-future", 3149 + "windows-numerics", 3150 + ] 3151 + 3152 + [[package]] 3153 + name = "windows-collections" 3154 + version = "0.3.2" 3155 + source = "registry+https://github.com/rust-lang/crates.io-index" 3156 + checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" 3157 + dependencies = [ 3158 + "windows-core", 3159 + ] 3160 + 3161 + [[package]] 3162 + name = "windows-core" 3163 + version = "0.62.2" 3164 + source = "registry+https://github.com/rust-lang/crates.io-index" 3165 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 3166 + dependencies = [ 3167 + "windows-implement", 3168 + "windows-interface", 3169 + "windows-link", 3170 + "windows-result", 3171 + "windows-strings", 3172 + ] 3173 + 3174 + [[package]] 3175 + name = "windows-future" 3176 + version = "0.3.2" 3177 + source = "registry+https://github.com/rust-lang/crates.io-index" 3178 + checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" 3179 + dependencies = [ 3180 + "windows-core", 3181 + "windows-link", 3182 + "windows-threading", 3183 + ] 3184 + 3185 + [[package]] 3186 + name = "windows-implement" 3187 + version = "0.60.2" 3188 + source = "registry+https://github.com/rust-lang/crates.io-index" 3189 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 3190 + dependencies = [ 3191 + "proc-macro2", 3192 + "quote", 3193 + "syn 2.0.117", 3194 + ] 3195 + 3196 + [[package]] 3197 + name = "windows-interface" 3198 + version = "0.59.3" 3199 + source = "registry+https://github.com/rust-lang/crates.io-index" 3200 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 3201 + dependencies = [ 3202 + "proc-macro2", 3203 + "quote", 3204 + "syn 2.0.117", 3205 + ] 3206 + 3207 + [[package]] 3208 + name = "windows-link" 3209 + version = "0.2.1" 3210 + source = "registry+https://github.com/rust-lang/crates.io-index" 3211 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 3212 + 3213 + [[package]] 3214 + name = "windows-numerics" 3215 + version = "0.3.1" 3216 + source = "registry+https://github.com/rust-lang/crates.io-index" 3217 + checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" 3218 + dependencies = [ 3219 + "windows-core", 3220 + "windows-link", 3221 + ] 3222 + 3223 + [[package]] 3224 + name = "windows-result" 3225 + version = "0.4.1" 3226 + source = "registry+https://github.com/rust-lang/crates.io-index" 3227 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 3228 + dependencies = [ 3229 + "windows-link", 3230 + ] 3231 + 3232 + [[package]] 3233 + name = "windows-strings" 3234 + version = "0.5.1" 3235 + source = "registry+https://github.com/rust-lang/crates.io-index" 3236 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 3237 + dependencies = [ 3238 + "windows-link", 3239 + ] 3240 + 3241 + [[package]] 3242 + name = "windows-sys" 3243 + version = "0.52.0" 3244 + source = "registry+https://github.com/rust-lang/crates.io-index" 3245 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 3246 + dependencies = [ 3247 + "windows-targets", 3248 + ] 3249 + 3250 + [[package]] 3251 + name = "windows-sys" 3252 + version = "0.59.0" 3253 + source = "registry+https://github.com/rust-lang/crates.io-index" 3254 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 3255 + dependencies = [ 3256 + "windows-targets", 3257 + ] 3258 + 3259 + [[package]] 3260 + name = "windows-sys" 3261 + version = "0.61.2" 3262 + source = "registry+https://github.com/rust-lang/crates.io-index" 3263 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 3264 + dependencies = [ 3265 + "windows-link", 3266 + ] 3267 + 3268 + [[package]] 3269 + name = "windows-targets" 3270 + version = "0.52.6" 3271 + source = "registry+https://github.com/rust-lang/crates.io-index" 3272 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 3273 + dependencies = [ 3274 + "windows_aarch64_gnullvm", 3275 + "windows_aarch64_msvc", 3276 + "windows_i686_gnu", 3277 + "windows_i686_gnullvm", 3278 + "windows_i686_msvc", 3279 + "windows_x86_64_gnu", 3280 + "windows_x86_64_gnullvm", 3281 + "windows_x86_64_msvc", 3282 + ] 3283 + 3284 + [[package]] 3285 + name = "windows-threading" 3286 + version = "0.2.1" 3287 + source = "registry+https://github.com/rust-lang/crates.io-index" 3288 + checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" 3289 + dependencies = [ 3290 + "windows-link", 3291 + ] 3292 + 3293 + [[package]] 3294 + name = "windows_aarch64_gnullvm" 3295 + version = "0.52.6" 3296 + source = "registry+https://github.com/rust-lang/crates.io-index" 3297 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 3298 + 3299 + [[package]] 3300 + name = "windows_aarch64_msvc" 3301 + version = "0.52.6" 3302 + source = "registry+https://github.com/rust-lang/crates.io-index" 3303 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 3304 + 3305 + [[package]] 3306 + name = "windows_i686_gnu" 3307 + version = "0.52.6" 3308 + source = "registry+https://github.com/rust-lang/crates.io-index" 3309 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 3310 + 3311 + [[package]] 3312 + name = "windows_i686_gnullvm" 3313 + version = "0.52.6" 3314 + source = "registry+https://github.com/rust-lang/crates.io-index" 3315 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 3316 + 3317 + [[package]] 3318 + name = "windows_i686_msvc" 3319 + version = "0.52.6" 3320 + source = "registry+https://github.com/rust-lang/crates.io-index" 3321 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 3322 + 3323 + [[package]] 3324 + name = "windows_x86_64_gnu" 3325 + version = "0.52.6" 3326 + source = "registry+https://github.com/rust-lang/crates.io-index" 3327 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 3328 + 3329 + [[package]] 3330 + name = "windows_x86_64_gnullvm" 3331 + version = "0.52.6" 3332 + source = "registry+https://github.com/rust-lang/crates.io-index" 3333 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 3334 + 3335 + [[package]] 3336 + name = "windows_x86_64_msvc" 3337 + version = "0.52.6" 3338 + source = "registry+https://github.com/rust-lang/crates.io-index" 3339 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 3340 + 3341 + [[package]] 3342 + name = "winit" 3343 + version = "0.30.13" 3344 + source = "registry+https://github.com/rust-lang/crates.io-index" 3345 + checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" 3346 + dependencies = [ 3347 + "ahash", 3348 + "android-activity", 3349 + "atomic-waker", 3350 + "bitflags 2.11.1", 3351 + "block2 0.5.1", 3352 + "bytemuck", 3353 + "calloop", 3354 + "cfg_aliases", 3355 + "concurrent-queue", 3356 + "core-foundation", 3357 + "core-graphics", 3358 + "cursor-icon", 3359 + "dpi", 3360 + "js-sys", 3361 + "libc", 3362 + "memmap2", 3363 + "ndk", 3364 + "objc2 0.5.2", 3365 + "objc2-app-kit", 3366 + "objc2-foundation 0.2.2", 3367 + "objc2-ui-kit", 3368 + "orbclient", 3369 + "percent-encoding", 3370 + "pin-project", 3371 + "raw-window-handle", 3372 + "redox_syscall 0.4.1", 3373 + "rustix 0.38.44", 3374 + "sctk-adwaita", 3375 + "smithay-client-toolkit", 3376 + "smol_str", 3377 + "tracing", 3378 + "unicode-segmentation", 3379 + "wasm-bindgen", 3380 + "wasm-bindgen-futures", 3381 + "wayland-backend", 3382 + "wayland-client", 3383 + "wayland-protocols", 3384 + "wayland-protocols-plasma", 3385 + "web-sys", 3386 + "web-time", 3387 + "windows-sys 0.52.0", 3388 + "x11-dl", 3389 + "x11rb", 3390 + "xkbcommon-dl", 3391 + ] 3392 + 3393 + [[package]] 3394 + name = "winnow" 3395 + version = "1.0.1" 3396 + source = "registry+https://github.com/rust-lang/crates.io-index" 3397 + checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" 3398 + dependencies = [ 3399 + "memchr", 3400 + ] 3401 + 3402 + [[package]] 3403 + name = "wit-bindgen" 3404 + version = "0.57.1" 3405 + source = "registry+https://github.com/rust-lang/crates.io-index" 3406 + checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 3407 + 3408 + [[package]] 3409 + name = "x11-dl" 3410 + version = "2.21.0" 3411 + source = "registry+https://github.com/rust-lang/crates.io-index" 3412 + checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" 3413 + dependencies = [ 3414 + "libc", 3415 + "once_cell", 3416 + "pkg-config", 3417 + ] 3418 + 3419 + [[package]] 3420 + name = "x11rb" 3421 + version = "0.13.2" 3422 + source = "registry+https://github.com/rust-lang/crates.io-index" 3423 + checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" 3424 + dependencies = [ 3425 + "as-raw-xcb-connection", 3426 + "gethostname", 3427 + "libc", 3428 + "libloading", 3429 + "once_cell", 3430 + "rustix 1.1.4", 3431 + "x11rb-protocol", 3432 + ] 3433 + 3434 + [[package]] 3435 + name = "x11rb-protocol" 3436 + version = "0.13.2" 3437 + source = "registry+https://github.com/rust-lang/crates.io-index" 3438 + checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" 3439 + 3440 + [[package]] 3441 + name = "xcursor" 3442 + version = "0.3.10" 3443 + source = "registry+https://github.com/rust-lang/crates.io-index" 3444 + checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" 3445 + 3446 + [[package]] 3447 + name = "xkbcommon-dl" 3448 + version = "0.4.2" 3449 + source = "registry+https://github.com/rust-lang/crates.io-index" 3450 + checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" 3451 + dependencies = [ 3452 + "bitflags 2.11.1", 3453 + "dlib", 3454 + "log", 3455 + "once_cell", 3456 + "xkeysym", 3457 + ] 3458 + 3459 + [[package]] 3460 + name = "xkeysym" 3461 + version = "0.2.1" 3462 + source = "registry+https://github.com/rust-lang/crates.io-index" 3463 + checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" 3464 + 3465 + [[package]] 3466 + name = "xml-rs" 3467 + version = "0.8.28" 3468 + source = "registry+https://github.com/rust-lang/crates.io-index" 3469 + checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" 3470 + 3471 + [[package]] 3472 + name = "zerocopy" 3473 + version = "0.8.48" 3474 + source = "registry+https://github.com/rust-lang/crates.io-index" 3475 + checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" 3476 + dependencies = [ 3477 + "zerocopy-derive", 3478 + ] 3479 + 3480 + [[package]] 3481 + name = "zerocopy-derive" 3482 + version = "0.8.48" 3483 + source = "registry+https://github.com/rust-lang/crates.io-index" 3484 + checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" 3485 + dependencies = [ 3486 + "proc-macro2", 3487 + "quote", 3488 + "syn 2.0.117", 3489 + ]
+59
Cargo.toml
··· 1 + [workspace] 2 + resolver = "3" 3 + members = [ 4 + "crates/bone-types", 5 + "crates/bone-kernel", 6 + "crates/bone-solver", 7 + "crates/bone-document", 8 + "crates/bone-render", 9 + "crates/bone-app", 10 + ] 11 + 12 + [workspace.package] 13 + version = "0.0.0" 14 + edition = "2024" 15 + license = "AGPL-3.0-or-later" 16 + rust-version = "1.95" 17 + 18 + [workspace.lints.rust] 19 + unsafe_code = "forbid" 20 + 21 + [workspace.lints.clippy] 22 + all = { level = "deny", priority = -1 } 23 + pedantic = { level = "warn", priority = -1 } 24 + unwrap_used = "deny" 25 + expect_used = "deny" 26 + missing_panics_doc = "allow" 27 + missing_errors_doc = "allow" 28 + 29 + [workspace.dependencies] 30 + bone-types = { path = "crates/bone-types" } 31 + bone-kernel = { path = "crates/bone-kernel" } 32 + bone-solver = { path = "crates/bone-solver" } 33 + bone-document = { path = "crates/bone-document" } 34 + bone-render = { path = "crates/bone-render" } 35 + 36 + blake3 = { version = "1", default-features = false, features = ["std"] } 37 + bytemuck = { version = "1", default-features = false, features = ["derive"] } 38 + faer = { version = "0.24", default-features = false, features = ["std"] } 39 + insta = "1" 40 + nalgebra = { version = "0.33", default-features = false, features = ["std"] } 41 + png = { version = "0.17", default-features = false } 42 + pollster = "0.4" 43 + proptest = { version = "1", default-features = false, features = ["std"] } 44 + ron = "0.12" 45 + serde = { version = "1", default-features = false, features = ["std", "derive", "rc"] } 46 + slotmap = { version = "1", default-features = false, features = ["std", "serde"] } 47 + tempfile = "3" 48 + thiserror = "2" 49 + tracing = "0.1" 50 + tracing-subscriber = { version = "0.3", features = ["env-filter"] } 51 + uom = { version = "0.38", default-features = false, features = ["f64", "si", "std", "autoconvert"] } 52 + wgpu = "29" 53 + winit = "0.30" 54 + 55 + [profile.release] 56 + lto = "fat" 57 + strip = true 58 + codegen-units = 1 59 + panic = "abort"
+3
LICENSE
··· 1 + Everything in this repository is licensed under AGPLv3 or later, unless a file says otherwise at its top. 2 + 3 + Full licence text: LICENSE-AGPL-3.0-or-later
+661
LICENSE-AGPL-3.0-or-later
··· 1 + GNU AFFERO GENERAL PUBLIC LICENSE 2 + Version 3, 19 November 2007 3 + 4 + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> 5 + Everyone is permitted to copy and distribute verbatim copies 6 + of this license document, but changing it is not allowed. 7 + 8 + Preamble 9 + 10 + The GNU Affero General Public License is a free, copyleft license for 11 + software and other kinds of works, specifically designed to ensure 12 + cooperation with the community in the case of network server software. 13 + 14 + The licenses for most software and other practical works are designed 15 + to take away your freedom to share and change the works. By contrast, 16 + our General Public Licenses are intended to guarantee your freedom to 17 + share and change all versions of a program--to make sure it remains free 18 + software for all its users. 19 + 20 + When we speak of free software, we are referring to freedom, not 21 + price. Our General Public Licenses are designed to make sure that you 22 + have the freedom to distribute copies of free software (and charge for 23 + them if you wish), that you receive source code or can get it if you 24 + want it, that you can change the software or use pieces of it in new 25 + free programs, and that you know you can do these things. 26 + 27 + Developers that use our General Public Licenses protect your rights 28 + with two steps: (1) assert copyright on the software, and (2) offer 29 + you this License which gives you legal permission to copy, distribute 30 + and/or modify the software. 31 + 32 + A secondary benefit of defending all users' freedom is that 33 + improvements made in alternate versions of the program, if they 34 + receive widespread use, become available for other developers to 35 + incorporate. Many developers of free software are heartened and 36 + encouraged by the resulting cooperation. However, in the case of 37 + software used on network servers, this result may fail to come about. 38 + The GNU General Public License permits making a modified version and 39 + letting the public access it on a server without ever releasing its 40 + source code to the public. 41 + 42 + The GNU Affero General Public License is designed specifically to 43 + ensure that, in such cases, the modified source code becomes available 44 + to the community. It requires the operator of a network server to 45 + provide the source code of the modified version running there to the 46 + users of that server. Therefore, public use of a modified version, on 47 + a publicly accessible server, gives the public access to the source 48 + code of the modified version. 49 + 50 + An older license, called the Affero General Public License and 51 + published by Affero, was designed to accomplish similar goals. This is 52 + a different license, not a version of the Affero GPL, but Affero has 53 + released a new version of the Affero GPL which permits relicensing under 54 + this license. 55 + 56 + The precise terms and conditions for copying, distribution and 57 + modification follow. 58 + 59 + TERMS AND CONDITIONS 60 + 61 + 0. Definitions. 62 + 63 + "This License" refers to version 3 of the GNU Affero General Public License. 64 + 65 + "Copyright" also means copyright-like laws that apply to other kinds of 66 + works, such as semiconductor masks. 67 + 68 + "The Program" refers to any copyrightable work licensed under this 69 + License. Each licensee is addressed as "you". "Licensees" and 70 + "recipients" may be individuals or organizations. 71 + 72 + To "modify" a work means to copy from or adapt all or part of the work 73 + in a fashion requiring copyright permission, other than the making of an 74 + exact copy. The resulting work is called a "modified version" of the 75 + earlier work or a work "based on" the earlier work. 76 + 77 + A "covered work" means either the unmodified Program or a work based 78 + on the Program. 79 + 80 + To "propagate" a work means to do anything with it that, without 81 + permission, would make you directly or secondarily liable for 82 + infringement under applicable copyright law, except executing it on a 83 + computer or modifying a private copy. Propagation includes copying, 84 + distribution (with or without modification), making available to the 85 + public, and in some countries other activities as well. 86 + 87 + To "convey" a work means any kind of propagation that enables other 88 + parties to make or receive copies. Mere interaction with a user through 89 + a computer network, with no transfer of a copy, is not conveying. 90 + 91 + An interactive user interface displays "Appropriate Legal Notices" 92 + to the extent that it includes a convenient and prominently visible 93 + feature that (1) displays an appropriate copyright notice, and (2) 94 + tells the user that there is no warranty for the work (except to the 95 + extent that warranties are provided), that licensees may convey the 96 + work under this License, and how to view a copy of this License. If 97 + the interface presents a list of user commands or options, such as a 98 + menu, a prominent item in the list meets this criterion. 99 + 100 + 1. Source Code. 101 + 102 + The "source code" for a work means the preferred form of the work 103 + for making modifications to it. "Object code" means any non-source 104 + form of a work. 105 + 106 + A "Standard Interface" means an interface that either is an official 107 + standard defined by a recognized standards body, or, in the case of 108 + interfaces specified for a particular programming language, one that 109 + is widely used among developers working in that language. 110 + 111 + The "System Libraries" of an executable work include anything, other 112 + than the work as a whole, that (a) is included in the normal form of 113 + packaging a Major Component, but which is not part of that Major 114 + Component, and (b) serves only to enable use of the work with that 115 + Major Component, or to implement a Standard Interface for which an 116 + implementation is available to the public in source code form. A 117 + "Major Component", in this context, means a major essential component 118 + (kernel, window system, and so on) of the specific operating system 119 + (if any) on which the executable work runs, or a compiler used to 120 + produce the work, or an object code interpreter used to run it. 121 + 122 + The "Corresponding Source" for a work in object code form means all 123 + the source code needed to generate, install, and (for an executable 124 + work) run the object code and to modify the work, including scripts to 125 + control those activities. However, it does not include the work's 126 + System Libraries, or general-purpose tools or generally available free 127 + programs which are used unmodified in performing those activities but 128 + which are not part of the work. For example, Corresponding Source 129 + includes interface definition files associated with source files for 130 + the work, and the source code for shared libraries and dynamically 131 + linked subprograms that the work is specifically designed to require, 132 + such as by intimate data communication or control flow between those 133 + subprograms and other parts of the work. 134 + 135 + The Corresponding Source need not include anything that users 136 + can regenerate automatically from other parts of the Corresponding 137 + Source. 138 + 139 + The Corresponding Source for a work in source code form is that 140 + same work. 141 + 142 + 2. Basic Permissions. 143 + 144 + All rights granted under this License are granted for the term of 145 + copyright on the Program, and are irrevocable provided the stated 146 + conditions are met. This License explicitly affirms your unlimited 147 + permission to run the unmodified Program. The output from running a 148 + covered work is covered by this License only if the output, given its 149 + content, constitutes a covered work. This License acknowledges your 150 + rights of fair use or other equivalent, as provided by copyright law. 151 + 152 + You may make, run and propagate covered works that you do not 153 + convey, without conditions so long as your license otherwise remains 154 + in force. You may convey covered works to others for the sole purpose 155 + of having them make modifications exclusively for you, or provide you 156 + with facilities for running those works, provided that you comply with 157 + the terms of this License in conveying all material for which you do 158 + not control copyright. Those thus making or running the covered works 159 + for you must do so exclusively on your behalf, under your direction 160 + and control, on terms that prohibit them from making any copies of 161 + your copyrighted material outside their relationship with you. 162 + 163 + Conveying under any other circumstances is permitted solely under 164 + the conditions stated below. Sublicensing is not allowed; section 10 165 + makes it unnecessary. 166 + 167 + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 + 169 + No covered work shall be deemed part of an effective technological 170 + measure under any applicable law fulfilling obligations under article 171 + 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 + similar laws prohibiting or restricting circumvention of such 173 + measures. 174 + 175 + When you convey a covered work, you waive any legal power to forbid 176 + circumvention of technological measures to the extent such circumvention 177 + is effected by exercising rights under this License with respect to 178 + the covered work, and you disclaim any intention to limit operation or 179 + modification of the work as a means of enforcing, against the work's 180 + users, your or third parties' legal rights to forbid circumvention of 181 + technological measures. 182 + 183 + 4. Conveying Verbatim Copies. 184 + 185 + You may convey verbatim copies of the Program's source code as you 186 + receive it, in any medium, provided that you conspicuously and 187 + appropriately publish on each copy an appropriate copyright notice; 188 + keep intact all notices stating that this License and any 189 + non-permissive terms added in accord with section 7 apply to the code; 190 + keep intact all notices of the absence of any warranty; and give all 191 + recipients a copy of this License along with the Program. 192 + 193 + You may charge any price or no price for each copy that you convey, 194 + and you may offer support or warranty protection for a fee. 195 + 196 + 5. Conveying Modified Source Versions. 197 + 198 + You may convey a work based on the Program, or the modifications to 199 + produce it from the Program, in the form of source code under the 200 + terms of section 4, provided that you also meet all of these conditions: 201 + 202 + a) The work must carry prominent notices stating that you modified 203 + it, and giving a relevant date. 204 + 205 + b) The work must carry prominent notices stating that it is 206 + released under this License and any conditions added under section 207 + 7. This requirement modifies the requirement in section 4 to 208 + "keep intact all notices". 209 + 210 + c) You must license the entire work, as a whole, under this 211 + License to anyone who comes into possession of a copy. This 212 + License will therefore apply, along with any applicable section 7 213 + additional terms, to the whole of the work, and all its parts, 214 + regardless of how they are packaged. This License gives no 215 + permission to license the work in any other way, but it does not 216 + invalidate such permission if you have separately received it. 217 + 218 + d) If the work has interactive user interfaces, each must display 219 + Appropriate Legal Notices; however, if the Program has interactive 220 + interfaces that do not display Appropriate Legal Notices, your 221 + work need not make them do so. 222 + 223 + A compilation of a covered work with other separate and independent 224 + works, which are not by their nature extensions of the covered work, 225 + and which are not combined with it such as to form a larger program, 226 + in or on a volume of a storage or distribution medium, is called an 227 + "aggregate" if the compilation and its resulting copyright are not 228 + used to limit the access or legal rights of the compilation's users 229 + beyond what the individual works permit. Inclusion of a covered work 230 + in an aggregate does not cause this License to apply to the other 231 + parts of the aggregate. 232 + 233 + 6. Conveying Non-Source Forms. 234 + 235 + You may convey a covered work in object code form under the terms 236 + of sections 4 and 5, provided that you also convey the 237 + machine-readable Corresponding Source under the terms of this License, 238 + in one of these ways: 239 + 240 + a) Convey the object code in, or embodied in, a physical product 241 + (including a physical distribution medium), accompanied by the 242 + Corresponding Source fixed on a durable physical medium 243 + customarily used for software interchange. 244 + 245 + b) Convey the object code in, or embodied in, a physical product 246 + (including a physical distribution medium), accompanied by a 247 + written offer, valid for at least three years and valid for as 248 + long as you offer spare parts or customer support for that product 249 + model, to give anyone who possesses the object code either (1) a 250 + copy of the Corresponding Source for all the software in the 251 + product that is covered by this License, on a durable physical 252 + medium customarily used for software interchange, for a price no 253 + more than your reasonable cost of physically performing this 254 + conveying of source, or (2) access to copy the 255 + Corresponding Source from a network server at no charge. 256 + 257 + c) Convey individual copies of the object code with a copy of the 258 + written offer to provide the Corresponding Source. This 259 + alternative is allowed only occasionally and noncommercially, and 260 + only if you received the object code with such an offer, in accord 261 + with subsection 6b. 262 + 263 + d) Convey the object code by offering access from a designated 264 + place (gratis or for a charge), and offer equivalent access to the 265 + Corresponding Source in the same way through the same place at no 266 + further charge. You need not require recipients to copy the 267 + Corresponding Source along with the object code. If the place to 268 + copy the object code is a network server, the Corresponding Source 269 + may be on a different server (operated by you or a third party) 270 + that supports equivalent copying facilities, provided you maintain 271 + clear directions next to the object code saying where to find the 272 + Corresponding Source. Regardless of what server hosts the 273 + Corresponding Source, you remain obligated to ensure that it is 274 + available for as long as needed to satisfy these requirements. 275 + 276 + e) Convey the object code using peer-to-peer transmission, provided 277 + you inform other peers where the object code and Corresponding 278 + Source of the work are being offered to the general public at no 279 + charge under subsection 6d. 280 + 281 + A separable portion of the object code, whose source code is excluded 282 + from the Corresponding Source as a System Library, need not be 283 + included in conveying the object code work. 284 + 285 + A "User Product" is either (1) a "consumer product", which means any 286 + tangible personal property which is normally used for personal, family, 287 + or household purposes, or (2) anything designed or sold for incorporation 288 + into a dwelling. In determining whether a product is a consumer product, 289 + doubtful cases shall be resolved in favor of coverage. For a particular 290 + product received by a particular user, "normally used" refers to a 291 + typical or common use of that class of product, regardless of the status 292 + of the particular user or of the way in which the particular user 293 + actually uses, or expects or is expected to use, the product. A product 294 + is a consumer product regardless of whether the product has substantial 295 + commercial, industrial or non-consumer uses, unless such uses represent 296 + the only significant mode of use of the product. 297 + 298 + "Installation Information" for a User Product means any methods, 299 + procedures, authorization keys, or other information required to install 300 + and execute modified versions of a covered work in that User Product from 301 + a modified version of its Corresponding Source. The information must 302 + suffice to ensure that the continued functioning of the modified object 303 + code is in no case prevented or interfered with solely because 304 + modification has been made. 305 + 306 + If you convey an object code work under this section in, or with, or 307 + specifically for use in, a User Product, and the conveying occurs as 308 + part of a transaction in which the right of possession and use of the 309 + User Product is transferred to the recipient in perpetuity or for a 310 + fixed term (regardless of how the transaction is characterized), the 311 + Corresponding Source conveyed under this section must be accompanied 312 + by the Installation Information. But this requirement does not apply 313 + if neither you nor any third party retains the ability to install 314 + modified object code on the User Product (for example, the work has 315 + been installed in ROM). 316 + 317 + The requirement to provide Installation Information does not include a 318 + requirement to continue to provide support service, warranty, or updates 319 + for a work that has been modified or installed by the recipient, or for 320 + the User Product in which it has been modified or installed. Access to a 321 + network may be denied when the modification itself materially and 322 + adversely affects the operation of the network or violates the rules and 323 + protocols for communication across the network. 324 + 325 + Corresponding Source conveyed, and Installation Information provided, 326 + in accord with this section must be in a format that is publicly 327 + documented (and with an implementation available to the public in 328 + source code form), and must require no special password or key for 329 + unpacking, reading or copying. 330 + 331 + 7. Additional Terms. 332 + 333 + "Additional permissions" are terms that supplement the terms of this 334 + License by making exceptions from one or more of its conditions. 335 + Additional permissions that are applicable to the entire Program shall 336 + be treated as though they were included in this License, to the extent 337 + that they are valid under applicable law. If additional permissions 338 + apply only to part of the Program, that part may be used separately 339 + under those permissions, but the entire Program remains governed by 340 + this License without regard to the additional permissions. 341 + 342 + When you convey a copy of a covered work, you may at your option 343 + remove any additional permissions from that copy, or from any part of 344 + it. (Additional permissions may be written to require their own 345 + removal in certain cases when you modify the work.) You may place 346 + additional permissions on material, added by you to a covered work, 347 + for which you have or can give appropriate copyright permission. 348 + 349 + Notwithstanding any other provision of this License, for material you 350 + add to a covered work, you may (if authorized by the copyright holders of 351 + that material) supplement the terms of this License with terms: 352 + 353 + a) Disclaiming warranty or limiting liability differently from the 354 + terms of sections 15 and 16 of this License; or 355 + 356 + b) Requiring preservation of specified reasonable legal notices or 357 + author attributions in that material or in the Appropriate Legal 358 + Notices displayed by works containing it; or 359 + 360 + c) Prohibiting misrepresentation of the origin of that material, or 361 + requiring that modified versions of such material be marked in 362 + reasonable ways as different from the original version; or 363 + 364 + d) Limiting the use for publicity purposes of names of licensors or 365 + authors of the material; or 366 + 367 + e) Declining to grant rights under trademark law for use of some 368 + trade names, trademarks, or service marks; or 369 + 370 + f) Requiring indemnification of licensors and authors of that 371 + material by anyone who conveys the material (or modified versions of 372 + it) with contractual assumptions of liability to the recipient, for 373 + any liability that these contractual assumptions directly impose on 374 + those licensors and authors. 375 + 376 + All other non-permissive additional terms are considered "further 377 + restrictions" within the meaning of section 10. If the Program as you 378 + received it, or any part of it, contains a notice stating that it is 379 + governed by this License along with a term that is a further 380 + restriction, you may remove that term. If a license document contains 381 + a further restriction but permits relicensing or conveying under this 382 + License, you may add to a covered work material governed by the terms 383 + of that license document, provided that the further restriction does 384 + not survive such relicensing or conveying. 385 + 386 + If you add terms to a covered work in accord with this section, you 387 + must place, in the relevant source files, a statement of the 388 + additional terms that apply to those files, or a notice indicating 389 + where to find the applicable terms. 390 + 391 + Additional terms, permissive or non-permissive, may be stated in the 392 + form of a separately written license, or stated as exceptions; 393 + the above requirements apply either way. 394 + 395 + 8. Termination. 396 + 397 + You may not propagate or modify a covered work except as expressly 398 + provided under this License. Any attempt otherwise to propagate or 399 + modify it is void, and will automatically terminate your rights under 400 + this License (including any patent licenses granted under the third 401 + paragraph of section 11). 402 + 403 + However, if you cease all violation of this License, then your 404 + license from a particular copyright holder is reinstated (a) 405 + provisionally, unless and until the copyright holder explicitly and 406 + finally terminates your license, and (b) permanently, if the copyright 407 + holder fails to notify you of the violation by some reasonable means 408 + prior to 60 days after the cessation. 409 + 410 + Moreover, your license from a particular copyright holder is 411 + reinstated permanently if the copyright holder notifies you of the 412 + violation by some reasonable means, this is the first time you have 413 + received notice of violation of this License (for any work) from that 414 + copyright holder, and you cure the violation prior to 30 days after 415 + your receipt of the notice. 416 + 417 + Termination of your rights under this section does not terminate the 418 + licenses of parties who have received copies or rights from you under 419 + this License. If your rights have been terminated and not permanently 420 + reinstated, you do not qualify to receive new licenses for the same 421 + material under section 10. 422 + 423 + 9. Acceptance Not Required for Having Copies. 424 + 425 + You are not required to accept this License in order to receive or 426 + run a copy of the Program. Ancillary propagation of a covered work 427 + occurring solely as a consequence of using peer-to-peer transmission 428 + to receive a copy likewise does not require acceptance. However, 429 + nothing other than this License grants you permission to propagate or 430 + modify any covered work. These actions infringe copyright if you do 431 + not accept this License. Therefore, by modifying or propagating a 432 + covered work, you indicate your acceptance of this License to do so. 433 + 434 + 10. Automatic Licensing of Downstream Recipients. 435 + 436 + Each time you convey a covered work, the recipient automatically 437 + receives a license from the original licensors, to run, modify and 438 + propagate that work, subject to this License. You are not responsible 439 + for enforcing compliance by third parties with this License. 440 + 441 + An "entity transaction" is a transaction transferring control of an 442 + organization, or substantially all assets of one, or subdividing an 443 + organization, or merging organizations. If propagation of a covered 444 + work results from an entity transaction, each party to that 445 + transaction who receives a copy of the work also receives whatever 446 + licenses to the work the party's predecessor in interest had or could 447 + give under the previous paragraph, plus a right to possession of the 448 + Corresponding Source of the work from the predecessor in interest, if 449 + the predecessor has it or can get it with reasonable efforts. 450 + 451 + You may not impose any further restrictions on the exercise of the 452 + rights granted or affirmed under this License. For example, you may 453 + not impose a license fee, royalty, or other charge for exercise of 454 + rights granted under this License, and you may not initiate litigation 455 + (including a cross-claim or counterclaim in a lawsuit) alleging that 456 + any patent claim is infringed by making, using, selling, offering for 457 + sale, or importing the Program or any portion of it. 458 + 459 + 11. Patents. 460 + 461 + A "contributor" is a copyright holder who authorizes use under this 462 + License of the Program or a work on which the Program is based. The 463 + work thus licensed is called the contributor's "contributor version". 464 + 465 + A contributor's "essential patent claims" are all patent claims 466 + owned or controlled by the contributor, whether already acquired or 467 + hereafter acquired, that would be infringed by some manner, permitted 468 + by this License, of making, using, or selling its contributor version, 469 + but do not include claims that would be infringed only as a 470 + consequence of further modification of the contributor version. For 471 + purposes of this definition, "control" includes the right to grant 472 + patent sublicenses in a manner consistent with the requirements of 473 + this License. 474 + 475 + Each contributor grants you a non-exclusive, worldwide, royalty-free 476 + patent license under the contributor's essential patent claims, to 477 + make, use, sell, offer for sale, import and otherwise run, modify and 478 + propagate the contents of its contributor version. 479 + 480 + In the following three paragraphs, a "patent license" is any express 481 + agreement or commitment, however denominated, not to enforce a patent 482 + (such as an express permission to practice a patent or covenant not to 483 + sue for patent infringement). To "grant" such a patent license to a 484 + party means to make such an agreement or commitment not to enforce a 485 + patent against the party. 486 + 487 + If you convey a covered work, knowingly relying on a patent license, 488 + and the Corresponding Source of the work is not available for anyone 489 + to copy, free of charge and under the terms of this License, through a 490 + publicly available network server or other readily accessible means, 491 + then you must either (1) cause the Corresponding Source to be so 492 + available, or (2) arrange to deprive yourself of the benefit of the 493 + patent license for this particular work, or (3) arrange, in a manner 494 + consistent with the requirements of this License, to extend the patent 495 + license to downstream recipients. "Knowingly relying" means you have 496 + actual knowledge that, but for the patent license, your conveying the 497 + covered work in a country, or your recipient's use of the covered work 498 + in a country, would infringe one or more identifiable patents in that 499 + country that you have reason to believe are valid. 500 + 501 + If, pursuant to or in connection with a single transaction or 502 + arrangement, you convey, or propagate by procuring conveyance of, a 503 + covered work, and grant a patent license to some of the parties 504 + receiving the covered work authorizing them to use, propagate, modify 505 + or convey a specific copy of the covered work, then the patent license 506 + you grant is automatically extended to all recipients of the covered 507 + work and works based on it. 508 + 509 + A patent license is "discriminatory" if it does not include within 510 + the scope of its coverage, prohibits the exercise of, or is 511 + conditioned on the non-exercise of one or more of the rights that are 512 + specifically granted under this License. You may not convey a covered 513 + work if you are a party to an arrangement with a third party that is 514 + in the business of distributing software, under which you make payment 515 + to the third party based on the extent of your activity of conveying 516 + the work, and under which the third party grants, to any of the 517 + parties who would receive the covered work from you, a discriminatory 518 + patent license (a) in connection with copies of the covered work 519 + conveyed by you (or copies made from those copies), or (b) primarily 520 + for and in connection with specific products or compilations that 521 + contain the covered work, unless you entered into that arrangement, 522 + or that patent license was granted, prior to 28 March 2007. 523 + 524 + Nothing in this License shall be construed as excluding or limiting 525 + any implied license or other defenses to infringement that may 526 + otherwise be available to you under applicable patent law. 527 + 528 + 12. No Surrender of Others' Freedom. 529 + 530 + If conditions are imposed on you (whether by court order, agreement or 531 + otherwise) that contradict the conditions of this License, they do not 532 + excuse you from the conditions of this License. If you cannot convey a 533 + covered work so as to satisfy simultaneously your obligations under this 534 + License and any other pertinent obligations, then as a consequence you may 535 + not convey it at all. For example, if you agree to terms that obligate you 536 + to collect a royalty for further conveying from those to whom you convey 537 + the Program, the only way you could satisfy both those terms and this 538 + License would be to refrain entirely from conveying the Program. 539 + 540 + 13. Remote Network Interaction; Use with the GNU General Public License. 541 + 542 + Notwithstanding any other provision of this License, if you modify the 543 + Program, your modified version must prominently offer all users 544 + interacting with it remotely through a computer network (if your version 545 + supports such interaction) an opportunity to receive the Corresponding 546 + Source of your version by providing access to the Corresponding Source 547 + from a network server at no charge, through some standard or customary 548 + means of facilitating copying of software. This Corresponding Source 549 + shall include the Corresponding Source for any work covered by version 3 550 + of the GNU General Public License that is incorporated pursuant to the 551 + following paragraph. 552 + 553 + Notwithstanding any other provision of this License, you have 554 + permission to link or combine any covered work with a work licensed 555 + under version 3 of the GNU General Public License into a single 556 + combined work, and to convey the resulting work. The terms of this 557 + License will continue to apply to the part which is the covered work, 558 + but the work with which it is combined will remain governed by version 559 + 3 of the GNU General Public License. 560 + 561 + 14. Revised Versions of this License. 562 + 563 + The Free Software Foundation may publish revised and/or new versions of 564 + the GNU Affero General Public License from time to time. Such new versions 565 + will be similar in spirit to the present version, but may differ in detail to 566 + address new problems or concerns. 567 + 568 + Each version is given a distinguishing version number. If the 569 + Program specifies that a certain numbered version of the GNU Affero General 570 + Public License "or any later version" applies to it, you have the 571 + option of following the terms and conditions either of that numbered 572 + version or of any later version published by the Free Software 573 + Foundation. If the Program does not specify a version number of the 574 + GNU Affero General Public License, you may choose any version ever published 575 + by the Free Software Foundation. 576 + 577 + If the Program specifies that a proxy can decide which future 578 + versions of the GNU Affero General Public License can be used, that proxy's 579 + public statement of acceptance of a version permanently authorizes you 580 + to choose that version for the Program. 581 + 582 + Later license versions may give you additional or different 583 + permissions. However, no additional obligations are imposed on any 584 + author or copyright holder as a result of your choosing to follow a 585 + later version. 586 + 587 + 15. Disclaimer of Warranty. 588 + 589 + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 + 598 + 16. Limitation of Liability. 599 + 600 + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 + SUCH DAMAGES. 609 + 610 + 17. Interpretation of Sections 15 and 16. 611 + 612 + If the disclaimer of warranty and limitation of liability provided 613 + above cannot be given local legal effect according to their terms, 614 + reviewing courts shall apply local law that most closely approximates 615 + an absolute waiver of all civil liability in connection with the 616 + Program, unless a warranty or assumption of liability accompanies a 617 + copy of the Program in return for a fee. 618 + 619 + END OF TERMS AND CONDITIONS 620 + 621 + How to Apply These Terms to Your New Programs 622 + 623 + If you develop a new program, and you want it to be of the greatest 624 + possible use to the public, the best way to achieve this is to make it 625 + free software which everyone can redistribute and change under these terms. 626 + 627 + To do so, attach the following notices to the program. It is safest 628 + to attach them to the start of each source file to most effectively 629 + state the exclusion of warranty; and each file should have at least 630 + the "copyright" line and a pointer to where the full notice is found. 631 + 632 + <one line to give the program's name and a brief idea of what it does.> 633 + Copyright (C) <year> <name of author> 634 + 635 + This program is free software: you can redistribute it and/or modify 636 + it under the terms of the GNU Affero General Public License as published by 637 + the Free Software Foundation, either version 3 of the License, or 638 + (at your option) any later version. 639 + 640 + This program is distributed in the hope that it will be useful, 641 + but WITHOUT ANY WARRANTY; without even the implied warranty of 642 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 + GNU Affero General Public License for more details. 644 + 645 + You should have received a copy of the GNU Affero General Public License 646 + along with this program. If not, see <https://www.gnu.org/licenses/>. 647 + 648 + Also add information on how to contact you by electronic and paper mail. 649 + 650 + If your software can interact with users remotely through a computer 651 + network, you should also make sure that it provides a way for users to 652 + get its source. For example, if your program is a web application, its 653 + interface could display a "Source" link that leads users to an archive 654 + of the code. There are many ways you could offer source, and different 655 + solutions will be better for different programs; see section 13 for the 656 + specific requirements. 657 + 658 + You should also get your employer (if you work as a programmer) or school, 659 + if any, to sign a "copyright disclaimer" for the program, if necessary. 660 + For more information on this, and how to apply and follow the GNU AGPL, see 661 + <https://www.gnu.org/licenses/>.
+21
crates/bone-app/Cargo.toml
··· 1 + [package] 2 + name = "bone-app" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + bone-types = { workspace = true } 10 + bone-document = { workspace = true } 11 + bone-render = { workspace = true } 12 + 13 + pollster = { workspace = true } 14 + thiserror = { workspace = true } 15 + tracing = { workspace = true } 16 + tracing-subscriber = { workspace = true } 17 + uom = { workspace = true } 18 + winit = { workspace = true } 19 + 20 + [lints] 21 + workspace = true
+409
crates/bone-app/src/main.rs
··· 1 + use std::sync::Arc; 2 + 3 + use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 4 + use bone_render::{ 5 + Camera2, PixelsPerMm, SketchRenderer, SketchScene, Style, SurfaceContext, ViewportExtent, 6 + ViewportPx, 7 + }; 8 + use bone_types::{ 9 + Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3, Vec2, 10 + }; 11 + use tracing_subscriber::EnvFilter; 12 + use uom::si::length::millimeter; 13 + use winit::{ 14 + application::ApplicationHandler, 15 + dpi::{PhysicalPosition, PhysicalSize}, 16 + event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}, 17 + event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, 18 + keyboard::{KeyCode, ModifiersState, PhysicalKey}, 19 + window::{Window, WindowId}, 20 + }; 21 + 22 + #[derive(Debug, thiserror::Error)] 23 + enum AppError { 24 + #[error("event loop: {0}")] 25 + EventLoop(#[from] winit::error::EventLoopError), 26 + } 27 + 28 + 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"; 29 + const ZOOM_STEP_PER_LINE: f64 = 1.1; 30 + const ZOOM_STEP_PER_PIXEL: f64 = 1.0025; 31 + const ZOOM_KEY_STEP: f64 = 1.25; 32 + const ZOOM_MIN: f64 = 0.01; 33 + const ZOOM_MAX: f64 = 1.0e5; 34 + const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0; 35 + const PAN_STEP_PX: f64 = 40.0; 36 + const PAN_FAST_MULTIPLIER: f64 = 5.0; 37 + const ZOOM_FIT_MARGIN: f64 = 0.9; 38 + 39 + struct RenderState { 40 + surface: SurfaceContext, 41 + renderer: SketchRenderer, 42 + scene: SketchScene, 43 + camera: Camera2, 44 + style: Style, 45 + } 46 + 47 + #[derive(Default)] 48 + struct InputState { 49 + cursor_px: Option<PhysicalPosition<f64>>, 50 + left_down: bool, 51 + middle_down: bool, 52 + modifiers: ModifiersState, 53 + } 54 + 55 + impl InputState { 56 + fn panning(&self) -> bool { 57 + self.middle_down || (self.left_down && self.modifiers.shift_key()) 58 + } 59 + 60 + fn pan_step_px(&self) -> f64 { 61 + if self.modifiers.shift_key() { 62 + PAN_STEP_PX * PAN_FAST_MULTIPLIER 63 + } else { 64 + PAN_STEP_PX 65 + } 66 + } 67 + } 68 + 69 + struct App { 70 + window: Option<Arc<Window>>, 71 + render: Option<RenderState>, 72 + input: InputState, 73 + } 74 + 75 + fn plane_xy() -> SketchPlaneBasis { 76 + let Ok(basis) = SketchPlaneBasis::new( 77 + Point3::origin(), 78 + UnitVec3::x_axis(), 79 + UnitVec3::y_axis(), 80 + Tolerance::new(1e-9), 81 + ) else { 82 + unreachable!("canonical XY axes are orthonormal"); 83 + }; 84 + basis 85 + } 86 + 87 + fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 88 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 89 + SketchEntity::point(Point2::from_mm(x, y)), 90 + )) else { 91 + unreachable!("AddEntity(Point) on fresh sketch yields Entity outcome"); 92 + }; 93 + (next, id) 94 + } 95 + 96 + fn add_line(sketch: Sketch, a: SketchEntityId, b: SketchEntityId) -> Sketch { 97 + let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { 98 + unreachable!("AddEntity(Line) referencing freshly added Points succeeds"); 99 + }; 100 + next 101 + } 102 + 103 + fn add_circle(sketch: Sketch, center: SketchEntityId, radius_mm: f64) -> Sketch { 104 + let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::circle( 105 + center, 106 + Length::new::<millimeter>(radius_mm), 107 + false, 108 + ))) else { 109 + unreachable!("AddEntity(Circle) referencing freshly added center succeeds"); 110 + }; 111 + next 112 + } 113 + 114 + fn add_arc( 115 + sketch: Sketch, 116 + center: SketchEntityId, 117 + start: SketchEntityId, 118 + end: SketchEntityId, 119 + ) -> Sketch { 120 + let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::arc( 121 + center, start, end, false, 122 + ))) else { 123 + unreachable!("AddEntity(Arc) referencing freshly added points succeeds"); 124 + }; 125 + next 126 + } 127 + 128 + fn default_sketch() -> Sketch { 129 + let sketch = Sketch::new(plane_xy()); 130 + let (sketch, p0) = add_point(sketch, -20.0, -12.5); 131 + let (sketch, p1) = add_point(sketch, 20.0, -12.5); 132 + let (sketch, p2) = add_point(sketch, 20.0, 12.5); 133 + let (sketch, p3) = add_point(sketch, -20.0, 12.5); 134 + let sketch = add_line(sketch, p0, p1); 135 + let sketch = add_line(sketch, p1, p2); 136 + let sketch = add_line(sketch, p2, p3); 137 + let sketch = add_line(sketch, p3, p0); 138 + let (sketch, origin) = add_point(sketch, 0.0, 0.0); 139 + let sketch = add_circle(sketch, origin, 5.0); 140 + let (sketch, arc_center) = add_point(sketch, -12.0, 0.0); 141 + let (sketch, arc_start) = add_point(sketch, -8.0, 0.0); 142 + let (sketch, arc_end) = add_point(sketch, -12.0, 4.0); 143 + add_arc(sketch, arc_center, arc_start, arc_end) 144 + } 145 + 146 + fn viewport_extent(size: PhysicalSize<u32>) -> ViewportExtent { 147 + ViewportExtent::new( 148 + ViewportPx::new(size.width.max(1)), 149 + ViewportPx::new(size.height.max(1)), 150 + ) 151 + } 152 + 153 + fn zoom_factor(delta: MouseScrollDelta) -> f64 { 154 + match delta { 155 + MouseScrollDelta::LineDelta(_, y) => ZOOM_STEP_PER_LINE.powf(f64::from(y)), 156 + MouseScrollDelta::PixelDelta(p) => ZOOM_STEP_PER_PIXEL.powf(p.y), 157 + } 158 + } 159 + 160 + fn zoom_about(camera: Camera2, cursor: Option<PhysicalPosition<f64>>, factor: f64) -> Camera2 { 161 + if !factor.is_finite() || factor <= 0.0 { 162 + return camera; 163 + } 164 + let zoom_before = camera.zoom().value(); 165 + let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX); 166 + if !zoom_after.is_finite() { 167 + return camera; 168 + } 169 + let extent = camera.extent(); 170 + let w = f64::from(extent.width().value()); 171 + let h = f64::from(extent.height().value()); 172 + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 173 + let (cursor_x, cursor_y) = match cursor { 174 + Some(c) => (c.x, c.y), 175 + None => (w * 0.5, h * 0.5), 176 + }; 177 + let horizontal_px = cursor_x - w * 0.5; 178 + let vertical_px = h * 0.5 - cursor_y; 179 + let world_x = pan_x + horizontal_px / zoom_before; 180 + let world_y = pan_y + vertical_px / zoom_before; 181 + let new_pan_x = world_x - horizontal_px / zoom_after; 182 + let new_pan_y = world_y - vertical_px / zoom_after; 183 + camera 184 + .with_zoom(PixelsPerMm::new(zoom_after)) 185 + .with_pan(Vec2::from_mm(new_pan_x, new_pan_y)) 186 + } 187 + 188 + fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 { 189 + let mm_per_px = camera.world_mm_per_pixel(); 190 + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 191 + camera.with_pan(Vec2::from_mm( 192 + pan_x - horizontal_px * mm_per_px, 193 + pan_y + vertical_px * mm_per_px, 194 + )) 195 + } 196 + 197 + fn zoom_fit(camera: Camera2, scene: &SketchScene) -> Camera2 { 198 + let Some(aabb) = scene.aabb() else { 199 + return camera; 200 + }; 201 + let (mnx, mny) = aabb.min().coords_mm(); 202 + let (mxx, mxy) = aabb.max().coords_mm(); 203 + let center = Vec2::from_mm((mnx + mxx) * 0.5, (mny + mxy) * 0.5); 204 + let world_w = mxx - mnx; 205 + let world_h = mxy - mny; 206 + if world_w <= 0.0 && world_h <= 0.0 { 207 + return camera.with_pan(center); 208 + } 209 + let extent = camera.extent(); 210 + let w_px = f64::from(extent.width().value()); 211 + let h_px = f64::from(extent.height().value()); 212 + let axis_zoom = |pixels: f64, world: f64| { 213 + if world > 0.0 { 214 + pixels / world 215 + } else { 216 + f64::INFINITY 217 + } 218 + }; 219 + let raw_zoom = axis_zoom(w_px, world_w).min(axis_zoom(h_px, world_h)) * ZOOM_FIT_MARGIN; 220 + let clamped = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX); 221 + camera.with_zoom(PixelsPerMm::new(clamped)).with_pan(center) 222 + } 223 + 224 + enum KeyAction { 225 + Exit, 226 + Camera(Camera2), 227 + } 228 + 229 + fn keyboard_action(code: KeyCode, input: &InputState, state: &RenderState) -> Option<KeyAction> { 230 + match (code, input.modifiers.control_key()) { 231 + (KeyCode::KeyQ, true) => Some(KeyAction::Exit), 232 + _ => keyboard_camera(code, input, state).map(KeyAction::Camera), 233 + } 234 + } 235 + 236 + fn keyboard_camera(code: KeyCode, input: &InputState, state: &RenderState) -> Option<Camera2> { 237 + let camera = state.camera; 238 + let step = input.pan_step_px(); 239 + let shift = input.modifiers.shift_key(); 240 + match code { 241 + KeyCode::ArrowLeft => Some(pan_by_px(camera, step, 0.0)), 242 + KeyCode::ArrowRight => Some(pan_by_px(camera, -step, 0.0)), 243 + KeyCode::ArrowUp => Some(pan_by_px(camera, 0.0, step)), 244 + KeyCode::ArrowDown => Some(pan_by_px(camera, 0.0, -step)), 245 + KeyCode::KeyF => Some(zoom_fit(camera, &state.scene)), 246 + KeyCode::KeyZ => Some(zoom_about( 247 + camera, 248 + input.cursor_px, 249 + if shift { 250 + 1.0 / ZOOM_KEY_STEP 251 + } else { 252 + ZOOM_KEY_STEP 253 + }, 254 + )), 255 + KeyCode::Equal => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)), 256 + KeyCode::Minus => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)), 257 + _ => None, 258 + } 259 + } 260 + 261 + impl ApplicationHandler for App { 262 + fn resumed(&mut self, event_loop: &ActiveEventLoop) { 263 + if self.window.is_some() { 264 + return; 265 + } 266 + let attrs = Window::default_attributes().with_title("bone"); 267 + let window = match event_loop.create_window(attrs) { 268 + Ok(w) => Arc::new(w), 269 + Err(e) => { 270 + tracing::error!(error = %e, "create_window failed"); 271 + event_loop.exit(); 272 + return; 273 + } 274 + }; 275 + let extent = viewport_extent(window.inner_size()); 276 + let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) { 277 + Ok(s) => s, 278 + Err(e) => { 279 + tracing::error!(error = %e, "SurfaceContext::new failed"); 280 + event_loop.exit(); 281 + return; 282 + } 283 + }; 284 + let renderer = SketchRenderer::new(surface.gpu(), surface.color_format()); 285 + let sketch = default_sketch(); 286 + let scene = match SketchScene::extract(&sketch) { 287 + Ok(s) => s, 288 + Err(e) => { 289 + tracing::error!(error = %e, "scene extract failed"); 290 + event_loop.exit(); 291 + return; 292 + } 293 + }; 294 + let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); 295 + let style = Style::default(); 296 + window.request_redraw(); 297 + self.window = Some(window); 298 + self.render = Some(RenderState { 299 + surface, 300 + renderer, 301 + scene, 302 + camera, 303 + style, 304 + }); 305 + } 306 + 307 + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { 308 + let (Some(state), Some(window)) = (self.render.as_mut(), self.window.as_ref()) else { 309 + return; 310 + }; 311 + match event { 312 + WindowEvent::CloseRequested => event_loop.exit(), 313 + WindowEvent::Resized(size) => { 314 + let extent = viewport_extent(size); 315 + state.surface.resize(extent); 316 + state.camera = state.camera.with_extent(extent); 317 + window.request_redraw(); 318 + } 319 + WindowEvent::RedrawRequested => { 320 + let surface = &mut state.surface; 321 + let renderer = &state.renderer; 322 + let scene = &state.scene; 323 + let camera = state.camera; 324 + let style = &state.style; 325 + surface.render( 326 + |encoder, color, pick| { 327 + renderer.encode_passes(encoder, color, pick, scene, camera, style); 328 + }, 329 + || window.pre_present_notify(), 330 + ); 331 + } 332 + WindowEvent::Focused(false) => { 333 + self.input.cursor_px = None; 334 + self.input.left_down = false; 335 + self.input.middle_down = false; 336 + self.input.modifiers = ModifiersState::empty(); 337 + } 338 + WindowEvent::ModifiersChanged(mods) => { 339 + self.input.modifiers = mods.state(); 340 + } 341 + WindowEvent::CursorMoved { position, .. } => { 342 + let prev = self.input.cursor_px; 343 + self.input.cursor_px = Some(position); 344 + if self.input.panning() 345 + && let Some(p) = prev 346 + { 347 + state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); 348 + window.request_redraw(); 349 + } 350 + } 351 + WindowEvent::CursorLeft { .. } => { 352 + self.input.cursor_px = None; 353 + } 354 + WindowEvent::MouseInput { 355 + state: btn_state, 356 + button: MouseButton::Left, 357 + .. 358 + } => { 359 + self.input.left_down = btn_state == ElementState::Pressed; 360 + } 361 + WindowEvent::MouseInput { 362 + state: btn_state, 363 + button: MouseButton::Middle, 364 + .. 365 + } => { 366 + self.input.middle_down = btn_state == ElementState::Pressed; 367 + } 368 + WindowEvent::MouseWheel { delta, .. } => { 369 + state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 370 + window.request_redraw(); 371 + } 372 + WindowEvent::KeyboardInput { 373 + event: 374 + KeyEvent { 375 + physical_key: PhysicalKey::Code(code), 376 + state: ElementState::Pressed, 377 + .. 378 + }, 379 + .. 380 + } => match keyboard_action(code, &self.input, state) { 381 + Some(KeyAction::Exit) => event_loop.exit(), 382 + Some(KeyAction::Camera(next)) => { 383 + state.camera = next; 384 + window.request_redraw(); 385 + } 386 + None => {} 387 + }, 388 + _ => {} 389 + } 390 + } 391 + } 392 + 393 + fn main() -> Result<(), AppError> { 394 + tracing_subscriber::fmt() 395 + .with_env_filter( 396 + EnvFilter::try_from_default_env() 397 + .unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)), 398 + ) 399 + .init(); 400 + let event_loop = EventLoop::new()?; 401 + event_loop.set_control_flow(ControlFlow::Wait); 402 + let mut app = App { 403 + window: None, 404 + render: None, 405 + input: InputState::default(), 406 + }; 407 + event_loop.run_app(&mut app)?; 408 + Ok(()) 409 + }
+25
crates/bone-document/Cargo.toml
··· 1 + [package] 2 + name = "bone-document" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + bone-types = { workspace = true } 10 + bone-solver = { workspace = true } 11 + blake3 = { workspace = true } 12 + ron = { workspace = true } 13 + serde = { workspace = true } 14 + slotmap = { workspace = true } 15 + thiserror = { workspace = true } 16 + tracing = { workspace = true } 17 + 18 + [dev-dependencies] 19 + insta = { workspace = true } 20 + nalgebra = { workspace = true } 21 + proptest = { workspace = true } 22 + tempfile = { workspace = true } 23 + 24 + [lints] 25 + workspace = true
+105
crates/bone-document/src/document/feature_tree.rs
··· 1 + use bone_types::{FeatureId, SketchId}; 2 + use serde::{Deserialize, Serialize}; 3 + use slotmap::{Key, KeyData}; 4 + 5 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 6 + pub enum PrincipalPlane { 7 + Xy, 8 + Yz, 9 + Zx, 10 + } 11 + 12 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 13 + pub enum FeatureNode { 14 + Origin, 15 + PrincipalPlane(PrincipalPlane), 16 + Sketch(SketchId), 17 + } 18 + 19 + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 20 + #[serde(deny_unknown_fields)] 21 + struct FeatureEntry { 22 + id: FeatureId, 23 + node: FeatureNode, 24 + } 25 + 26 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 27 + #[serde(deny_unknown_fields)] 28 + pub struct FeatureTree { 29 + entries: Vec<FeatureEntry>, 30 + } 31 + 32 + impl FeatureTree { 33 + #[must_use] 34 + pub fn seeded() -> Self { 35 + let seeds = [ 36 + FeatureNode::Origin, 37 + FeatureNode::PrincipalPlane(PrincipalPlane::Xy), 38 + FeatureNode::PrincipalPlane(PrincipalPlane::Yz), 39 + FeatureNode::PrincipalPlane(PrincipalPlane::Zx), 40 + ]; 41 + let entries = seeds 42 + .into_iter() 43 + .zip(1u32..) 44 + .map(|(node, idx)| FeatureEntry { 45 + id: feature_id_from_idx(idx), 46 + node, 47 + }) 48 + .collect(); 49 + Self { entries } 50 + } 51 + 52 + #[must_use] 53 + pub fn node(&self, id: FeatureId) -> Option<FeatureNode> { 54 + self.entries.iter().find(|e| e.id == id).map(|e| e.node) 55 + } 56 + 57 + pub fn iter(&self) -> impl Iterator<Item = (FeatureId, FeatureNode)> + '_ { 58 + self.entries.iter().map(|e| (e.id, e.node)) 59 + } 60 + 61 + #[must_use] 62 + pub fn feature_of_sketch(&self, sketch: SketchId) -> Option<FeatureId> { 63 + self.entries 64 + .iter() 65 + .find(|e| matches!(e.node, FeatureNode::Sketch(s) if s == sketch)) 66 + .map(|e| e.id) 67 + } 68 + 69 + pub fn push_sketch(&mut self, sketch: SketchId) -> FeatureId { 70 + if let Some(existing) = self.feature_of_sketch(sketch) { 71 + return existing; 72 + } 73 + let id = self.allocate(); 74 + self.entries.push(FeatureEntry { 75 + id, 76 + node: FeatureNode::Sketch(sketch), 77 + }); 78 + id 79 + } 80 + 81 + pub fn remove_sketch(&mut self, sketch: SketchId) -> Option<FeatureId> { 82 + let id = self.feature_of_sketch(sketch)?; 83 + self.entries.retain(|e| e.id != id); 84 + Some(id) 85 + } 86 + 87 + fn allocate(&self) -> FeatureId { 88 + let highest = self.entries.iter().map(|e| idx_of(e.id)).max().unwrap_or(0); 89 + let Some(next) = highest.checked_add(1) else { 90 + panic!("FeatureTree exhausted 32-bit feature id space"); 91 + }; 92 + feature_id_from_idx(next) 93 + } 94 + } 95 + 96 + fn feature_id_from_idx(idx: u32) -> FeatureId { 97 + FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 98 + } 99 + 100 + fn idx_of(id: FeatureId) -> u32 { 101 + let Ok(idx) = u32::try_from(id.data().as_ffi() & 0xFFFF_FFFF) else { 102 + panic!("lower 32 bits of ffi key fit in u32"); 103 + }; 104 + idx 105 + }
+271
crates/bone-document/src/document/mod.rs
··· 1 + use std::collections::BTreeMap; 2 + 3 + use bone_types::{DocumentId, FeatureId, SchemaHeader, SketchId}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + use crate::Sketch; 7 + 8 + pub mod feature_tree; 9 + 10 + pub use feature_tree::{FeatureNode, FeatureTree, PrincipalPlane}; 11 + 12 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 13 + #[serde(deny_unknown_fields)] 14 + pub enum UnitsPreference { 15 + Millimetre, 16 + Centimetre, 17 + Metre, 18 + Inch, 19 + Foot, 20 + } 21 + 22 + impl UnitsPreference { 23 + pub const DEFAULT: Self = Self::Millimetre; 24 + } 25 + 26 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 27 + #[serde(deny_unknown_fields)] 28 + pub struct SketchRegistryEntry { 29 + pub label: String, 30 + pub filename: String, 31 + } 32 + 33 + #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] 34 + #[serde(deny_unknown_fields)] 35 + pub struct SketchRegistry { 36 + order: Vec<SketchId>, 37 + entries: BTreeMap<SketchId, SketchRegistryEntry>, 38 + } 39 + 40 + impl SketchRegistry { 41 + #[must_use] 42 + pub fn new() -> Self { 43 + Self::default() 44 + } 45 + 46 + #[must_use] 47 + pub fn order(&self) -> &[SketchId] { 48 + &self.order 49 + } 50 + 51 + #[must_use] 52 + pub fn entry(&self, id: SketchId) -> Option<&SketchRegistryEntry> { 53 + self.entries.get(&id) 54 + } 55 + 56 + pub fn iter(&self) -> impl Iterator<Item = (SketchId, &SketchRegistryEntry)> + '_ { 57 + self.order 58 + .iter() 59 + .filter_map(|id| self.entries.get(id).map(|e| (*id, e))) 60 + } 61 + 62 + pub(crate) fn insert(&mut self, id: SketchId, entry: SketchRegistryEntry) { 63 + if !self.entries.contains_key(&id) { 64 + self.order.push(id); 65 + } 66 + self.entries.insert(id, entry); 67 + } 68 + 69 + pub(crate) fn remove(&mut self, id: SketchId) -> Option<SketchRegistryEntry> { 70 + self.order.retain(|other| *other != id); 71 + self.entries.remove(&id) 72 + } 73 + } 74 + 75 + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] 76 + #[serde(deny_unknown_fields)] 77 + pub struct DocumentParameters { 78 + order: Vec<String>, 79 + entries: BTreeMap<String, f64>, 80 + } 81 + 82 + impl DocumentParameters { 83 + #[must_use] 84 + pub fn new() -> Self { 85 + Self::default() 86 + } 87 + 88 + #[must_use] 89 + pub fn order(&self) -> &[String] { 90 + &self.order 91 + } 92 + 93 + #[must_use] 94 + pub fn get(&self, name: &str) -> Option<f64> { 95 + self.entries.get(name).copied() 96 + } 97 + 98 + pub fn insert(&mut self, name: String, value: f64) { 99 + if !self.entries.contains_key(&name) { 100 + self.order.push(name.clone()); 101 + } 102 + self.entries.insert(name, value); 103 + } 104 + 105 + pub fn remove(&mut self, name: &str) -> Option<f64> { 106 + self.order.retain(|n| n != name); 107 + self.entries.remove(name) 108 + } 109 + } 110 + 111 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 112 + #[serde(deny_unknown_fields)] 113 + pub struct DocumentHeader { 114 + pub schema: SchemaHeader, 115 + pub id: DocumentId, 116 + pub name: String, 117 + pub units: UnitsPreference, 118 + pub parameters: DocumentParameters, 119 + pub feature_tree: FeatureTree, 120 + pub sketches: SketchRegistry, 121 + } 122 + 123 + impl DocumentHeader { 124 + #[must_use] 125 + pub fn new(id: DocumentId, name: String) -> Self { 126 + Self { 127 + schema: SchemaHeader::bone_document(), 128 + id, 129 + name, 130 + units: UnitsPreference::DEFAULT, 131 + parameters: DocumentParameters::new(), 132 + feature_tree: FeatureTree::seeded(), 133 + sketches: SketchRegistry::new(), 134 + } 135 + } 136 + } 137 + 138 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 139 + #[serde(deny_unknown_fields)] 140 + pub struct SketchFile { 141 + pub schema: SchemaHeader, 142 + pub sketch: Sketch, 143 + } 144 + 145 + impl SketchFile { 146 + #[must_use] 147 + pub fn new(sketch: Sketch) -> Self { 148 + Self { 149 + schema: SchemaHeader::bone_document(), 150 + sketch, 151 + } 152 + } 153 + } 154 + 155 + #[derive(Clone, Debug, PartialEq)] 156 + pub struct Document { 157 + header: DocumentHeader, 158 + sketches: BTreeMap<SketchId, Sketch>, 159 + } 160 + 161 + impl Document { 162 + #[must_use] 163 + pub fn new(id: DocumentId, name: String) -> Self { 164 + Self { 165 + header: DocumentHeader::new(id, name), 166 + sketches: BTreeMap::new(), 167 + } 168 + } 169 + 170 + pub(crate) fn from_parts(header: DocumentHeader, sketches: BTreeMap<SketchId, Sketch>) -> Self { 171 + Self { header, sketches } 172 + } 173 + 174 + #[must_use] 175 + pub fn header(&self) -> &DocumentHeader { 176 + &self.header 177 + } 178 + 179 + #[must_use] 180 + pub fn id(&self) -> DocumentId { 181 + self.header.id 182 + } 183 + 184 + #[must_use] 185 + pub fn name(&self) -> &str { 186 + &self.header.name 187 + } 188 + 189 + #[must_use] 190 + pub fn units(&self) -> UnitsPreference { 191 + self.header.units 192 + } 193 + 194 + #[must_use] 195 + pub fn feature_tree(&self) -> &FeatureTree { 196 + &self.header.feature_tree 197 + } 198 + 199 + #[must_use] 200 + pub fn parameters(&self) -> &DocumentParameters { 201 + &self.header.parameters 202 + } 203 + 204 + #[must_use] 205 + pub fn registry(&self) -> &SketchRegistry { 206 + &self.header.sketches 207 + } 208 + 209 + #[must_use] 210 + pub fn sketch(&self, id: SketchId) -> Option<&Sketch> { 211 + self.sketches.get(&id) 212 + } 213 + 214 + pub fn sketches(&self) -> impl Iterator<Item = (SketchId, &Sketch)> + '_ { 215 + self.header 216 + .sketches 217 + .order() 218 + .iter() 219 + .filter_map(|id| self.sketches.get(id).map(|s| (*id, s))) 220 + } 221 + 222 + #[must_use] 223 + pub fn sketches_map(&self) -> &BTreeMap<SketchId, Sketch> { 224 + &self.sketches 225 + } 226 + 227 + pub fn set_name(&mut self, name: String) { 228 + self.header.name = name; 229 + } 230 + 231 + pub fn set_units(&mut self, units: UnitsPreference) { 232 + self.header.units = units; 233 + } 234 + 235 + pub fn insert_sketch(&mut self, id: SketchId, label: String, sketch: Sketch) { 236 + let filename = sketch_filename(id); 237 + self.header 238 + .sketches 239 + .insert(id, SketchRegistryEntry { label, filename }); 240 + self.header.feature_tree.push_sketch(id); 241 + self.sketches.insert(id, sketch); 242 + } 243 + 244 + pub fn replace_sketch(&mut self, id: SketchId, sketch: Sketch) -> Option<Sketch> { 245 + self.sketches.insert(id, sketch) 246 + } 247 + 248 + pub fn remove_sketch(&mut self, id: SketchId) -> Option<Sketch> { 249 + self.header.sketches.remove(id); 250 + self.header.feature_tree.remove_sketch(id); 251 + self.sketches.remove(&id) 252 + } 253 + 254 + pub fn set_parameter(&mut self, name: String, value: f64) { 255 + self.header.parameters.insert(name, value); 256 + } 257 + 258 + #[must_use] 259 + pub fn sketch_of_feature(&self, feature: FeatureId) -> Option<&Sketch> { 260 + match self.header.feature_tree.node(feature)? { 261 + FeatureNode::Sketch(id) => self.sketches.get(&id), 262 + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => None, 263 + } 264 + } 265 + } 266 + 267 + #[must_use] 268 + pub fn sketch_filename(id: SketchId) -> String { 269 + use slotmap::Key; 270 + format!("{:016x}.ron", id.data().as_ffi()) 271 + }
+81
crates/bone-document/src/evaluator.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; 2 + 3 + use bone_solver::SolverError; 4 + use bone_types::FeatureId; 5 + 6 + use crate::Sketch; 7 + 8 + #[derive(Clone, Debug, PartialEq)] 9 + pub enum EvaluatedSketch { 10 + Solved(Sketch), 11 + Failed(SolverError), 12 + } 13 + 14 + #[must_use] 15 + pub fn evaluate_sketch(input: &Sketch) -> EvaluatedSketch { 16 + match input.solve() { 17 + Ok(solved) => EvaluatedSketch::Solved(solved), 18 + Err(error) => EvaluatedSketch::Failed(error), 19 + } 20 + } 21 + 22 + #[derive(Clone, Debug, Default)] 23 + pub struct FeatureCache { 24 + entries: BTreeMap<FeatureId, CachedSketch>, 25 + } 26 + 27 + #[derive(Clone, Debug)] 28 + struct CachedSketch { 29 + input: Sketch, 30 + output: EvaluatedSketch, 31 + } 32 + 33 + impl FeatureCache { 34 + #[must_use] 35 + pub fn new() -> Self { 36 + Self::default() 37 + } 38 + 39 + pub fn evaluate(&mut self, feature: FeatureId, input: &Sketch) -> &EvaluatedSketch { 40 + let cached = match self.entries.entry(feature) { 41 + Entry::Occupied(mut slot) => { 42 + if &slot.get().input != input { 43 + *slot.get_mut() = CachedSketch { 44 + input: input.clone(), 45 + output: evaluate_sketch(input), 46 + }; 47 + } 48 + slot.into_mut() 49 + } 50 + Entry::Vacant(slot) => slot.insert(CachedSketch { 51 + input: input.clone(), 52 + output: evaluate_sketch(input), 53 + }), 54 + }; 55 + &cached.output 56 + } 57 + 58 + #[must_use] 59 + pub fn lookup(&self, feature: FeatureId) -> Option<&EvaluatedSketch> { 60 + self.entries.get(&feature).map(|cached| &cached.output) 61 + } 62 + 63 + pub fn invalidate(&mut self, feature: FeatureId) -> bool { 64 + self.entries.remove(&feature).is_some() 65 + } 66 + 67 + pub fn retain(&mut self, live: impl IntoIterator<Item = FeatureId>) { 68 + let keep: BTreeSet<FeatureId> = live.into_iter().collect(); 69 + self.entries.retain(|id, _| keep.contains(id)); 70 + } 71 + 72 + #[must_use] 73 + pub fn len(&self) -> usize { 74 + self.entries.len() 75 + } 76 + 77 + #[must_use] 78 + pub fn is_empty(&self) -> bool { 79 + self.entries.is_empty() 80 + } 81 + }
+167
crates/bone-document/src/io/blob.rs
··· 1 + use std::path::PathBuf; 2 + 3 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 4 + pub struct BlobHash(blake3::Hash); 5 + 6 + impl BlobHash { 7 + #[must_use] 8 + pub fn of(bytes: &[u8]) -> Self { 9 + Self(blake3::hash(bytes)) 10 + } 11 + 12 + #[must_use] 13 + pub const fn from_bytes(bytes: [u8; 32]) -> Self { 14 + Self(blake3::Hash::from_bytes(bytes)) 15 + } 16 + 17 + #[must_use] 18 + pub fn bytes(self) -> [u8; 32] { 19 + *self.0.as_bytes() 20 + } 21 + 22 + #[must_use] 23 + pub fn full_hex(self) -> String { 24 + self.0.to_hex().to_string() 25 + } 26 + 27 + #[must_use] 28 + pub fn truncated_128_hex(self) -> String { 29 + self.0.to_hex()[..32].to_string() 30 + } 31 + 32 + #[must_use] 33 + pub fn relative_path(self, kind: BlobKind) -> PathBuf { 34 + let hex = self.truncated_128_hex(); 35 + let (aa, rest) = hex.split_at(2); 36 + PathBuf::from(aa).join(format!("{rest}.{ext}", ext = kind.as_str())) 37 + } 38 + } 39 + 40 + impl core::fmt::Display for BlobHash { 41 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 42 + write!(f, "{}", self.0.to_hex()) 43 + } 44 + } 45 + 46 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 47 + pub struct BlobKind(&'static str); 48 + 49 + impl BlobKind { 50 + pub const STEP: Self = Self("step"); 51 + pub const TESS: Self = Self("tess"); 52 + pub const THUMB: Self = Self("thumb"); 53 + 54 + #[must_use] 55 + pub fn new(ext: &'static str) -> Self { 56 + assert!( 57 + !ext.is_empty() 58 + && ext 59 + .bytes() 60 + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()), 61 + "BlobKind extension must be non-empty lowercase ASCII alphanumeric", 62 + ); 63 + Self(ext) 64 + } 65 + 66 + #[must_use] 67 + pub const fn as_str(self) -> &'static str { 68 + self.0 69 + } 70 + } 71 + 72 + impl core::fmt::Display for BlobKind { 73 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 74 + f.write_str(self.0) 75 + } 76 + } 77 + 78 + #[cfg(test)] 79 + mod tests { 80 + use super::*; 81 + 82 + #[test] 83 + fn of_matches_blake3_reference() { 84 + let hash = BlobHash::of(b""); 85 + assert_eq!( 86 + hash.full_hex(), 87 + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" 88 + ); 89 + } 90 + 91 + #[test] 92 + fn truncated_is_first_32_hex_chars() { 93 + let hash = BlobHash::of(b"example"); 94 + let full = hash.full_hex(); 95 + let short = hash.truncated_128_hex(); 96 + assert_eq!(short.len(), 32); 97 + assert_eq!(short, full[..32]); 98 + } 99 + 100 + #[test] 101 + fn relative_path_is_fanout_then_rest_dot_kind() { 102 + let hash = BlobHash::of(b"example"); 103 + let path = hash.relative_path(BlobKind::STEP); 104 + let parts: Vec<String> = path 105 + .components() 106 + .map(|c| c.as_os_str().to_string_lossy().into_owned()) 107 + .collect(); 108 + let Some([fanout, file]) = parts 109 + .get(..2) 110 + .and_then(|s| <&[String; 2]>::try_from(s).ok()) 111 + else { 112 + panic!("two components, got {parts:?}"); 113 + }; 114 + assert_eq!(fanout.len(), 2); 115 + let Some(stem) = file.strip_suffix(".step") else { 116 + panic!("ends with .step: {file}"); 117 + }; 118 + assert_eq!(stem.len(), 30); 119 + let hex = hash.truncated_128_hex(); 120 + assert_eq!(fanout, &hex[..2]); 121 + assert_eq!(stem, &hex[2..]); 122 + } 123 + 124 + #[test] 125 + fn from_bytes_roundtrips_through_bytes() { 126 + let bytes = [7u8; 32]; 127 + let hash = BlobHash::from_bytes(bytes); 128 + assert_eq!(hash.bytes(), bytes); 129 + } 130 + 131 + #[test] 132 + fn blob_kind_presets_match_adr_examples() { 133 + assert_eq!(BlobKind::STEP.as_str(), "step"); 134 + assert_eq!(BlobKind::TESS.as_str(), "tess"); 135 + assert_eq!(BlobKind::THUMB.as_str(), "thumb"); 136 + } 137 + 138 + #[test] 139 + fn blob_kind_new_accepts_lowercase_alphanumeric() { 140 + assert_eq!(BlobKind::new("gltf").as_str(), "gltf"); 141 + assert_eq!(BlobKind::new("v1").as_str(), "v1"); 142 + } 143 + 144 + #[test] 145 + #[should_panic = "BlobKind extension"] 146 + fn blob_kind_new_rejects_empty() { 147 + let _ = BlobKind::new(""); 148 + } 149 + 150 + #[test] 151 + #[should_panic = "BlobKind extension"] 152 + fn blob_kind_new_rejects_uppercase() { 153 + let _ = BlobKind::new("STEP"); 154 + } 155 + 156 + #[test] 157 + #[should_panic = "BlobKind extension"] 158 + fn blob_kind_new_rejects_path_separator() { 159 + let _ = BlobKind::new("bad/ext"); 160 + } 161 + 162 + #[test] 163 + #[should_panic = "BlobKind extension"] 164 + fn blob_kind_new_rejects_dot() { 165 + let _ = BlobKind::new("step.bak"); 166 + } 167 + }
+412
crates/bone-document/src/io/folder.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + use std::io::Write; 3 + use std::path::{Path, PathBuf}; 4 + use std::{fs, io}; 5 + 6 + use bone_types::{SchemaHeader, SchemaVersion, SketchId}; 7 + 8 + use crate::document::{Document, DocumentHeader, FeatureNode, SketchFile, sketch_filename}; 9 + use crate::io::blob::{BlobHash, BlobKind}; 10 + use crate::io::ron_io::{RonError, from_str, to_string}; 11 + use crate::sketch::SketchEditError; 12 + 13 + pub const DOCUMENT_FILE: &str = "document.ron"; 14 + pub const SKETCHES_DIR: &str = "sketches"; 15 + pub const BLOBS_DIR: &str = "blobs"; 16 + pub const CACHES_DIR: &str = "caches"; 17 + 18 + const ROOT_GITIGNORE: &str = "caches/\n"; 19 + const ROOT_GITATTRIBUTES: &str = "* text=auto eol=lf\n*.boneblob binary\n"; 20 + const CACHES_GITIGNORE: &str = "*\n!.gitignore\n!CACHEDIR.TAG\n"; 21 + const CACHEDIR_TAG: &str = concat!( 22 + "Signature: 8a477f597d28d172789f06886806bc55\n", 23 + "# This file is a cache directory tag automatically created by bone.\n", 24 + "# For information about cache directory tags see https://bford.info/cachedir/\n", 25 + ); 26 + 27 + #[derive(Debug, thiserror::Error)] 28 + #[error(transparent)] 29 + pub struct FolderError(Box<FolderErrorKind>); 30 + 31 + impl FolderError { 32 + #[must_use] 33 + pub fn kind(&self) -> &FolderErrorKind { 34 + &self.0 35 + } 36 + 37 + #[must_use] 38 + pub fn into_kind(self) -> FolderErrorKind { 39 + *self.0 40 + } 41 + } 42 + 43 + impl From<FolderErrorKind> for FolderError { 44 + fn from(kind: FolderErrorKind) -> Self { 45 + Self(Box::new(kind)) 46 + } 47 + } 48 + 49 + impl FolderErrorKind { 50 + fn wrap(self) -> FolderError { 51 + FolderError(Box::new(self)) 52 + } 53 + } 54 + 55 + #[derive(Debug, thiserror::Error)] 56 + pub enum FolderErrorKind { 57 + #[error("io at {path}: {source}")] 58 + Io { 59 + path: PathBuf, 60 + #[source] 61 + source: io::Error, 62 + }, 63 + #[error("ron at {path}: {source}")] 64 + Ron { 65 + path: PathBuf, 66 + #[source] 67 + source: RonError, 68 + }, 69 + #[error("unknown schema {found} (expected name {expected_name})")] 70 + UnknownSchema { 71 + found: String, 72 + expected_name: &'static str, 73 + }, 74 + #[error("schema {name} major v{found} is unsupported (this build supports v{supported})")] 75 + UnsupportedMajor { 76 + name: String, 77 + found: SchemaVersion, 78 + supported: SchemaVersion, 79 + }, 80 + #[error("registry references sketch {id:?} with no file on disk")] 81 + MissingSketchFile { id: SketchId }, 82 + #[error("integrity of {path}: {source}")] 83 + SketchIntegrity { 84 + path: PathBuf, 85 + #[source] 86 + source: SketchEditError, 87 + }, 88 + #[error("feature tree references sketch {id:?} not in registry")] 89 + DanglingTreeSketch { id: SketchId }, 90 + #[error("registry has sketch {id:?} absent from feature tree")] 91 + OrphanRegistered { id: SketchId }, 92 + } 93 + 94 + #[derive(Clone, Debug, PartialEq, Eq, Hash)] 95 + pub struct DocumentFolder { 96 + path: PathBuf, 97 + } 98 + 99 + impl DocumentFolder { 100 + #[must_use] 101 + pub fn new(path: impl Into<PathBuf>) -> Self { 102 + Self { path: path.into() } 103 + } 104 + 105 + #[must_use] 106 + pub fn path(&self) -> &Path { 107 + &self.path 108 + } 109 + 110 + #[must_use] 111 + pub fn document_file(&self) -> PathBuf { 112 + self.path.join(DOCUMENT_FILE) 113 + } 114 + 115 + #[must_use] 116 + pub fn sketches_dir(&self) -> PathBuf { 117 + self.path.join(SKETCHES_DIR) 118 + } 119 + 120 + #[must_use] 121 + pub fn blobs_dir(&self) -> PathBuf { 122 + self.path.join(BLOBS_DIR) 123 + } 124 + 125 + #[must_use] 126 + pub fn caches_dir(&self) -> PathBuf { 127 + self.path.join(CACHES_DIR) 128 + } 129 + 130 + #[must_use] 131 + pub fn sketch_path(&self, id: SketchId) -> PathBuf { 132 + self.sketches_dir().join(sketch_filename(id)) 133 + } 134 + 135 + #[must_use] 136 + pub fn blob_path(&self, hash: BlobHash, kind: BlobKind) -> PathBuf { 137 + self.blobs_dir().join(hash.relative_path(kind)) 138 + } 139 + } 140 + 141 + pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> { 142 + ensure_dir(folder.path())?; 143 + ensure_dir(&folder.sketches_dir())?; 144 + ensure_dir(&folder.blobs_dir())?; 145 + ensure_dir(&folder.caches_dir())?; 146 + 147 + write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?; 148 + write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?; 149 + write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?; 150 + write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE)?; 151 + 152 + document 153 + .sketches() 154 + .try_for_each(|(id, sketch)| -> Result<(), FolderError> { 155 + let file = SketchFile::new(sketch.clone()); 156 + let ron = to_ron(&folder.sketch_path(id), &file)?; 157 + atomic_write(&folder.sketch_path(id), &ron) 158 + })?; 159 + 160 + let document_ron = to_ron(&folder.document_file(), document.header())?; 161 + atomic_write(&folder.document_file(), &document_ron)?; 162 + 163 + let registry_ids: BTreeSet<SketchId> = document.registry().order().iter().copied().collect(); 164 + remove_stale_sketches(&folder.sketches_dir(), &registry_ids)?; 165 + 166 + Ok(()) 167 + } 168 + 169 + pub fn load(folder: &DocumentFolder) -> Result<Document, FolderError> { 170 + let header_path = folder.document_file(); 171 + let header_text = read_to_string(&header_path)?; 172 + let header: DocumentHeader = from_ron(&header_path, &header_text)?; 173 + check_schema(&header.schema)?; 174 + validate_header(&header)?; 175 + 176 + let sketches = 177 + header 178 + .sketches 179 + .order() 180 + .iter() 181 + .copied() 182 + .try_fold(BTreeMap::new(), |mut acc, id| { 183 + let path = folder.sketch_path(id); 184 + let text = read_to_string(&path).map_err(|e| match e.into_kind() { 185 + FolderErrorKind::Io { source, .. } 186 + if source.kind() == io::ErrorKind::NotFound => 187 + { 188 + FolderErrorKind::MissingSketchFile { id }.wrap() 189 + } 190 + other => other.wrap(), 191 + })?; 192 + let file: SketchFile = from_ron(&path, &text)?; 193 + check_schema(&file.schema)?; 194 + file.sketch.validate().map_err(|source| { 195 + FolderErrorKind::SketchIntegrity { 196 + path: path.clone(), 197 + source, 198 + } 199 + .wrap() 200 + })?; 201 + acc.insert(id, file.sketch); 202 + Ok::<_, FolderError>(acc) 203 + })?; 204 + 205 + Ok(Document::from_parts(header, sketches)) 206 + } 207 + 208 + fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> { 209 + let registered: BTreeSet<SketchId> = header.sketches.order().iter().copied().collect(); 210 + let tree_sketches: BTreeSet<SketchId> = header 211 + .feature_tree 212 + .iter() 213 + .filter_map(|(_, node)| match node { 214 + FeatureNode::Sketch(id) => Some(id), 215 + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => None, 216 + }) 217 + .collect(); 218 + if let Some(&id) = tree_sketches.difference(&registered).next() { 219 + return Err(FolderErrorKind::DanglingTreeSketch { id }.wrap()); 220 + } 221 + if let Some(&id) = registered.difference(&tree_sketches).next() { 222 + return Err(FolderErrorKind::OrphanRegistered { id }.wrap()); 223 + } 224 + Ok(()) 225 + } 226 + 227 + fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> { 228 + if !schema.is_bone_document() { 229 + return Err(FolderErrorKind::UnknownSchema { 230 + found: schema.name.clone(), 231 + expected_name: SchemaHeader::BONE_DOCUMENT_NAME, 232 + } 233 + .wrap()); 234 + } 235 + let supported = SchemaVersion::new( 236 + SchemaHeader::BONE_DOCUMENT_MAJOR, 237 + SchemaHeader::BONE_DOCUMENT_MINOR, 238 + ); 239 + if schema.version.major != SchemaHeader::BONE_DOCUMENT_MAJOR { 240 + return Err(FolderErrorKind::UnsupportedMajor { 241 + name: schema.name.clone(), 242 + found: schema.version, 243 + supported, 244 + } 245 + .wrap()); 246 + } 247 + if schema.version.minor > SchemaHeader::BONE_DOCUMENT_MINOR { 248 + tracing::info!( 249 + name = %schema.name, 250 + found = %schema.version, 251 + supported = %supported, 252 + "accepting newer minor schema version; unknown fields will be rejected by deny_unknown_fields" 253 + ); 254 + } 255 + Ok(()) 256 + } 257 + 258 + fn ensure_dir(path: &Path) -> Result<(), FolderError> { 259 + fs::create_dir_all(path).map_err(|source| { 260 + FolderErrorKind::Io { 261 + path: path.to_path_buf(), 262 + source, 263 + } 264 + .wrap() 265 + }) 266 + } 267 + 268 + fn write_if_different(path: &Path, contents: &str) -> Result<(), FolderError> { 269 + if let Ok(existing) = fs::read_to_string(path) 270 + && existing == contents 271 + { 272 + return Ok(()); 273 + } 274 + atomic_write(path, contents) 275 + } 276 + 277 + fn atomic_write(path: &Path, contents: &str) -> Result<(), FolderError> { 278 + let parent = path.parent().unwrap_or_else(|| Path::new(".")); 279 + ensure_dir(parent)?; 280 + let tmp = tmp_sibling(path); 281 + write_and_sync(&tmp, contents)?; 282 + fs::rename(&tmp, path).map_err(|source| { 283 + let _ = fs::remove_file(&tmp); 284 + FolderErrorKind::Io { 285 + path: path.to_path_buf(), 286 + source, 287 + } 288 + .wrap() 289 + })?; 290 + sync_dir(parent) 291 + } 292 + 293 + fn write_and_sync(path: &Path, contents: &str) -> Result<(), FolderError> { 294 + let mut file = fs::File::create(path).map_err(|source| { 295 + FolderErrorKind::Io { 296 + path: path.to_path_buf(), 297 + source, 298 + } 299 + .wrap() 300 + })?; 301 + file.write_all(contents.as_bytes()).map_err(|source| { 302 + FolderErrorKind::Io { 303 + path: path.to_path_buf(), 304 + source, 305 + } 306 + .wrap() 307 + })?; 308 + file.sync_all().map_err(|source| { 309 + FolderErrorKind::Io { 310 + path: path.to_path_buf(), 311 + source, 312 + } 313 + .wrap() 314 + }) 315 + } 316 + 317 + #[cfg(unix)] 318 + fn sync_dir(path: &Path) -> Result<(), FolderError> { 319 + fs::File::open(path) 320 + .and_then(|f| f.sync_all()) 321 + .map_err(|source| { 322 + FolderErrorKind::Io { 323 + path: path.to_path_buf(), 324 + source, 325 + } 326 + .wrap() 327 + }) 328 + } 329 + 330 + #[cfg(not(unix))] 331 + fn sync_dir(_: &Path) -> Result<(), FolderError> { 332 + Ok(()) 333 + } 334 + 335 + fn tmp_sibling(path: &Path) -> PathBuf { 336 + let file_name = path 337 + .file_name() 338 + .map(std::ffi::OsStr::to_os_string) 339 + .unwrap_or_default(); 340 + let mut tmp_name = file_name; 341 + tmp_name.push(".tmp"); 342 + path.with_file_name(tmp_name) 343 + } 344 + 345 + fn read_to_string(path: &Path) -> Result<String, FolderError> { 346 + fs::read_to_string(path).map_err(|source| { 347 + FolderErrorKind::Io { 348 + path: path.to_path_buf(), 349 + source, 350 + } 351 + .wrap() 352 + }) 353 + } 354 + 355 + fn to_ron<T: serde::Serialize>(path: &Path, value: &T) -> Result<String, FolderError> { 356 + to_string(value).map_err(|source| { 357 + FolderErrorKind::Ron { 358 + path: path.to_path_buf(), 359 + source, 360 + } 361 + .wrap() 362 + }) 363 + } 364 + 365 + fn from_ron<T: serde::de::DeserializeOwned>(path: &Path, text: &str) -> Result<T, FolderError> { 366 + from_str(text).map_err(|source| { 367 + FolderErrorKind::Ron { 368 + path: path.to_path_buf(), 369 + source, 370 + } 371 + .wrap() 372 + }) 373 + } 374 + 375 + fn remove_stale_sketches(dir: &Path, live: &BTreeSet<SketchId>) -> Result<(), FolderError> { 376 + let entries = match fs::read_dir(dir) { 377 + Ok(iter) => iter, 378 + Err(ref source) if source.kind() == io::ErrorKind::NotFound => return Ok(()), 379 + Err(source) => { 380 + return Err(FolderErrorKind::Io { 381 + path: dir.to_path_buf(), 382 + source, 383 + } 384 + .into()); 385 + } 386 + }; 387 + let live_names: BTreeSet<String> = live.iter().copied().map(sketch_filename).collect(); 388 + let modified = entries.into_iter().try_fold(false, |modified, entry| { 389 + let entry = entry.map_err(|source| { 390 + FolderErrorKind::Io { 391 + path: dir.to_path_buf(), 392 + source, 393 + } 394 + .wrap() 395 + })?; 396 + let name = entry.file_name().to_string_lossy().into_owned(); 397 + let looks_like_ron = Path::new(&name) 398 + .extension() 399 + .is_some_and(|ext| ext.eq_ignore_ascii_case("ron")); 400 + if looks_like_ron && !live_names.contains(&name) { 401 + let path = entry.path(); 402 + fs::remove_file(&path).map_err(|source| FolderErrorKind::Io { path, source }.wrap())?; 403 + Ok::<_, FolderError>(true) 404 + } else { 405 + Ok(modified) 406 + } 407 + })?; 408 + if modified { 409 + sync_dir(dir)?; 410 + } 411 + Ok(()) 412 + }
+7
crates/bone-document/src/io/mod.rs
··· 1 + pub mod blob; 2 + pub mod folder; 3 + pub mod ron_io; 4 + 5 + pub use blob::{BlobHash, BlobKind}; 6 + pub use folder::{DocumentFolder, FolderError, FolderErrorKind, load, save}; 7 + pub use ron_io::{RonError, from_str, to_string};
+34
crates/bone-document/src/io/ron_io.rs
··· 1 + use ron::extensions::Extensions; 2 + use ron::ser::PrettyConfig; 3 + use serde::{Serialize, de::DeserializeOwned}; 4 + 5 + #[derive(Debug, thiserror::Error)] 6 + pub enum RonError { 7 + #[error("serialize: {0}")] 8 + Serialize(#[from] ron::Error), 9 + #[error("deserialize: {0}")] 10 + Deserialize(#[from] ron::error::SpannedError), 11 + } 12 + 13 + #[must_use] 14 + pub fn pretty_config() -> PrettyConfig { 15 + PrettyConfig::new() 16 + .struct_names(true) 17 + .separate_tuple_members(false) 18 + .compact_arrays(true) 19 + .depth_limit(usize::MAX) 20 + .indentor(" ".to_owned()) 21 + .extensions( 22 + Extensions::UNWRAP_NEWTYPES 23 + | Extensions::IMPLICIT_SOME 24 + | Extensions::EXPLICIT_STRUCT_NAMES, 25 + ) 26 + } 27 + 28 + pub fn to_string<T: Serialize>(value: &T) -> Result<String, RonError> { 29 + Ok(ron::ser::to_string_pretty(value, pretty_config())?) 30 + } 31 + 32 + pub fn from_str<T: DeserializeOwned>(input: &str) -> Result<T, RonError> { 33 + Ok(ron::de::from_str(input)?) 34 + }
+34
crates/bone-document/src/lib.rs
··· 1 + pub mod document; 2 + pub mod evaluator; 3 + pub mod io; 4 + pub mod sketch; 5 + pub mod undo; 6 + 7 + pub use document::{ 8 + Document, DocumentHeader, DocumentParameters, FeatureNode, FeatureTree, PrincipalPlane, 9 + SketchFile, SketchRegistry, SketchRegistryEntry, UnitsPreference, sketch_filename, 10 + }; 11 + pub use evaluator::{EvaluatedSketch, FeatureCache, evaluate_sketch}; 12 + pub use io::{ 13 + BlobHash, BlobKind, DocumentFolder, FolderError, FolderErrorKind, RonError, from_str, load, 14 + save, to_string, 15 + }; 16 + pub use sketch::{ 17 + ArcData, CircleData, DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch, 18 + EditOutcome, EntityRefs, LineData, PointData, RelationRefs, Sketch, SketchDimension, 19 + SketchDofReport, SketchEdit, SketchEditError, SketchEntity, SketchEntityKind, SketchParameter, 20 + SketchRelation, 21 + }; 22 + pub use undo::UndoStack; 23 + 24 + #[derive(Debug, thiserror::Error)] 25 + pub enum DocumentError { 26 + #[error(transparent)] 27 + SketchEdit(#[from] SketchEditError), 28 + #[error(transparent)] 29 + Folder(#[from] FolderError), 30 + #[error(transparent)] 31 + Ron(#[from] RonError), 32 + } 33 + 34 + pub type Result<T, E = DocumentError> = core::result::Result<T, E>;
+123
crates/bone-document/src/sketch/dimension.rs
··· 1 + use bone_types::{Angle, Length, SketchEntityId, dimensioned_serde}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 5 + pub enum DimensionKind { 6 + Driving, 7 + Driven, 8 + } 9 + 10 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 11 + pub enum DimensionValue { 12 + #[serde(with = "dimensioned_serde::length_si")] 13 + Length(Length), 14 + #[serde(with = "dimensioned_serde::angle_si")] 15 + Angle(Angle), 16 + } 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 19 + #[serde(deny_unknown_fields)] 20 + pub enum SketchDimension { 21 + Linear { 22 + a: SketchEntityId, 23 + b: SketchEntityId, 24 + #[serde(with = "dimensioned_serde::length_si")] 25 + value: Length, 26 + kind: DimensionKind, 27 + }, 28 + Radius { 29 + target: SketchEntityId, 30 + #[serde(with = "dimensioned_serde::length_si")] 31 + value: Length, 32 + kind: DimensionKind, 33 + }, 34 + Diameter { 35 + target: SketchEntityId, 36 + #[serde(with = "dimensioned_serde::length_si")] 37 + value: Length, 38 + kind: DimensionKind, 39 + }, 40 + Angular { 41 + a: SketchEntityId, 42 + b: SketchEntityId, 43 + #[serde(with = "dimensioned_serde::angle_si")] 44 + value: Angle, 45 + kind: DimensionKind, 46 + }, 47 + } 48 + 49 + impl SketchDimension { 50 + #[must_use] 51 + pub const fn kind(&self) -> DimensionKind { 52 + match *self { 53 + Self::Linear { kind, .. } 54 + | Self::Radius { kind, .. } 55 + | Self::Diameter { kind, .. } 56 + | Self::Angular { kind, .. } => kind, 57 + } 58 + } 59 + 60 + #[must_use] 61 + pub const fn value(&self) -> DimensionValue { 62 + match *self { 63 + Self::Linear { value, .. } 64 + | Self::Radius { value, .. } 65 + | Self::Diameter { value, .. } => DimensionValue::Length(value), 66 + Self::Angular { value, .. } => DimensionValue::Angle(value), 67 + } 68 + } 69 + 70 + #[must_use] 71 + pub const fn references(&self) -> DimensionRefs { 72 + match *self { 73 + Self::Linear { a, b, .. } | Self::Angular { a, b, .. } => { 74 + DimensionRefs([Some(a), Some(b)]) 75 + } 76 + Self::Radius { target, .. } | Self::Diameter { target, .. } => { 77 + DimensionRefs([Some(target), None]) 78 + } 79 + } 80 + } 81 + 82 + pub fn with_value(self, value: DimensionValue) -> Result<Self, DimensionValueMismatch> { 83 + match (self, value) { 84 + (Self::Linear { a, b, kind, .. }, DimensionValue::Length(value)) => { 85 + Ok(Self::Linear { a, b, value, kind }) 86 + } 87 + (Self::Radius { target, kind, .. }, DimensionValue::Length(value)) => { 88 + Ok(Self::Radius { 89 + target, 90 + value, 91 + kind, 92 + }) 93 + } 94 + (Self::Diameter { target, kind, .. }, DimensionValue::Length(value)) => { 95 + Ok(Self::Diameter { 96 + target, 97 + value, 98 + kind, 99 + }) 100 + } 101 + (Self::Angular { a, b, kind, .. }, DimensionValue::Angle(value)) => { 102 + Ok(Self::Angular { a, b, value, kind }) 103 + } 104 + _ => Err(DimensionValueMismatch), 105 + } 106 + } 107 + } 108 + 109 + #[derive(Copy, Clone, Debug, PartialEq)] 110 + pub struct DimensionRefs([Option<SketchEntityId>; 2]); 111 + 112 + impl IntoIterator for DimensionRefs { 113 + type Item = SketchEntityId; 114 + type IntoIter = core::iter::Flatten<core::array::IntoIter<Option<SketchEntityId>, 2>>; 115 + 116 + fn into_iter(self) -> Self::IntoIter { 117 + self.0.into_iter().flatten() 118 + } 119 + } 120 + 121 + #[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] 122 + #[error("dimension value kind does not match variant")] 123 + pub struct DimensionValueMismatch;
+35
crates/bone-document/src/sketch/edit.rs
··· 1 + use bone_types::{SketchDimensionId, SketchEntityId, SketchParameterId, SketchRelationId}; 2 + 3 + use super::dimension::{DimensionValue, SketchDimension}; 4 + use super::entity::SketchEntity; 5 + use super::parameter::SketchParameter; 6 + use super::relation::SketchRelation; 7 + 8 + #[derive(Copy, Clone, Debug, PartialEq)] 9 + pub enum SketchEdit { 10 + AddEntity(SketchEntity), 11 + AddRelation(SketchRelation), 12 + AddDimension(SketchDimension), 13 + AddParameter(SketchParameter), 14 + DeleteEntity(SketchEntityId), 15 + DeleteRelation(SketchRelationId), 16 + DeleteDimension(SketchDimensionId), 17 + DeleteParameter(SketchParameterId), 18 + SetConstruction { 19 + id: SketchEntityId, 20 + for_construction: bool, 21 + }, 22 + UpdateDimensionValue { 23 + id: SketchDimensionId, 24 + value: DimensionValue, 25 + }, 26 + } 27 + 28 + #[derive(Copy, Clone, Debug, PartialEq)] 29 + pub enum EditOutcome { 30 + Entity(SketchEntityId), 31 + Relation(SketchRelationId), 32 + Dimension(SketchDimensionId), 33 + Parameter(SketchParameterId), 34 + None, 35 + }
+254
crates/bone-document/src/sketch/entity.rs
··· 1 + use bone_types::{Length, Point2, SketchEntityId, dimensioned_serde}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 5 + #[serde(deny_unknown_fields)] 6 + pub struct PointData { 7 + at: Point2, 8 + } 9 + 10 + impl PointData { 11 + #[must_use] 12 + pub const fn new(at: Point2) -> Self { 13 + Self { at } 14 + } 15 + 16 + #[must_use] 17 + pub const fn at(self) -> Point2 { 18 + self.at 19 + } 20 + 21 + #[must_use] 22 + pub const fn for_construction(self) -> bool { 23 + true 24 + } 25 + } 26 + 27 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 28 + #[serde(deny_unknown_fields)] 29 + pub struct LineData { 30 + a: SketchEntityId, 31 + b: SketchEntityId, 32 + for_construction: bool, 33 + } 34 + 35 + impl LineData { 36 + #[must_use] 37 + pub const fn new(a: SketchEntityId, b: SketchEntityId, for_construction: bool) -> Self { 38 + Self { 39 + a, 40 + b, 41 + for_construction, 42 + } 43 + } 44 + 45 + #[must_use] 46 + pub const fn a(self) -> SketchEntityId { 47 + self.a 48 + } 49 + 50 + #[must_use] 51 + pub const fn b(self) -> SketchEntityId { 52 + self.b 53 + } 54 + 55 + #[must_use] 56 + pub const fn for_construction(self) -> bool { 57 + self.for_construction 58 + } 59 + 60 + #[must_use] 61 + pub const fn with_construction(self, for_construction: bool) -> Self { 62 + Self { 63 + for_construction, 64 + ..self 65 + } 66 + } 67 + } 68 + 69 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 70 + #[serde(deny_unknown_fields)] 71 + pub struct ArcData { 72 + center: SketchEntityId, 73 + start: SketchEntityId, 74 + end: SketchEntityId, 75 + for_construction: bool, 76 + } 77 + 78 + impl ArcData { 79 + #[must_use] 80 + pub const fn new( 81 + center: SketchEntityId, 82 + start: SketchEntityId, 83 + end: SketchEntityId, 84 + for_construction: bool, 85 + ) -> Self { 86 + Self { 87 + center, 88 + start, 89 + end, 90 + for_construction, 91 + } 92 + } 93 + 94 + #[must_use] 95 + pub const fn center(self) -> SketchEntityId { 96 + self.center 97 + } 98 + 99 + #[must_use] 100 + pub const fn start(self) -> SketchEntityId { 101 + self.start 102 + } 103 + 104 + #[must_use] 105 + pub const fn end(self) -> SketchEntityId { 106 + self.end 107 + } 108 + 109 + #[must_use] 110 + pub const fn for_construction(self) -> bool { 111 + self.for_construction 112 + } 113 + 114 + #[must_use] 115 + pub const fn with_construction(self, for_construction: bool) -> Self { 116 + Self { 117 + for_construction, 118 + ..self 119 + } 120 + } 121 + } 122 + 123 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 124 + #[serde(deny_unknown_fields)] 125 + pub struct CircleData { 126 + center: SketchEntityId, 127 + #[serde(with = "dimensioned_serde::length_si")] 128 + radius: Length, 129 + for_construction: bool, 130 + } 131 + 132 + impl CircleData { 133 + #[must_use] 134 + pub const fn new(center: SketchEntityId, radius: Length, for_construction: bool) -> Self { 135 + Self { 136 + center, 137 + radius, 138 + for_construction, 139 + } 140 + } 141 + 142 + #[must_use] 143 + pub const fn center(self) -> SketchEntityId { 144 + self.center 145 + } 146 + 147 + #[must_use] 148 + pub const fn radius(self) -> Length { 149 + self.radius 150 + } 151 + 152 + #[must_use] 153 + pub const fn for_construction(self) -> bool { 154 + self.for_construction 155 + } 156 + 157 + #[must_use] 158 + pub const fn with_construction(self, for_construction: bool) -> Self { 159 + Self { 160 + for_construction, 161 + ..self 162 + } 163 + } 164 + } 165 + 166 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 167 + pub enum SketchEntityKind { 168 + Point, 169 + Line, 170 + Arc, 171 + Circle, 172 + } 173 + 174 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 175 + pub enum SketchEntity { 176 + Point(PointData), 177 + Line(LineData), 178 + Arc(ArcData), 179 + Circle(CircleData), 180 + } 181 + 182 + impl SketchEntity { 183 + #[must_use] 184 + pub const fn point(at: Point2) -> Self { 185 + Self::Point(PointData::new(at)) 186 + } 187 + 188 + #[must_use] 189 + pub const fn line(a: SketchEntityId, b: SketchEntityId, for_construction: bool) -> Self { 190 + Self::Line(LineData::new(a, b, for_construction)) 191 + } 192 + 193 + #[must_use] 194 + pub const fn arc( 195 + center: SketchEntityId, 196 + start: SketchEntityId, 197 + end: SketchEntityId, 198 + for_construction: bool, 199 + ) -> Self { 200 + Self::Arc(ArcData::new(center, start, end, for_construction)) 201 + } 202 + 203 + #[must_use] 204 + pub const fn circle(center: SketchEntityId, radius: Length, for_construction: bool) -> Self { 205 + Self::Circle(CircleData::new(center, radius, for_construction)) 206 + } 207 + 208 + #[must_use] 209 + pub const fn for_construction(&self) -> bool { 210 + match *self { 211 + Self::Point(p) => p.for_construction(), 212 + Self::Line(l) => l.for_construction(), 213 + Self::Arc(a) => a.for_construction(), 214 + Self::Circle(c) => c.for_construction(), 215 + } 216 + } 217 + 218 + #[must_use] 219 + pub const fn is_point(&self) -> bool { 220 + matches!(self, Self::Point(_)) 221 + } 222 + 223 + #[must_use] 224 + pub const fn kind(&self) -> SketchEntityKind { 225 + match *self { 226 + Self::Point(_) => SketchEntityKind::Point, 227 + Self::Line(_) => SketchEntityKind::Line, 228 + Self::Arc(_) => SketchEntityKind::Arc, 229 + Self::Circle(_) => SketchEntityKind::Circle, 230 + } 231 + } 232 + 233 + #[must_use] 234 + pub const fn references(&self) -> EntityRefs { 235 + match *self { 236 + Self::Point(_) => EntityRefs([None, None, None]), 237 + Self::Line(l) => EntityRefs([Some(l.a()), Some(l.b()), None]), 238 + Self::Arc(a) => EntityRefs([Some(a.center()), Some(a.start()), Some(a.end())]), 239 + Self::Circle(c) => EntityRefs([Some(c.center()), None, None]), 240 + } 241 + } 242 + } 243 + 244 + #[derive(Copy, Clone, Debug, PartialEq)] 245 + pub struct EntityRefs([Option<SketchEntityId>; 3]); 246 + 247 + impl IntoIterator for EntityRefs { 248 + type Item = SketchEntityId; 249 + type IntoIter = core::iter::Flatten<core::array::IntoIter<Option<SketchEntityId>, 3>>; 250 + 251 + fn into_iter(self) -> Self::IntoIter { 252 + self.0.into_iter().flatten() 253 + } 254 + }
+1611
crates/bone-document/src/sketch/mod.rs
··· 1 + use std::collections::{BTreeSet, HashMap}; 2 + use std::sync::Arc; 3 + 4 + use bone_types::{ 5 + Length, Point2, SketchDimensionId, SketchEntityId, SketchParameterId, SketchPlaneBasis, 6 + SketchRelationId, 7 + }; 8 + use serde::{Deserialize, Serialize}; 9 + use slotmap::SlotMap; 10 + 11 + pub mod dimension; 12 + pub mod edit; 13 + pub mod entity; 14 + pub mod parameter; 15 + pub mod relation; 16 + pub mod solve; 17 + 18 + pub use dimension::{ 19 + DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch, SketchDimension, 20 + }; 21 + pub use edit::{EditOutcome, SketchEdit}; 22 + pub use entity::{ 23 + ArcData, CircleData, EntityRefs, LineData, PointData, SketchEntity, SketchEntityKind, 24 + }; 25 + pub use parameter::SketchParameter; 26 + pub use relation::{RelationRefs, SketchRelation}; 27 + pub use solve::{DragError, Mapping as SketchSolveMapping, SketchDofReport}; 28 + 29 + type EntityMap = SlotMap<SketchEntityId, SketchEntity>; 30 + type RelationMap = SlotMap<SketchRelationId, SketchRelation>; 31 + type DimensionMap = SlotMap<SketchDimensionId, SketchDimension>; 32 + type ParameterMap = SlotMap<SketchParameterId, SketchParameter>; 33 + 34 + #[derive(Clone, Debug, Serialize, Deserialize)] 35 + #[serde(deny_unknown_fields)] 36 + pub struct Sketch { 37 + plane: SketchPlaneBasis, 38 + entities: Arc<EntityMap>, 39 + entity_order: Arc<Vec<SketchEntityId>>, 40 + relations: Arc<RelationMap>, 41 + relation_order: Arc<Vec<SketchRelationId>>, 42 + dimensions: Arc<DimensionMap>, 43 + dimension_order: Arc<Vec<SketchDimensionId>>, 44 + parameters: Arc<ParameterMap>, 45 + parameter_order: Arc<Vec<SketchParameterId>>, 46 + } 47 + 48 + impl Sketch { 49 + #[must_use] 50 + pub fn new(plane: SketchPlaneBasis) -> Self { 51 + Self { 52 + plane, 53 + entities: Arc::new(SlotMap::with_key()), 54 + entity_order: Arc::new(Vec::new()), 55 + relations: Arc::new(SlotMap::with_key()), 56 + relation_order: Arc::new(Vec::new()), 57 + dimensions: Arc::new(SlotMap::with_key()), 58 + dimension_order: Arc::new(Vec::new()), 59 + parameters: Arc::new(SlotMap::with_key()), 60 + parameter_order: Arc::new(Vec::new()), 61 + } 62 + } 63 + 64 + #[must_use] 65 + pub fn plane(&self) -> SketchPlaneBasis { 66 + self.plane 67 + } 68 + 69 + #[must_use] 70 + pub fn entities(&self) -> &EntityMap { 71 + &self.entities 72 + } 73 + 74 + #[must_use] 75 + pub fn relations(&self) -> &RelationMap { 76 + &self.relations 77 + } 78 + 79 + #[must_use] 80 + pub fn dimensions(&self) -> &DimensionMap { 81 + &self.dimensions 82 + } 83 + 84 + #[must_use] 85 + pub fn parameters(&self) -> &ParameterMap { 86 + &self.parameters 87 + } 88 + 89 + #[must_use] 90 + pub fn entity_order(&self) -> &[SketchEntityId] { 91 + &self.entity_order 92 + } 93 + 94 + #[must_use] 95 + pub fn relation_order(&self) -> &[SketchRelationId] { 96 + &self.relation_order 97 + } 98 + 99 + #[must_use] 100 + pub fn dimension_order(&self) -> &[SketchDimensionId] { 101 + &self.dimension_order 102 + } 103 + 104 + #[must_use] 105 + pub fn parameter_order(&self) -> &[SketchParameterId] { 106 + &self.parameter_order 107 + } 108 + 109 + fn entries_equal<K, V>(order: &[K], lhs: &SlotMap<K, V>, rhs: &SlotMap<K, V>) -> bool 110 + where 111 + K: slotmap::Key, 112 + V: PartialEq, 113 + { 114 + order.iter().all(|id| lhs.get(*id) == rhs.get(*id)) 115 + } 116 + 117 + pub fn validate(&self) -> Result<(), SketchEditError> { 118 + ensure_order_matches("entities", &self.entity_order, &self.entities)?; 119 + ensure_order_matches("relations", &self.relation_order, &self.relations)?; 120 + ensure_order_matches("dimensions", &self.dimension_order, &self.dimensions)?; 121 + ensure_order_matches("parameters", &self.parameter_order, &self.parameters)?; 122 + 123 + self.entity_order.iter().copied().try_for_each(|id| { 124 + let entity = *self.require_entity(id)?; 125 + validate_entity_shape(entity)?; 126 + entity 127 + .references() 128 + .into_iter() 129 + .try_for_each(|reference| self.require_point(reference)) 130 + })?; 131 + 132 + self.relation_order.iter().copied().try_for_each(|id| { 133 + let rel = *self 134 + .relations 135 + .get(id) 136 + .ok_or(SketchEditError::RelationNotFound(id))?; 137 + self.validate_relation(rel) 138 + })?; 139 + 140 + self.dimension_order.iter().copied().try_for_each(|id| { 141 + let dim = *self 142 + .dimensions 143 + .get(id) 144 + .ok_or(SketchEditError::DimensionNotFound(id))?; 145 + self.validate_dimension(dim) 146 + }) 147 + } 148 + 149 + pub fn apply(self, edit: SketchEdit) -> Result<(Self, EditOutcome), SketchEditError> { 150 + let result = match edit { 151 + SketchEdit::AddEntity(entity) => { 152 + entity 153 + .references() 154 + .into_iter() 155 + .try_for_each(|ref_id| self.require_point(ref_id))?; 156 + validate_entity_shape(entity)?; 157 + Ok(self.add_entity(entity)) 158 + } 159 + SketchEdit::AddRelation(rel) => self.add_relation(rel), 160 + SketchEdit::AddDimension(dim) => self.add_dimension(dim), 161 + SketchEdit::AddParameter(p) => Ok(self.add_parameter(p)), 162 + SketchEdit::DeleteEntity(id) => self.delete_entity(id), 163 + SketchEdit::DeleteRelation(id) => self.delete_relation(id), 164 + SketchEdit::DeleteDimension(id) => self.delete_dimension(id), 165 + SketchEdit::DeleteParameter(id) => self.delete_parameter(id), 166 + SketchEdit::SetConstruction { 167 + id, 168 + for_construction, 169 + } => self.set_construction(id, for_construction), 170 + SketchEdit::UpdateDimensionValue { id, value } => self.update_dimension(id, value), 171 + }; 172 + if let Ok((ref sketch, _)) = result { 173 + sketch.assert_invariants(); 174 + } 175 + result 176 + } 177 + 178 + fn assert_invariants(&self) { 179 + debug_assert_eq!(self.entity_order.len(), self.entities.len()); 180 + debug_assert_eq!(self.relation_order.len(), self.relations.len()); 181 + debug_assert_eq!(self.dimension_order.len(), self.dimensions.len()); 182 + debug_assert_eq!(self.parameter_order.len(), self.parameters.len()); 183 + debug_assert!( 184 + self.entity_order 185 + .iter() 186 + .all(|id| self.entities.contains_key(*id)) 187 + ); 188 + debug_assert!( 189 + self.relation_order 190 + .iter() 191 + .all(|id| self.relations.contains_key(*id)) 192 + ); 193 + debug_assert!( 194 + self.dimension_order 195 + .iter() 196 + .all(|id| self.dimensions.contains_key(*id)) 197 + ); 198 + debug_assert!( 199 + self.parameter_order 200 + .iter() 201 + .all(|id| self.parameters.contains_key(*id)) 202 + ); 203 + } 204 + 205 + pub fn apply_all<I>(&self, edits: I) -> Result<(Self, Vec<EditOutcome>), SketchEditError> 206 + where 207 + I: IntoIterator<Item = SketchEdit>, 208 + { 209 + edits.into_iter().try_fold( 210 + (self.clone(), Vec::new()), 211 + |(sketch, mut outcomes), edit| { 212 + let (next, outcome) = sketch.apply(edit)?; 213 + outcomes.push(outcome); 214 + Ok((next, outcomes)) 215 + }, 216 + ) 217 + } 218 + 219 + fn require_entity(&self, id: SketchEntityId) -> Result<&SketchEntity, SketchEditError> { 220 + self.entities 221 + .get(id) 222 + .ok_or(SketchEditError::EntityNotFound(id)) 223 + } 224 + 225 + fn require_point(&self, id: SketchEntityId) -> Result<(), SketchEditError> { 226 + if self.require_entity(id)?.is_point() { 227 + Ok(()) 228 + } else { 229 + Err(SketchEditError::ExpectedPoint(id)) 230 + } 231 + } 232 + 233 + fn kind_of(&self, id: SketchEntityId) -> Result<SketchEntityKind, SketchEditError> { 234 + self.require_entity(id).map(SketchEntity::kind) 235 + } 236 + 237 + fn validate_relation(&self, rel: SketchRelation) -> Result<(), SketchEditError> { 238 + use SketchEntityKind as K; 239 + if let Some((a, b)) = rel.pair() 240 + && a == b 241 + { 242 + return Err(SketchEditError::SelfReferencingRelation(rel)); 243 + } 244 + let ok = match rel { 245 + SketchRelation::Coincident(a, b) => { 246 + matches!( 247 + (self.kind_of(a)?, self.kind_of(b)?), 248 + (K::Point, _) | (_, K::Point) 249 + ) 250 + } 251 + SketchRelation::Horizontal(a) | SketchRelation::Vertical(a) => { 252 + self.kind_of(a)? == K::Line 253 + } 254 + SketchRelation::Parallel(a, b) | SketchRelation::Perpendicular(a, b) => { 255 + self.kind_of(a)? == K::Line && self.kind_of(b)? == K::Line 256 + } 257 + SketchRelation::Tangent(a, b) => { 258 + let (ka, kb) = (self.kind_of(a)?, self.kind_of(b)?); 259 + let curve = |k| matches!(k, K::Line | K::Arc | K::Circle); 260 + let round = |k| matches!(k, K::Arc | K::Circle); 261 + curve(ka) && curve(kb) && (round(ka) || round(kb)) 262 + } 263 + SketchRelation::Equal(a, b) => matches!( 264 + (self.kind_of(a)?, self.kind_of(b)?), 265 + (K::Line, K::Line) | (K::Arc | K::Circle, K::Arc | K::Circle) 266 + ), 267 + SketchRelation::Concentric(a, b) => { 268 + matches!(self.kind_of(a)?, K::Arc | K::Circle) 269 + && matches!(self.kind_of(b)?, K::Arc | K::Circle) 270 + } 271 + SketchRelation::Fix(a) => { 272 + self.kind_of(a)?; 273 + true 274 + } 275 + }; 276 + if ok { 277 + Ok(()) 278 + } else { 279 + Err(SketchEditError::InvalidRelationOperands(rel)) 280 + } 281 + } 282 + 283 + fn validate_dimension(&self, dim: SketchDimension) -> Result<(), SketchEditError> { 284 + use SketchEntityKind as K; 285 + let ok = match dim { 286 + SketchDimension::Linear { a, b, .. } => { 287 + self.kind_of(a)? == K::Point && self.kind_of(b)? == K::Point 288 + } 289 + SketchDimension::Radius { target, .. } | SketchDimension::Diameter { target, .. } => { 290 + matches!(self.kind_of(target)?, K::Arc | K::Circle) 291 + } 292 + SketchDimension::Angular { a, b, .. } => { 293 + self.kind_of(a)? == K::Line && self.kind_of(b)? == K::Line 294 + } 295 + }; 296 + if ok { 297 + Ok(()) 298 + } else { 299 + Err(SketchEditError::InvalidDimensionOperands(dim)) 300 + } 301 + } 302 + 303 + fn add_entity(self, entity: SketchEntity) -> (Self, EditOutcome) { 304 + let (id, entities) = insert(self.entities, entity); 305 + let entity_order = push(self.entity_order, id); 306 + let next = Self { 307 + entities, 308 + entity_order, 309 + ..self 310 + }; 311 + (next, EditOutcome::Entity(id)) 312 + } 313 + 314 + fn add_relation(self, rel: SketchRelation) -> Result<(Self, EditOutcome), SketchEditError> { 315 + self.validate_relation(rel)?; 316 + let (id, relations) = insert(self.relations, rel); 317 + let relation_order = push(self.relation_order, id); 318 + let next = Self { 319 + relations, 320 + relation_order, 321 + ..self 322 + }; 323 + Ok((next, EditOutcome::Relation(id))) 324 + } 325 + 326 + fn add_dimension(self, dim: SketchDimension) -> Result<(Self, EditOutcome), SketchEditError> { 327 + self.validate_dimension(dim)?; 328 + let (id, dimensions) = insert(self.dimensions, dim); 329 + let dimension_order = push(self.dimension_order, id); 330 + let next = Self { 331 + dimensions, 332 + dimension_order, 333 + ..self 334 + }; 335 + Ok((next, EditOutcome::Dimension(id))) 336 + } 337 + 338 + fn add_parameter(self, parameter: SketchParameter) -> (Self, EditOutcome) { 339 + let (id, parameters) = insert(self.parameters, parameter); 340 + let parameter_order = push(self.parameter_order, id); 341 + let next = Self { 342 + parameters, 343 + parameter_order, 344 + ..self 345 + }; 346 + (next, EditOutcome::Parameter(id)) 347 + } 348 + 349 + fn delete_entity(self, id: SketchEntityId) -> Result<(Self, EditOutcome), SketchEditError> { 350 + if !self.entities.contains_key(id) { 351 + return Err(SketchEditError::EntityNotFound(id)); 352 + } 353 + let closure = entity_closure(&self.entities, id); 354 + let entities = remove_many(self.entities, closure.iter().copied()); 355 + let entity_order = retain(&self.entity_order, |eid| !closure.contains(eid)); 356 + let touches_closure_rel = 357 + |rel: &SketchRelation| rel.references().into_iter().any(|e| closure.contains(&e)); 358 + let touches_closure_dim = 359 + |dim: &SketchDimension| dim.references().into_iter().any(|e| closure.contains(&e)); 360 + let (relations, dropped_relations) = remove_where(self.relations, touches_closure_rel); 361 + let relation_order = retain(&self.relation_order, |rid| !dropped_relations.contains(rid)); 362 + let (dimensions, dropped_dimensions) = remove_where(self.dimensions, touches_closure_dim); 363 + let dimension_order = retain(&self.dimension_order, |did| { 364 + !dropped_dimensions.contains(did) 365 + }); 366 + let next = Self { 367 + entities, 368 + entity_order, 369 + relations, 370 + relation_order, 371 + dimensions, 372 + dimension_order, 373 + ..self 374 + }; 375 + Ok((next, EditOutcome::None)) 376 + } 377 + 378 + fn delete_relation(self, id: SketchRelationId) -> Result<(Self, EditOutcome), SketchEditError> { 379 + if !self.relations.contains_key(id) { 380 + return Err(SketchEditError::RelationNotFound(id)); 381 + } 382 + let relations = remove_one(self.relations, id); 383 + let relation_order = retain(&self.relation_order, |rid| *rid != id); 384 + let next = Self { 385 + relations, 386 + relation_order, 387 + ..self 388 + }; 389 + Ok((next, EditOutcome::None)) 390 + } 391 + 392 + fn delete_dimension( 393 + self, 394 + id: SketchDimensionId, 395 + ) -> Result<(Self, EditOutcome), SketchEditError> { 396 + if !self.dimensions.contains_key(id) { 397 + return Err(SketchEditError::DimensionNotFound(id)); 398 + } 399 + let dimensions = remove_one(self.dimensions, id); 400 + let dimension_order = retain(&self.dimension_order, |did| *did != id); 401 + let next = Self { 402 + dimensions, 403 + dimension_order, 404 + ..self 405 + }; 406 + Ok((next, EditOutcome::None)) 407 + } 408 + 409 + fn delete_parameter( 410 + self, 411 + id: SketchParameterId, 412 + ) -> Result<(Self, EditOutcome), SketchEditError> { 413 + if !self.parameters.contains_key(id) { 414 + return Err(SketchEditError::ParameterNotFound(id)); 415 + } 416 + let parameters = remove_one(self.parameters, id); 417 + let parameter_order = retain(&self.parameter_order, |pid| *pid != id); 418 + let next = Self { 419 + parameters, 420 + parameter_order, 421 + ..self 422 + }; 423 + Ok((next, EditOutcome::None)) 424 + } 425 + 426 + fn set_construction( 427 + self, 428 + id: SketchEntityId, 429 + for_construction: bool, 430 + ) -> Result<(Self, EditOutcome), SketchEditError> { 431 + let entity = *self.require_entity(id)?; 432 + let updated = match entity { 433 + SketchEntity::Point(_) => return Err(SketchEditError::PointAlwaysConstruction), 434 + SketchEntity::Line(l) => SketchEntity::Line(l.with_construction(for_construction)), 435 + SketchEntity::Arc(a) => SketchEntity::Arc(a.with_construction(for_construction)), 436 + SketchEntity::Circle(c) => SketchEntity::Circle(c.with_construction(for_construction)), 437 + }; 438 + if updated == entity { 439 + return Ok((self, EditOutcome::None)); 440 + } 441 + let entities = replace(self.entities, id, updated); 442 + let next = Self { entities, ..self }; 443 + Ok((next, EditOutcome::None)) 444 + } 445 + 446 + fn update_dimension( 447 + self, 448 + id: SketchDimensionId, 449 + value: DimensionValue, 450 + ) -> Result<(Self, EditOutcome), SketchEditError> { 451 + let existing = *self 452 + .dimensions 453 + .get(id) 454 + .ok_or(SketchEditError::DimensionNotFound(id))?; 455 + if existing.kind() == DimensionKind::Driven { 456 + return Err(SketchEditError::DimensionIsDriven(id)); 457 + } 458 + let updated = existing 459 + .with_value(value) 460 + .map_err(|_| SketchEditError::DimensionValueMismatch { id })?; 461 + if updated == existing { 462 + return Ok((self, EditOutcome::None)); 463 + } 464 + let dimensions = replace(self.dimensions, id, updated); 465 + let next = Self { dimensions, ..self }; 466 + Ok((next, EditOutcome::None)) 467 + } 468 + 469 + #[must_use] 470 + pub(crate) fn with_point_positions(self, updates: &HashMap<SketchEntityId, Point2>) -> Self { 471 + if updates.is_empty() { 472 + return self; 473 + } 474 + let entities = updates 475 + .iter() 476 + .fold(unwrap_arc(self.entities), |mut acc, (id, pt)| { 477 + if let Some(slot @ SketchEntity::Point(_)) = acc.get_mut(*id) { 478 + *slot = SketchEntity::Point(PointData::new(*pt)); 479 + } 480 + acc 481 + }); 482 + Self { 483 + entities: Arc::new(entities), 484 + ..self 485 + } 486 + } 487 + 488 + #[must_use] 489 + pub(crate) fn with_circle_radii(self, updates: &HashMap<SketchEntityId, Length>) -> Self { 490 + if updates.is_empty() { 491 + return self; 492 + } 493 + let entities = updates 494 + .iter() 495 + .fold(unwrap_arc(self.entities), |mut acc, (id, r)| { 496 + if let Some(SketchEntity::Circle(existing)) = acc.get(*id).copied() 497 + && let Some(slot) = acc.get_mut(*id) 498 + { 499 + *slot = SketchEntity::Circle(CircleData::new( 500 + existing.center(), 501 + *r, 502 + existing.for_construction(), 503 + )); 504 + } 505 + acc 506 + }); 507 + Self { 508 + entities: Arc::new(entities), 509 + ..self 510 + } 511 + } 512 + 513 + #[must_use] 514 + pub(crate) fn with_driven_dimension_values( 515 + self, 516 + updates: &[(SketchDimensionId, DimensionValue)], 517 + ) -> Self { 518 + if updates.is_empty() { 519 + return self; 520 + } 521 + let dimensions = 522 + updates 523 + .iter() 524 + .copied() 525 + .fold(unwrap_arc(self.dimensions), |mut acc, (id, v)| { 526 + let existing = acc.get(id).copied(); 527 + if let Some(dim) = existing 528 + && dim.kind() == DimensionKind::Driven 529 + && let Ok(next) = dim.with_value(v) 530 + && let Some(slot) = acc.get_mut(id) 531 + { 532 + *slot = next; 533 + } 534 + acc 535 + }); 536 + Self { 537 + dimensions: Arc::new(dimensions), 538 + ..self 539 + } 540 + } 541 + } 542 + 543 + impl PartialEq for Sketch { 544 + fn eq(&self, other: &Self) -> bool { 545 + self.plane == other.plane 546 + && *self.entity_order == *other.entity_order 547 + && Self::entries_equal(&self.entity_order, &self.entities, &other.entities) 548 + && *self.relation_order == *other.relation_order 549 + && Self::entries_equal(&self.relation_order, &self.relations, &other.relations) 550 + && *self.dimension_order == *other.dimension_order 551 + && Self::entries_equal(&self.dimension_order, &self.dimensions, &other.dimensions) 552 + && *self.parameter_order == *other.parameter_order 553 + && Self::entries_equal(&self.parameter_order, &self.parameters, &other.parameters) 554 + } 555 + } 556 + 557 + fn ensure_order_matches<K, V>( 558 + container: &'static str, 559 + order: &[K], 560 + map: &SlotMap<K, V>, 561 + ) -> Result<(), SketchEditError> 562 + where 563 + K: slotmap::Key + Ord, 564 + { 565 + let order_set: BTreeSet<K> = order.iter().copied().collect(); 566 + let has_duplicates = order_set.len() != order.len(); 567 + let same_size = order.len() == map.len(); 568 + let covers_map = order_set.iter().all(|id| map.contains_key(*id)); 569 + if has_duplicates || !same_size || !covers_map { 570 + Err(SketchEditError::OrderMismatch { container }) 571 + } else { 572 + Ok(()) 573 + } 574 + } 575 + 576 + fn validate_entity_shape(entity: SketchEntity) -> Result<(), SketchEditError> { 577 + let ok = match entity { 578 + SketchEntity::Point(_) => true, 579 + SketchEntity::Line(l) => l.a() != l.b(), 580 + SketchEntity::Arc(a) => { 581 + a.center() != a.start() && a.start() != a.end() && a.center() != a.end() 582 + } 583 + SketchEntity::Circle(c) => c.radius() > Length::default(), 584 + }; 585 + if ok { 586 + Ok(()) 587 + } else { 588 + Err(SketchEditError::DegenerateEntity(entity)) 589 + } 590 + } 591 + 592 + fn insert<K, V>(map: Arc<SlotMap<K, V>>, value: V) -> (K, Arc<SlotMap<K, V>>) 593 + where 594 + K: slotmap::Key, 595 + V: Clone, 596 + { 597 + let mut owned = unwrap_arc(map); 598 + let id = owned.insert(value); 599 + (id, Arc::new(owned)) 600 + } 601 + 602 + fn push<T: Clone>(list: Arc<Vec<T>>, value: T) -> Arc<Vec<T>> { 603 + let mut owned = unwrap_arc(list); 604 + owned.push(value); 605 + Arc::new(owned) 606 + } 607 + 608 + fn retain<T, F>(list: &Arc<Vec<T>>, keep: F) -> Arc<Vec<T>> 609 + where 610 + T: Clone, 611 + F: Fn(&T) -> bool, 612 + { 613 + Arc::new(list.iter().filter(|t| keep(t)).cloned().collect()) 614 + } 615 + 616 + fn remove_one<K, V>(map: Arc<SlotMap<K, V>>, id: K) -> Arc<SlotMap<K, V>> 617 + where 618 + K: slotmap::Key, 619 + V: Clone, 620 + { 621 + let mut owned = unwrap_arc(map); 622 + owned.remove(id); 623 + Arc::new(owned) 624 + } 625 + 626 + fn remove_many<K, V, I>(map: Arc<SlotMap<K, V>>, ids: I) -> Arc<SlotMap<K, V>> 627 + where 628 + K: slotmap::Key, 629 + V: Clone, 630 + I: IntoIterator<Item = K>, 631 + { 632 + let owned = ids.into_iter().fold(unwrap_arc(map), |mut acc, id| { 633 + acc.remove(id); 634 + acc 635 + }); 636 + Arc::new(owned) 637 + } 638 + 639 + fn remove_where<K, V, F>(map: Arc<SlotMap<K, V>>, predicate: F) -> (Arc<SlotMap<K, V>>, BTreeSet<K>) 640 + where 641 + K: slotmap::Key + Ord, 642 + V: Clone, 643 + F: Fn(&V) -> bool, 644 + { 645 + let dropped: BTreeSet<K> = map 646 + .iter() 647 + .filter_map(|(k, v)| predicate(v).then_some(k)) 648 + .collect(); 649 + let next = remove_many(map, dropped.iter().copied()); 650 + (next, dropped) 651 + } 652 + 653 + fn replace<K, V>(map: Arc<SlotMap<K, V>>, id: K, value: V) -> Arc<SlotMap<K, V>> 654 + where 655 + K: slotmap::Key, 656 + V: Clone, 657 + { 658 + let mut owned = unwrap_arc(map); 659 + if let Some(slot) = owned.get_mut(id) { 660 + *slot = value; 661 + } 662 + Arc::new(owned) 663 + } 664 + 665 + fn unwrap_arc<T: Clone>(arc: Arc<T>) -> T { 666 + Arc::try_unwrap(arc).unwrap_or_else(|shared| (*shared).clone()) 667 + } 668 + 669 + fn entity_closure(entities: &EntityMap, seed: SketchEntityId) -> BTreeSet<SketchEntityId> { 670 + grow_closure(entities, BTreeSet::from([seed])) 671 + } 672 + 673 + fn grow_closure( 674 + entities: &EntityMap, 675 + closure: BTreeSet<SketchEntityId>, 676 + ) -> BTreeSet<SketchEntityId> { 677 + let grown: BTreeSet<SketchEntityId> = entities 678 + .iter() 679 + .filter_map(|(id, entity)| { 680 + entity 681 + .references() 682 + .into_iter() 683 + .any(|r| closure.contains(&r)) 684 + .then_some(id) 685 + }) 686 + .chain(closure.iter().copied()) 687 + .collect(); 688 + if grown == closure { 689 + closure 690 + } else { 691 + grow_closure(entities, grown) 692 + } 693 + } 694 + 695 + #[derive(Debug, Clone, PartialEq, thiserror::Error)] 696 + pub enum SketchEditError { 697 + #[error("entity not found: {0:?}")] 698 + EntityNotFound(SketchEntityId), 699 + #[error("relation not found: {0:?}")] 700 + RelationNotFound(SketchRelationId), 701 + #[error("dimension not found: {0:?}")] 702 + DimensionNotFound(SketchDimensionId), 703 + #[error("parameter not found: {0:?}")] 704 + ParameterNotFound(SketchParameterId), 705 + #[error("expected point entity: {0:?}")] 706 + ExpectedPoint(SketchEntityId), 707 + #[error("cannot toggle construction on a point")] 708 + PointAlwaysConstruction, 709 + #[error("dimension {id:?} value kind does not match variant")] 710 + DimensionValueMismatch { id: SketchDimensionId }, 711 + #[error("cannot update driven dimension: {0:?}")] 712 + DimensionIsDriven(SketchDimensionId), 713 + #[error("invalid relation operands: {0:?}")] 714 + InvalidRelationOperands(SketchRelation), 715 + #[error("invalid dimension operands: {0:?}")] 716 + InvalidDimensionOperands(SketchDimension), 717 + #[error("entity is degenerate: {0:?}")] 718 + DegenerateEntity(SketchEntity), 719 + #[error("self-referencing relation: {0:?}")] 720 + SelfReferencingRelation(SketchRelation), 721 + #[error("{container} order list is out of step with its slotmap")] 722 + OrderMismatch { container: &'static str }, 723 + } 724 + 725 + #[cfg(test)] 726 + mod tests { 727 + use super::*; 728 + use bone_types::{ 729 + Angle, Length, Parameter, Point2, Point3, Tolerance, UnitVec3, degree, millimeter, 730 + }; 731 + 732 + fn plane() -> SketchPlaneBasis { 733 + let Ok(basis) = SketchPlaneBasis::new( 734 + Point3::origin(), 735 + UnitVec3::x_axis(), 736 + UnitVec3::y_axis(), 737 + Tolerance::new(1e-9), 738 + ) else { 739 + panic!("xy plane basis is orthogonal"); 740 + }; 741 + basis 742 + } 743 + 744 + fn len_mm(v: f64) -> Length { 745 + Length::new::<millimeter>(v) 746 + } 747 + 748 + fn angle_deg(v: f64) -> Angle { 749 + Angle::new::<degree>(v) 750 + } 751 + 752 + fn rectangle_script() -> Vec<SketchEdit> { 753 + vec![ 754 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 755 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), 756 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))), 757 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))), 758 + ] 759 + } 760 + 761 + fn apply_script(sketch: &Sketch, edits: Vec<SketchEdit>) -> (Sketch, Vec<EditOutcome>) { 762 + let Ok(pair) = sketch.apply_all(edits) else { 763 + panic!("fixture edits apply cleanly"); 764 + }; 765 + pair 766 + } 767 + 768 + #[test] 769 + fn point_is_always_construction() { 770 + let p = SketchEntity::point(Point2::from_mm(0.0, 0.0)); 771 + assert!(p.for_construction()); 772 + } 773 + 774 + #[test] 775 + fn new_sketch_is_empty() { 776 + let s = Sketch::new(plane()); 777 + assert!(s.entities().is_empty()); 778 + assert!(s.relations().is_empty()); 779 + assert!(s.dimensions().is_empty()); 780 + assert!(s.parameters().is_empty()); 781 + assert!(s.entity_order().is_empty()); 782 + } 783 + 784 + #[test] 785 + fn add_point_records_insertion_order() { 786 + let (sketch, outcomes) = apply_script(&Sketch::new(plane()), rectangle_script()); 787 + let ids: Vec<_> = outcomes 788 + .iter() 789 + .map(|o| match *o { 790 + EditOutcome::Entity(id) => id, 791 + _ => panic!("expected entity outcomes"), 792 + }) 793 + .collect(); 794 + assert_eq!(sketch.entity_order(), ids.as_slice()); 795 + assert_eq!(sketch.entities().len(), 4); 796 + } 797 + 798 + #[test] 799 + fn add_line_requires_point_endpoints() { 800 + let mut sketch = Sketch::new(plane()); 801 + let Ok((s1, EditOutcome::Entity(a))) = sketch.apply(SketchEdit::AddEntity( 802 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 803 + )) else { 804 + panic!("add point"); 805 + }; 806 + let Ok((s2, EditOutcome::Entity(b))) = s1.apply(SketchEdit::AddEntity( 807 + SketchEntity::point(Point2::from_mm(1.0, 0.0)), 808 + )) else { 809 + panic!("add point"); 810 + }; 811 + let Ok((s3, EditOutcome::Entity(_))) = 812 + s2.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 813 + else { 814 + panic!("add line"); 815 + }; 816 + sketch = s3; 817 + let bad = sketch.apply(SketchEdit::AddEntity(SketchEntity::line( 818 + a, 819 + SketchEntityId::default(), 820 + false, 821 + ))); 822 + assert!(matches!(bad, Err(SketchEditError::EntityNotFound(_)))); 823 + } 824 + 825 + #[test] 826 + fn add_line_rejects_non_point_endpoint() { 827 + let Ok((s1, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 828 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 829 + )) else { 830 + panic!("add point"); 831 + }; 832 + let Ok((s2, EditOutcome::Entity(center))) = s1.apply(SketchEdit::AddEntity( 833 + SketchEntity::point(Point2::from_mm(2.0, 0.0)), 834 + )) else { 835 + panic!("add center"); 836 + }; 837 + let Ok((s3, EditOutcome::Entity(circle))) = s2.apply(SketchEdit::AddEntity( 838 + SketchEntity::circle(center, len_mm(1.0), false), 839 + )) else { 840 + panic!("add circle"); 841 + }; 842 + let bad = s3.apply(SketchEdit::AddEntity(SketchEntity::line(a, circle, false))); 843 + assert!(matches!(bad, Err(SketchEditError::ExpectedPoint(_)))); 844 + } 845 + 846 + #[test] 847 + fn delete_entity_cascades_dependents() { 848 + let s = Sketch::new(plane()); 849 + let Ok((s, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 850 + Point2::from_mm(0.0, 0.0), 851 + ))) else { 852 + panic!("a"); 853 + }; 854 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 855 + Point2::from_mm(1.0, 0.0), 856 + ))) else { 857 + panic!("b"); 858 + }; 859 + let Ok((s, EditOutcome::Entity(line))) = 860 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 861 + else { 862 + panic!("line"); 863 + }; 864 + let Ok((s, EditOutcome::Relation(_))) = 865 + s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line))) 866 + else { 867 + panic!("rel"); 868 + }; 869 + let Ok((s, EditOutcome::Dimension(_))) = 870 + s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 871 + a, 872 + b, 873 + value: len_mm(10.0), 874 + kind: DimensionKind::Driving, 875 + })) 876 + else { 877 + panic!("dim"); 878 + }; 879 + let Ok((after, EditOutcome::None)) = s.apply(SketchEdit::DeleteEntity(a)) else { 880 + panic!("delete"); 881 + }; 882 + assert_eq!(after.entities().len(), 1); 883 + assert!(after.entities().contains_key(b)); 884 + assert!(!after.entities().contains_key(line)); 885 + assert!(after.relations().is_empty()); 886 + assert!(after.dimensions().is_empty()); 887 + assert_eq!(after.entity_order(), [b]); 888 + assert!(after.relation_order().is_empty()); 889 + assert!(after.dimension_order().is_empty()); 890 + } 891 + 892 + #[test] 893 + fn delete_entity_errors_on_unknown() { 894 + let s = Sketch::new(plane()); 895 + let bad = s.apply(SketchEdit::DeleteEntity(SketchEntityId::default())); 896 + assert!(matches!(bad, Err(SketchEditError::EntityNotFound(_)))); 897 + } 898 + 899 + #[test] 900 + fn set_construction_refuses_point() { 901 + let Ok((s, EditOutcome::Entity(p))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 902 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 903 + )) else { 904 + panic!("p"); 905 + }; 906 + let bad = s.apply(SketchEdit::SetConstruction { 907 + id: p, 908 + for_construction: false, 909 + }); 910 + assert!(matches!(bad, Err(SketchEditError::PointAlwaysConstruction))); 911 + } 912 + 913 + #[test] 914 + fn set_construction_toggles_line() { 915 + let s = Sketch::new(plane()); 916 + let Ok((s, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 917 + Point2::from_mm(0.0, 0.0), 918 + ))) else { 919 + panic!("a"); 920 + }; 921 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 922 + Point2::from_mm(1.0, 0.0), 923 + ))) else { 924 + panic!("b"); 925 + }; 926 + let Ok((s, EditOutcome::Entity(line))) = 927 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 928 + else { 929 + panic!("line"); 930 + }; 931 + assert!(!s.entities()[line].for_construction()); 932 + let Ok((s, _)) = s.apply(SketchEdit::SetConstruction { 933 + id: line, 934 + for_construction: true, 935 + }) else { 936 + panic!("toggle"); 937 + }; 938 + assert!(s.entities()[line].for_construction()); 939 + } 940 + 941 + #[test] 942 + fn update_dimension_value_rejects_unit_mismatch() { 943 + let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 944 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 945 + )) else { 946 + panic!("a"); 947 + }; 948 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 949 + Point2::from_mm(1.0, 0.0), 950 + ))) else { 951 + panic!("b"); 952 + }; 953 + let Ok((s, EditOutcome::Dimension(id))) = 954 + s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 955 + a, 956 + b, 957 + value: len_mm(1.0), 958 + kind: DimensionKind::Driving, 959 + })) 960 + else { 961 + panic!("dim"); 962 + }; 963 + let bad = s.apply(SketchEdit::UpdateDimensionValue { 964 + id, 965 + value: DimensionValue::Angle(angle_deg(90.0)), 966 + }); 967 + assert!(matches!( 968 + bad, 969 + Err(SketchEditError::DimensionValueMismatch { .. }) 970 + )); 971 + } 972 + 973 + #[test] 974 + fn update_dimension_value_replaces_length() { 975 + let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 976 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 977 + )) else { 978 + panic!("a"); 979 + }; 980 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 981 + Point2::from_mm(1.0, 0.0), 982 + ))) else { 983 + panic!("b"); 984 + }; 985 + let Ok((s, EditOutcome::Dimension(id))) = 986 + s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 987 + a, 988 + b, 989 + value: len_mm(1.0), 990 + kind: DimensionKind::Driving, 991 + })) 992 + else { 993 + panic!("dim"); 994 + }; 995 + let Ok((s, _)) = s.apply(SketchEdit::UpdateDimensionValue { 996 + id, 997 + value: DimensionValue::Length(len_mm(12.5)), 998 + }) else { 999 + panic!("update"); 1000 + }; 1001 + let SketchDimension::Linear { value, .. } = s.dimensions()[id] else { 1002 + panic!("variant unchanged"); 1003 + }; 1004 + assert!((value.get::<millimeter>() - 12.5).abs() < 1e-12); 1005 + } 1006 + 1007 + #[test] 1008 + fn identical_scripts_produce_equal_sketches() { 1009 + let script = rich_script(); 1010 + let a = Sketch::new(plane()) 1011 + .apply_all(script.clone()) 1012 + .map(|(s, _)| s); 1013 + let b = Sketch::new(plane()).apply_all(script).map(|(s, _)| s); 1014 + let (Ok(a), Ok(b)) = (a, b) else { 1015 + panic!("scripts apply cleanly"); 1016 + }; 1017 + assert_eq!(a, b); 1018 + } 1019 + 1020 + #[test] 1021 + fn relation_edit_shares_entity_storage() { 1022 + let (base, _) = apply_script(&Sketch::new(plane()), rectangle_script()); 1023 + let before = base.clone(); 1024 + let Ok((base_with_rel, _)) = base.apply(SketchEdit::AddRelation(SketchRelation::Fix( 1025 + before.entity_order()[0], 1026 + ))) else { 1027 + panic!("relation"); 1028 + }; 1029 + assert!(Arc::ptr_eq(&before.entities, &base_with_rel.entities)); 1030 + assert!(Arc::ptr_eq( 1031 + &before.entity_order, 1032 + &base_with_rel.entity_order 1033 + )); 1034 + assert!(Arc::ptr_eq(&before.dimensions, &base_with_rel.dimensions)); 1035 + assert!(!Arc::ptr_eq(&before.relations, &base_with_rel.relations)); 1036 + } 1037 + 1038 + #[test] 1039 + fn add_parameter_roundtrips() { 1040 + let s = Sketch::new(plane()); 1041 + let Ok((s, EditOutcome::Parameter(id))) = s.apply(SketchEdit::AddParameter( 1042 + SketchParameter::new(Parameter::new(0.5)), 1043 + )) else { 1044 + panic!("param"); 1045 + }; 1046 + assert!((s.parameters()[id].value().value() - 0.5).abs() < f64::EPSILON); 1047 + let Ok((s, _)) = s.apply(SketchEdit::DeleteParameter(id)) else { 1048 + panic!("delete param"); 1049 + }; 1050 + assert!(s.parameters().is_empty()); 1051 + assert!(s.parameter_order().is_empty()); 1052 + } 1053 + 1054 + fn rich_script() -> Vec<SketchEdit> { 1055 + let mut edits = rectangle_script(); 1056 + edits.extend([ 1057 + SketchEdit::AddParameter(SketchParameter::new(Parameter::new(0.0))), 1058 + SketchEdit::AddParameter(SketchParameter::new(Parameter::new(1.0))), 1059 + ]); 1060 + edits 1061 + } 1062 + 1063 + fn two_points() -> (Sketch, SketchEntityId, SketchEntityId) { 1064 + let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 1065 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 1066 + )) else { 1067 + panic!("a"); 1068 + }; 1069 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 1070 + Point2::from_mm(1.0, 0.0), 1071 + ))) else { 1072 + panic!("b"); 1073 + }; 1074 + (s, a, b) 1075 + } 1076 + 1077 + #[test] 1078 + fn horizontal_on_point_is_rejected() { 1079 + let (s, a, _) = two_points(); 1080 + let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(a))); 1081 + assert!(matches!( 1082 + bad, 1083 + Err(SketchEditError::InvalidRelationOperands(_)) 1084 + )); 1085 + } 1086 + 1087 + #[test] 1088 + fn tangent_between_two_lines_is_rejected() { 1089 + let (s, a, b) = two_points(); 1090 + let Ok((s, EditOutcome::Entity(c))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 1091 + Point2::from_mm(0.0, 1.0), 1092 + ))) else { 1093 + panic!("c"); 1094 + }; 1095 + let Ok((s, EditOutcome::Entity(l1))) = 1096 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1097 + else { 1098 + panic!("l1"); 1099 + }; 1100 + let Ok((s, EditOutcome::Entity(l2))) = 1101 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, c, false))) 1102 + else { 1103 + panic!("l2"); 1104 + }; 1105 + let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Tangent(l1, l2))); 1106 + assert!(matches!( 1107 + bad, 1108 + Err(SketchEditError::InvalidRelationOperands(_)) 1109 + )); 1110 + } 1111 + 1112 + #[test] 1113 + fn equal_cross_kind_is_rejected() { 1114 + let (s, a, b) = two_points(); 1115 + let Ok((s, EditOutcome::Entity(line))) = 1116 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1117 + else { 1118 + panic!("line"); 1119 + }; 1120 + let Ok((s, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity( 1121 + SketchEntity::circle(a, len_mm(1.0), false), 1122 + )) else { 1123 + panic!("circle"); 1124 + }; 1125 + let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Equal(line, circle))); 1126 + assert!(matches!( 1127 + bad, 1128 + Err(SketchEditError::InvalidRelationOperands(_)) 1129 + )); 1130 + } 1131 + 1132 + #[test] 1133 + fn coincident_point_on_line_is_accepted() { 1134 + let (s, a, b) = two_points(); 1135 + let Ok((s, EditOutcome::Entity(line))) = 1136 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1137 + else { 1138 + panic!("line"); 1139 + }; 1140 + let Ok((s2, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Coincident(a, line))) 1141 + else { 1142 + panic!("coincident"); 1143 + }; 1144 + assert_eq!(s2.relations().len(), 1); 1145 + } 1146 + 1147 + #[test] 1148 + fn linear_dimension_on_non_point_is_rejected() { 1149 + let (s, a, b) = two_points(); 1150 + let Ok((s, EditOutcome::Entity(line))) = 1151 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1152 + else { 1153 + panic!("line"); 1154 + }; 1155 + let bad = s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 1156 + a: line, 1157 + b: a, 1158 + value: len_mm(1.0), 1159 + kind: DimensionKind::Driving, 1160 + })); 1161 + assert!(matches!( 1162 + bad, 1163 + Err(SketchEditError::InvalidDimensionOperands(_)) 1164 + )); 1165 + } 1166 + 1167 + #[test] 1168 + fn driven_dimension_cannot_be_updated() { 1169 + let (s, a, b) = two_points(); 1170 + let Ok((s, EditOutcome::Dimension(id))) = 1171 + s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 1172 + a, 1173 + b, 1174 + value: len_mm(1.0), 1175 + kind: DimensionKind::Driven, 1176 + })) 1177 + else { 1178 + panic!("dim"); 1179 + }; 1180 + let bad = s.apply(SketchEdit::UpdateDimensionValue { 1181 + id, 1182 + value: DimensionValue::Length(len_mm(2.0)), 1183 + }); 1184 + assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_)))); 1185 + } 1186 + 1187 + #[test] 1188 + fn apply_all_leaves_caller_unchanged_on_error() { 1189 + let (base, _) = apply_script(&Sketch::new(plane()), rectangle_script()); 1190 + let snapshot = base.clone(); 1191 + let bad_edit = SketchEdit::AddRelation(SketchRelation::Horizontal(base.entity_order()[0])); 1192 + let result = base.apply_all([bad_edit]); 1193 + assert!(matches!( 1194 + result, 1195 + Err(SketchEditError::InvalidRelationOperands(_)) 1196 + )); 1197 + assert_eq!(base, snapshot); 1198 + } 1199 + 1200 + #[test] 1201 + fn line_with_same_endpoints_is_rejected() { 1202 + let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 1203 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 1204 + )) else { 1205 + panic!("a"); 1206 + }; 1207 + let bad = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, a, false))); 1208 + assert!(matches!(bad, Err(SketchEditError::DegenerateEntity(_)))); 1209 + } 1210 + 1211 + #[test] 1212 + fn arc_with_repeated_endpoints_is_rejected() { 1213 + let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 1214 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 1215 + )) else { 1216 + panic!("a"); 1217 + }; 1218 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 1219 + Point2::from_mm(1.0, 0.0), 1220 + ))) else { 1221 + panic!("b"); 1222 + }; 1223 + let bad = s.apply(SketchEdit::AddEntity(SketchEntity::arc(a, b, a, false))); 1224 + assert!(matches!(bad, Err(SketchEditError::DegenerateEntity(_)))); 1225 + } 1226 + 1227 + #[test] 1228 + fn self_referencing_relation_is_rejected() { 1229 + let (s, a, b) = two_points(); 1230 + let Ok((s, EditOutcome::Entity(line))) = 1231 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1232 + else { 1233 + panic!("line"); 1234 + }; 1235 + let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Parallel( 1236 + line, line, 1237 + ))); 1238 + assert!(matches!( 1239 + bad, 1240 + Err(SketchEditError::SelfReferencingRelation(_)) 1241 + )); 1242 + } 1243 + 1244 + #[test] 1245 + fn coincident_self_reference_is_rejected() { 1246 + let (s, a, _) = two_points(); 1247 + let bad = s.apply(SketchEdit::AddRelation(SketchRelation::Coincident(a, a))); 1248 + assert!(matches!( 1249 + bad, 1250 + Err(SketchEditError::SelfReferencingRelation(_)) 1251 + )); 1252 + } 1253 + 1254 + #[test] 1255 + fn set_construction_noop_shares_storage() { 1256 + let (s, a, b) = two_points(); 1257 + let Ok((s, EditOutcome::Entity(line))) = 1258 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, true))) 1259 + else { 1260 + panic!("line"); 1261 + }; 1262 + let before = s.clone(); 1263 + let Ok((after, _)) = s.apply(SketchEdit::SetConstruction { 1264 + id: line, 1265 + for_construction: true, 1266 + }) else { 1267 + panic!("set"); 1268 + }; 1269 + assert!(Arc::ptr_eq(&before.entities, &after.entities)); 1270 + } 1271 + 1272 + #[test] 1273 + fn circle_with_non_positive_radius_is_rejected() { 1274 + let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply( 1275 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1276 + ) else { 1277 + panic!("center"); 1278 + }; 1279 + let zero = s.clone().apply(SketchEdit::AddEntity(SketchEntity::circle( 1280 + center, 1281 + len_mm(0.0), 1282 + false, 1283 + ))); 1284 + assert!(matches!(zero, Err(SketchEditError::DegenerateEntity(_)))); 1285 + let negative = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 1286 + center, 1287 + len_mm(-1.0), 1288 + false, 1289 + ))); 1290 + assert!(matches!( 1291 + negative, 1292 + Err(SketchEditError::DegenerateEntity(_)) 1293 + )); 1294 + } 1295 + 1296 + #[test] 1297 + fn delete_relation_errors_on_unknown() { 1298 + let s = Sketch::new(plane()); 1299 + let bad = s.apply(SketchEdit::DeleteRelation(SketchRelationId::default())); 1300 + assert!(matches!(bad, Err(SketchEditError::RelationNotFound(_)))); 1301 + } 1302 + 1303 + #[test] 1304 + fn delete_dimension_errors_on_unknown() { 1305 + let s = Sketch::new(plane()); 1306 + let bad = s.apply(SketchEdit::DeleteDimension(SketchDimensionId::default())); 1307 + assert!(matches!(bad, Err(SketchEditError::DimensionNotFound(_)))); 1308 + } 1309 + 1310 + #[test] 1311 + fn delete_parameter_errors_on_unknown() { 1312 + let s = Sketch::new(plane()); 1313 + let bad = s.apply(SketchEdit::DeleteParameter(SketchParameterId::default())); 1314 + assert!(matches!(bad, Err(SketchEditError::ParameterNotFound(_)))); 1315 + } 1316 + 1317 + #[test] 1318 + fn update_dimension_errors_on_unknown() { 1319 + let s = Sketch::new(plane()); 1320 + let bad = s.apply(SketchEdit::UpdateDimensionValue { 1321 + id: SketchDimensionId::default(), 1322 + value: DimensionValue::Length(len_mm(1.0)), 1323 + }); 1324 + assert!(matches!(bad, Err(SketchEditError::DimensionNotFound(_)))); 1325 + } 1326 + 1327 + #[test] 1328 + fn driven_radius_cannot_be_updated() { 1329 + let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply( 1330 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1331 + ) else { 1332 + panic!("center"); 1333 + }; 1334 + let Ok((s, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity( 1335 + SketchEntity::circle(center, len_mm(1.0), false), 1336 + )) else { 1337 + panic!("circle"); 1338 + }; 1339 + let Ok((s, EditOutcome::Dimension(id))) = 1340 + s.apply(SketchEdit::AddDimension(SketchDimension::Radius { 1341 + target: circle, 1342 + value: len_mm(1.0), 1343 + kind: DimensionKind::Driven, 1344 + })) 1345 + else { 1346 + panic!("dim"); 1347 + }; 1348 + let bad = s.apply(SketchEdit::UpdateDimensionValue { 1349 + id, 1350 + value: DimensionValue::Length(len_mm(2.0)), 1351 + }); 1352 + assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_)))); 1353 + } 1354 + 1355 + #[test] 1356 + fn driven_diameter_cannot_be_updated() { 1357 + let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply( 1358 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1359 + ) else { 1360 + panic!("center"); 1361 + }; 1362 + let Ok((s, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity( 1363 + SketchEntity::circle(center, len_mm(1.0), false), 1364 + )) else { 1365 + panic!("circle"); 1366 + }; 1367 + let Ok((s, EditOutcome::Dimension(id))) = 1368 + s.apply(SketchEdit::AddDimension(SketchDimension::Diameter { 1369 + target: circle, 1370 + value: len_mm(2.0), 1371 + kind: DimensionKind::Driven, 1372 + })) 1373 + else { 1374 + panic!("dim"); 1375 + }; 1376 + let bad = s.apply(SketchEdit::UpdateDimensionValue { 1377 + id, 1378 + value: DimensionValue::Length(len_mm(4.0)), 1379 + }); 1380 + assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_)))); 1381 + } 1382 + 1383 + #[test] 1384 + fn driven_angular_cannot_be_updated() { 1385 + let (s, a, b) = two_points(); 1386 + let Ok((s, EditOutcome::Entity(c))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 1387 + Point2::from_mm(0.0, 1.0), 1388 + ))) else { 1389 + panic!("c"); 1390 + }; 1391 + let Ok((s, EditOutcome::Entity(l1))) = 1392 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1393 + else { 1394 + panic!("l1"); 1395 + }; 1396 + let Ok((s, EditOutcome::Entity(l2))) = 1397 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, c, false))) 1398 + else { 1399 + panic!("l2"); 1400 + }; 1401 + let Ok((s, EditOutcome::Dimension(id))) = 1402 + s.apply(SketchEdit::AddDimension(SketchDimension::Angular { 1403 + a: l1, 1404 + b: l2, 1405 + value: angle_deg(90.0), 1406 + kind: DimensionKind::Driven, 1407 + })) 1408 + else { 1409 + panic!("dim"); 1410 + }; 1411 + let bad = s.apply(SketchEdit::UpdateDimensionValue { 1412 + id, 1413 + value: DimensionValue::Angle(angle_deg(45.0)), 1414 + }); 1415 + assert!(matches!(bad, Err(SketchEditError::DimensionIsDriven(_)))); 1416 + } 1417 + 1418 + mod properties { 1419 + use super::*; 1420 + use proptest::prelude::*; 1421 + 1422 + #[derive(Copy, Clone, Debug)] 1423 + enum Step { 1424 + Point(i16, i16), 1425 + Parameter(i16), 1426 + Line { 1427 + ai: u8, 1428 + bi: u8, 1429 + }, 1430 + Circle { 1431 + ci: u8, 1432 + r: u16, 1433 + }, 1434 + Horizontal { 1435 + li: u8, 1436 + }, 1437 + LinearDim { 1438 + ai: u8, 1439 + bi: u8, 1440 + v: u16, 1441 + driven: bool, 1442 + }, 1443 + UpdateDim { 1444 + di: u8, 1445 + v: u16, 1446 + }, 1447 + Toggle { 1448 + ei: u8, 1449 + }, 1450 + DelEntity { 1451 + ei: u8, 1452 + }, 1453 + DelParam { 1454 + pi: u8, 1455 + }, 1456 + } 1457 + 1458 + fn arb_step() -> impl Strategy<Value = Step> { 1459 + prop_oneof![ 1460 + (any::<i16>(), any::<i16>()).prop_map(|(x, y)| Step::Point(x, y)), 1461 + any::<i16>().prop_map(Step::Parameter), 1462 + (any::<u8>(), any::<u8>()).prop_map(|(ai, bi)| Step::Line { ai, bi }), 1463 + (any::<u8>(), any::<u16>()).prop_map(|(ci, r)| Step::Circle { ci, r: r % 100 + 1 }), 1464 + any::<u8>().prop_map(|li| Step::Horizontal { li }), 1465 + (any::<u8>(), any::<u8>(), any::<u16>(), any::<bool>()) 1466 + .prop_map(|(ai, bi, v, driven)| Step::LinearDim { ai, bi, v, driven }), 1467 + (any::<u8>(), any::<u16>()).prop_map(|(di, v)| Step::UpdateDim { di, v }), 1468 + any::<u8>().prop_map(|ei| Step::Toggle { ei }), 1469 + any::<u8>().prop_map(|ei| Step::DelEntity { ei }), 1470 + any::<u8>().prop_map(|pi| Step::DelParam { pi }), 1471 + ] 1472 + } 1473 + 1474 + fn entities_of_kind(s: &Sketch, kind: SketchEntityKind) -> Vec<SketchEntityId> { 1475 + s.entity_order() 1476 + .iter() 1477 + .copied() 1478 + .filter(|id| s.entities()[*id].kind() == kind) 1479 + .collect() 1480 + } 1481 + 1482 + fn pick<T: Copy>(xs: &[T], i: u8) -> Option<T> { 1483 + if xs.is_empty() { 1484 + None 1485 + } else { 1486 + Some(xs[usize::from(i) % xs.len()]) 1487 + } 1488 + } 1489 + 1490 + fn pick_two_distinct<T: Copy + Eq>(xs: &[T], ai: u8, bi: u8) -> Option<(T, T)> { 1491 + if xs.len() < 2 { 1492 + return None; 1493 + } 1494 + let ai = usize::from(ai) % xs.len(); 1495 + let offset = usize::from(bi) % (xs.len() - 1) + 1; 1496 + let bi = (ai + offset) % xs.len(); 1497 + Some((xs[ai], xs[bi])) 1498 + } 1499 + 1500 + fn resolve_step(s: &Sketch, step: Step) -> Option<SketchEdit> { 1501 + match step { 1502 + Step::Point(x, y) => Some(SketchEdit::AddEntity(SketchEntity::point( 1503 + Point2::from_mm(f64::from(x), f64::from(y)), 1504 + ))), 1505 + Step::Parameter(v) => Some(SketchEdit::AddParameter(SketchParameter::new( 1506 + Parameter::new(f64::from(v)), 1507 + ))), 1508 + Step::Line { ai, bi } => { 1509 + let (a, b) = 1510 + pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?; 1511 + Some(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1512 + } 1513 + Step::Circle { ci, r } => { 1514 + let c = pick(&entities_of_kind(s, SketchEntityKind::Point), ci)?; 1515 + Some(SketchEdit::AddEntity(SketchEntity::circle( 1516 + c, 1517 + Length::new::<millimeter>(f64::from(r)), 1518 + false, 1519 + ))) 1520 + } 1521 + Step::Horizontal { li } => { 1522 + let l = pick(&entities_of_kind(s, SketchEntityKind::Line), li)?; 1523 + Some(SketchEdit::AddRelation(SketchRelation::Horizontal(l))) 1524 + } 1525 + Step::LinearDim { ai, bi, v, driven } => { 1526 + let (a, b) = 1527 + pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?; 1528 + let kind = if driven { 1529 + DimensionKind::Driven 1530 + } else { 1531 + DimensionKind::Driving 1532 + }; 1533 + Some(SketchEdit::AddDimension(SketchDimension::Linear { 1534 + a, 1535 + b, 1536 + value: Length::new::<millimeter>(f64::from(v)), 1537 + kind, 1538 + })) 1539 + } 1540 + Step::UpdateDim { di, v } => { 1541 + let id = pick(s.dimension_order(), di)?; 1542 + Some(SketchEdit::UpdateDimensionValue { 1543 + id, 1544 + value: DimensionValue::Length(Length::new::<millimeter>(f64::from(v))), 1545 + }) 1546 + } 1547 + Step::Toggle { ei } => { 1548 + let id = pick(s.entity_order(), ei)?; 1549 + Some(SketchEdit::SetConstruction { 1550 + id, 1551 + for_construction: true, 1552 + }) 1553 + } 1554 + Step::DelEntity { ei } => { 1555 + let id = pick(s.entity_order(), ei)?; 1556 + Some(SketchEdit::DeleteEntity(id)) 1557 + } 1558 + Step::DelParam { pi } => { 1559 + let id = pick(s.parameter_order(), pi)?; 1560 + Some(SketchEdit::DeleteParameter(id)) 1561 + } 1562 + } 1563 + } 1564 + 1565 + fn build_script(steps: Vec<Step>) -> Vec<SketchEdit> { 1566 + steps 1567 + .into_iter() 1568 + .fold( 1569 + (Sketch::new(plane()), Vec::new()), 1570 + |(sk, mut acc), step| match resolve_step(&sk, step) { 1571 + Some(edit) => match sk.clone().apply(edit) { 1572 + Ok((next, _)) => { 1573 + acc.push(edit); 1574 + (next, acc) 1575 + } 1576 + Err(_) => (sk, acc), 1577 + }, 1578 + None => (sk, acc), 1579 + }, 1580 + ) 1581 + .1 1582 + } 1583 + 1584 + proptest! { 1585 + #[test] 1586 + fn apply_all_is_deterministic(steps in prop::collection::vec(arb_step(), 0..40)) { 1587 + let script = build_script(steps); 1588 + let Ok((a, _)) = Sketch::new(plane()).apply_all(script.clone()) else { 1589 + unreachable!("build_script only emits successfully-applied edits"); 1590 + }; 1591 + let Ok((b, _)) = Sketch::new(plane()).apply_all(script) else { 1592 + unreachable!(); 1593 + }; 1594 + prop_assert_eq!(a, b); 1595 + } 1596 + 1597 + #[test] 1598 + fn apply_all_matches_apply_fold(steps in prop::collection::vec(arb_step(), 0..40)) { 1599 + let script = build_script(steps); 1600 + let Ok((via_all, _)) = Sketch::new(plane()).apply_all(script.clone()) else { 1601 + unreachable!(); 1602 + }; 1603 + let via_fold = script.into_iter().try_fold(Sketch::new(plane()), |acc, e| { 1604 + acc.apply(e).map(|(s, _)| s) 1605 + }); 1606 + let Ok(via_fold) = via_fold else { unreachable!(); }; 1607 + prop_assert_eq!(via_all, via_fold); 1608 + } 1609 + } 1610 + } 1611 + }
+25
crates/bone-document/src/sketch/parameter.rs
··· 1 + use bone_types::Parameter; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 5 + #[serde(deny_unknown_fields)] 6 + pub struct SketchParameter { 7 + value: Parameter, 8 + } 9 + 10 + impl SketchParameter { 11 + #[must_use] 12 + pub const fn new(value: Parameter) -> Self { 13 + Self { value } 14 + } 15 + 16 + #[must_use] 17 + pub const fn value(self) -> Parameter { 18 + self.value 19 + } 20 + 21 + #[must_use] 22 + pub const fn with_value(self, value: Parameter) -> Self { 23 + Self { value } 24 + } 25 + }
+55
crates/bone-document/src/sketch/relation.rs
··· 1 + use bone_types::SketchEntityId; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 5 + pub enum SketchRelation { 6 + Coincident(SketchEntityId, SketchEntityId), 7 + Horizontal(SketchEntityId), 8 + Vertical(SketchEntityId), 9 + Parallel(SketchEntityId, SketchEntityId), 10 + Perpendicular(SketchEntityId, SketchEntityId), 11 + Tangent(SketchEntityId, SketchEntityId), 12 + Equal(SketchEntityId, SketchEntityId), 13 + Concentric(SketchEntityId, SketchEntityId), 14 + Fix(SketchEntityId), 15 + } 16 + 17 + impl SketchRelation { 18 + #[must_use] 19 + pub const fn references(&self) -> RelationRefs { 20 + match *self { 21 + Self::Coincident(a, b) 22 + | Self::Parallel(a, b) 23 + | Self::Perpendicular(a, b) 24 + | Self::Tangent(a, b) 25 + | Self::Equal(a, b) 26 + | Self::Concentric(a, b) => RelationRefs([Some(a), Some(b)]), 27 + Self::Horizontal(a) | Self::Vertical(a) | Self::Fix(a) => RelationRefs([Some(a), None]), 28 + } 29 + } 30 + 31 + #[must_use] 32 + pub const fn pair(&self) -> Option<(SketchEntityId, SketchEntityId)> { 33 + match *self { 34 + Self::Coincident(a, b) 35 + | Self::Parallel(a, b) 36 + | Self::Perpendicular(a, b) 37 + | Self::Tangent(a, b) 38 + | Self::Equal(a, b) 39 + | Self::Concentric(a, b) => Some((a, b)), 40 + Self::Horizontal(_) | Self::Vertical(_) | Self::Fix(_) => None, 41 + } 42 + } 43 + } 44 + 45 + #[derive(Copy, Clone, Debug, PartialEq)] 46 + pub struct RelationRefs([Option<SketchEntityId>; 2]); 47 + 48 + impl IntoIterator for RelationRefs { 49 + type Item = SketchEntityId; 50 + type IntoIter = core::iter::Flatten<core::array::IntoIter<Option<SketchEntityId>, 2>>; 51 + 52 + fn into_iter(self) -> Self::IntoIter { 53 + self.0.into_iter().flatten() 54 + } 55 + }
+13
crates/bone-document/src/sketch/snapshots/bone_document__sketch__solve__tests__rectangle_sparsity.snap
··· 1 + --- 2 + source: crates/bone-document/src/sketch/solve.rs 3 + assertion_line: 692 4 + expression: pattern.display() 5 + --- 6 + .X.X.... 7 + .....X.X 8 + ..X.X... 9 + X.....X. 10 + X....... 11 + .X...... 12 + XXXX.... 13 + ..XXXX..
+1913
crates/bone-document/src/sketch/solve.rs
··· 1 + use std::collections::{BTreeMap, HashMap}; 2 + use std::time::Instant; 3 + 4 + use bone_solver::{ 5 + ConstraintSystem, CurveRadius, DofConfig, DofReport, LineHandle, NewtonConfig, PointHandle, 6 + Residual, SolverError, analyze_dof, analyze_dof_at, decompose, dedup_preserving_order, 7 + minimal_unsatisfiable_subset, solve_newton_decomposed, 8 + }; 9 + use bone_types::{ 10 + Angle, BudgetCeiling, DegreesOfFreedom, Length, Parameter, ParameterIndex, Point2, 11 + ResidualIndex, SketchDimensionId, SketchEntityId, SketchItemId, SketchRelationId, millimeter, 12 + radian, 13 + }; 14 + 15 + use super::{ 16 + DimensionKind, DimensionValue, Sketch, SketchDimension, SketchEntity, SketchEntityKind, 17 + SketchRelation, 18 + }; 19 + 20 + #[derive(Debug, Clone, PartialEq, thiserror::Error)] 21 + pub enum DragError { 22 + #[error("dragged entity {0:?} is not a Point")] 23 + NotAPoint(SketchEntityId), 24 + #[error("dragged entity {0:?} not found in sketch")] 25 + NotFound(SketchEntityId), 26 + #[error(transparent)] 27 + Solver(#[from] SolverError), 28 + } 29 + 30 + #[derive(Clone, Debug)] 31 + pub struct Mapping { 32 + points: BTreeMap<SketchEntityId, PointHandle>, 33 + radii: BTreeMap<SketchEntityId, ParameterIndex>, 34 + residual_origin: Vec<Origin>, 35 + } 36 + 37 + #[derive(Copy, Clone, Debug, PartialEq)] 38 + enum Origin { 39 + Relation(SketchRelationId), 40 + Dimension(SketchDimensionId), 41 + Intrinsic(SketchEntityId), 42 + } 43 + 44 + impl Mapping { 45 + #[must_use] 46 + pub fn point(&self, id: SketchEntityId) -> Option<PointHandle> { 47 + self.points.get(&id).copied() 48 + } 49 + 50 + #[must_use] 51 + pub fn radius(&self, id: SketchEntityId) -> Option<ParameterIndex> { 52 + self.radii.get(&id).copied() 53 + } 54 + 55 + #[must_use] 56 + pub fn item_of_residual(&self, idx: ResidualIndex) -> Option<SketchItemId> { 57 + self.residual_origin.get(idx.as_usize()).map(|o| match *o { 58 + Origin::Relation(id) => SketchItemId::Relation(id), 59 + Origin::Dimension(id) => SketchItemId::Dimension(id), 60 + Origin::Intrinsic(id) => SketchItemId::Entity(id), 61 + }) 62 + } 63 + 64 + #[must_use] 65 + pub fn items(&self, indices: impl IntoIterator<Item = ResidualIndex>) -> Vec<SketchItemId> { 66 + indices 67 + .into_iter() 68 + .filter_map(|i| self.item_of_residual(i)) 69 + .collect() 70 + } 71 + 72 + #[must_use] 73 + pub fn entity_of_parameter(&self, idx: ParameterIndex) -> Option<SketchEntityId> { 74 + self.points 75 + .iter() 76 + .find_map(|(id, h)| (h.x == idx || h.y == idx).then_some(*id)) 77 + .or_else(|| { 78 + self.radii 79 + .iter() 80 + .find_map(|(id, p)| (*p == idx).then_some(*id)) 81 + }) 82 + } 83 + } 84 + 85 + #[derive(Clone, Debug, PartialEq)] 86 + pub struct SketchDofReport { 87 + dof: DegreesOfFreedom, 88 + under_constrained: Vec<SketchEntityId>, 89 + over_constrained: Vec<SketchItemId>, 90 + redundant_consistent: Vec<SketchItemId>, 91 + } 92 + 93 + impl SketchDofReport { 94 + #[must_use] 95 + pub fn dof(&self) -> DegreesOfFreedom { 96 + self.dof 97 + } 98 + 99 + #[must_use] 100 + pub fn under_constrained(&self) -> &[SketchEntityId] { 101 + &self.under_constrained 102 + } 103 + 104 + #[must_use] 105 + pub fn over_constrained(&self) -> &[SketchItemId] { 106 + &self.over_constrained 107 + } 108 + 109 + #[must_use] 110 + pub fn redundant_consistent(&self) -> &[SketchItemId] { 111 + &self.redundant_consistent 112 + } 113 + 114 + fn from_solver_report(report: &DofReport, mapping: &Mapping) -> Self { 115 + Self { 116 + dof: report.dof(), 117 + under_constrained: dedup_preserving_order( 118 + report 119 + .under_constrained() 120 + .iter() 121 + .filter_map(|p| mapping.entity_of_parameter(*p)), 122 + ), 123 + over_constrained: dedup_preserving_order( 124 + mapping.items(report.over_constrained().iter().copied()), 125 + ), 126 + redundant_consistent: dedup_preserving_order( 127 + mapping.items(report.redundant_consistent().iter().copied()), 128 + ), 129 + } 130 + } 131 + } 132 + 133 + impl Sketch { 134 + #[must_use] 135 + pub fn lower(&self) -> (ConstraintSystem, Mapping) { 136 + let (parameters, points, radii) = self.entity_order().iter().fold( 137 + ( 138 + Vec::<Parameter>::new(), 139 + BTreeMap::<SketchEntityId, PointHandle>::new(), 140 + BTreeMap::<SketchEntityId, ParameterIndex>::new(), 141 + ), 142 + |(mut parameters, mut points, mut radii), id| { 143 + match self.entities()[*id] { 144 + SketchEntity::Point(p) => { 145 + let (x, y) = p.at().coords_mm(); 146 + let handle = PointHandle { 147 + x: push_parameter(&mut parameters, x), 148 + y: push_parameter(&mut parameters, y), 149 + }; 150 + points.insert(*id, handle); 151 + } 152 + SketchEntity::Circle(c) => { 153 + let idx = push_parameter(&mut parameters, c.radius().get::<millimeter>()); 154 + radii.insert(*id, idx); 155 + } 156 + SketchEntity::Line(_) | SketchEntity::Arc(_) => {} 157 + } 158 + (parameters, points, radii) 159 + }, 160 + ); 161 + 162 + let intrinsic_rows: Vec<(Residual, Origin)> = self 163 + .entity_order() 164 + .iter() 165 + .flat_map(|eid| { 166 + intrinsic_residuals(self, *eid, &points) 167 + .into_iter() 168 + .map(move |r| (r, Origin::Intrinsic(*eid))) 169 + }) 170 + .collect(); 171 + 172 + let relation_rows: Vec<(Residual, Origin)> = self 173 + .relation_order() 174 + .iter() 175 + .flat_map(|rid| { 176 + lower_relation(self, self.relations()[*rid], &points, &radii, &parameters) 177 + .into_iter() 178 + .map(move |r| (r, Origin::Relation(*rid))) 179 + }) 180 + .collect(); 181 + 182 + let dimension_rows: Vec<(Residual, Origin)> = self 183 + .dimension_order() 184 + .iter() 185 + .filter(|did| self.dimensions()[**did].kind() != DimensionKind::Driven) 186 + .flat_map(|did| { 187 + lower_dimension(self, self.dimensions()[*did], &points, &radii) 188 + .into_iter() 189 + .map(move |r| (r, Origin::Dimension(*did))) 190 + }) 191 + .collect(); 192 + 193 + let combined: Vec<(Residual, Origin)> = intrinsic_rows 194 + .into_iter() 195 + .chain(relation_rows) 196 + .chain(dimension_rows) 197 + .collect(); 198 + let residual_origin: Vec<Origin> = combined 199 + .iter() 200 + .flat_map(|(r, o)| std::iter::repeat_n(*o, r.rows())) 201 + .collect(); 202 + let residuals: Vec<Residual> = combined.into_iter().map(|(r, _)| r).collect(); 203 + let system = ConstraintSystem::new(parameters, residuals); 204 + let mapping = Mapping { 205 + points, 206 + radii, 207 + residual_origin, 208 + }; 209 + (system, mapping) 210 + } 211 + 212 + pub fn solve(&self) -> Result<Sketch, SolverError> { 213 + let (system, mapping) = self.lower(); 214 + let decomposition = decompose(&system); 215 + match solve_newton_decomposed(&system, &decomposition, NewtonConfig::DEFAULT) { 216 + Ok(parameters) => { 217 + let report = analyze_dof_at(&system, &parameters, DofConfig::DEFAULT); 218 + if report.over_constrained().is_empty() { 219 + Ok(self.clone().apply_solution(&parameters, &mapping)) 220 + } else { 221 + Err(narrow_over_defined( 222 + &system, 223 + &mapping, 224 + report.over_constrained(), 225 + )) 226 + } 227 + } 228 + Err(SolverError::NoSolutionFound { last }) => { 229 + let report = analyze_dof(&system, DofConfig::DEFAULT); 230 + if report.over_constrained().is_empty() { 231 + Err(SolverError::NoSolutionFound { last }) 232 + } else { 233 + Err(narrow_over_defined( 234 + &system, 235 + &mapping, 236 + report.over_constrained(), 237 + )) 238 + } 239 + } 240 + Err(other) => Err(other), 241 + } 242 + } 243 + 244 + pub fn solve_with_drag( 245 + &self, 246 + dragged: SketchEntityId, 247 + target: Point2, 248 + budget: BudgetCeiling, 249 + ) -> Result<Sketch, DragError> { 250 + let started = Instant::now(); 251 + let entity = self 252 + .entities() 253 + .get(dragged) 254 + .copied() 255 + .ok_or(DragError::NotFound(dragged))?; 256 + if !matches!(entity, SketchEntity::Point(_)) { 257 + return Err(DragError::NotAPoint(dragged)); 258 + } 259 + let warm = self 260 + .clone() 261 + .with_point_positions(&HashMap::from([(dragged, target)])); 262 + let (system, mapping) = warm.lower(); 263 + let Some(handle) = mapping.point(dragged) else { 264 + unreachable!("Point entity must lower to a PointHandle in the mapping") 265 + }; 266 + let (tx, ty) = target.coords_mm(); 267 + let system = system.with_extra_residuals(vec![ 268 + Residual::Pin { 269 + param: handle.x, 270 + target: tx, 271 + }, 272 + Residual::Pin { 273 + param: handle.y, 274 + target: ty, 275 + }, 276 + ]); 277 + let decomposition = decompose(&system); 278 + let remaining = BudgetCeiling::new(budget.duration().saturating_sub(started.elapsed())); 279 + let cfg = NewtonConfig { 280 + budget: Some(remaining), 281 + ..NewtonConfig::DEFAULT 282 + }; 283 + let parameters = solve_newton_decomposed(&system, &decomposition, cfg)?; 284 + Ok(warm.apply_solution(&parameters, &mapping)) 285 + } 286 + 287 + #[must_use] 288 + pub fn analyze_dof(&self) -> SketchDofReport { 289 + let (system, mapping) = self.lower(); 290 + let decomposition = decompose(&system); 291 + let report = match solve_newton_decomposed(&system, &decomposition, NewtonConfig::DEFAULT) { 292 + Ok(parameters) => analyze_dof_at(&system, &parameters, DofConfig::DEFAULT), 293 + Err(_) => analyze_dof(&system, DofConfig::DEFAULT), 294 + }; 295 + SketchDofReport::from_solver_report(&report, &mapping) 296 + } 297 + 298 + #[must_use] 299 + pub fn apply_solution(self, parameters: &[Parameter], mapping: &Mapping) -> Self { 300 + let point_updates: HashMap<SketchEntityId, bone_types::Point2> = mapping 301 + .points 302 + .iter() 303 + .map(|(id, h)| { 304 + let p = bone_types::Point2::from_mm( 305 + parameters[h.x.as_usize()].value(), 306 + parameters[h.y.as_usize()].value(), 307 + ); 308 + (*id, p) 309 + }) 310 + .collect(); 311 + let radius_updates: HashMap<SketchEntityId, Length> = mapping 312 + .radii 313 + .iter() 314 + .map(|(id, idx)| { 315 + ( 316 + *id, 317 + Length::new::<millimeter>(parameters[idx.as_usize()].value()), 318 + ) 319 + }) 320 + .collect(); 321 + let next = self 322 + .with_point_positions(&point_updates) 323 + .with_circle_radii(&radius_updates); 324 + recompute_driven(next) 325 + } 326 + } 327 + 328 + fn narrow_over_defined( 329 + system: &ConstraintSystem, 330 + mapping: &Mapping, 331 + over_flagged: &[ResidualIndex], 332 + ) -> SolverError { 333 + let mus = minimal_unsatisfiable_subset(system, over_flagged, DofConfig::DEFAULT); 334 + let rows = if mus.is_empty() { over_flagged } else { &mus }; 335 + SolverError::OverDefined { 336 + conflicts: dedup_preserving_order(mapping.items(rows.iter().copied())), 337 + } 338 + } 339 + 340 + fn push_parameter(out: &mut Vec<Parameter>, value: f64) -> ParameterIndex { 341 + let idx = ParameterIndex::new(u32::try_from(out.len()).unwrap_or(u32::MAX)); 342 + out.push(Parameter::new(value)); 343 + idx 344 + } 345 + 346 + fn intrinsic_residuals( 347 + sketch: &Sketch, 348 + id: SketchEntityId, 349 + points: &BTreeMap<SketchEntityId, PointHandle>, 350 + ) -> Vec<Residual> { 351 + match sketch.entities().get(id) { 352 + Some(SketchEntity::Arc(a)) => match ( 353 + points.get(&a.center()).copied(), 354 + points.get(&a.start()).copied(), 355 + points.get(&a.end()).copied(), 356 + ) { 357 + (Some(center), Some(spoke), Some(end)) => vec![Residual::CoincidentPointCurve { 358 + point: end, 359 + curve: CurveRadius::FromSpoke { center, spoke }, 360 + }], 361 + _ => Vec::new(), 362 + }, 363 + _ => Vec::new(), 364 + } 365 + } 366 + 367 + fn lower_relation( 368 + sketch: &Sketch, 369 + relation: SketchRelation, 370 + points: &BTreeMap<SketchEntityId, PointHandle>, 371 + radii: &BTreeMap<SketchEntityId, ParameterIndex>, 372 + parameters: &[Parameter], 373 + ) -> Vec<Residual> { 374 + match relation { 375 + SketchRelation::Coincident(a, b) => lower_coincident(sketch, a, b, points, radii), 376 + SketchRelation::Horizontal(id) => line_handle(sketch, id, points) 377 + .map(Residual::Horizontal) 378 + .into_iter() 379 + .collect(), 380 + SketchRelation::Vertical(id) => line_handle(sketch, id, points) 381 + .map(Residual::Vertical) 382 + .into_iter() 383 + .collect(), 384 + SketchRelation::Parallel(a, b) => combine_lines(sketch, a, b, points, Residual::Parallel), 385 + SketchRelation::Perpendicular(a, b) => { 386 + combine_lines(sketch, a, b, points, Residual::Perpendicular) 387 + } 388 + SketchRelation::Tangent(a, b) => lower_tangent(sketch, a, b, points, radii), 389 + SketchRelation::Equal(a, b) => lower_equal(sketch, a, b, points, radii), 390 + SketchRelation::Concentric(a, b) => lower_concentric(sketch, a, b, points), 391 + SketchRelation::Fix(id) => lower_fix(sketch, id, points, radii, parameters), 392 + } 393 + } 394 + 395 + fn lower_coincident( 396 + sketch: &Sketch, 397 + a: SketchEntityId, 398 + b: SketchEntityId, 399 + points: &BTreeMap<SketchEntityId, PointHandle>, 400 + radii: &BTreeMap<SketchEntityId, ParameterIndex>, 401 + ) -> Vec<Residual> { 402 + let ka = kind(sketch, a); 403 + let kb = kind(sketch, b); 404 + let (point_id, other_id, other_kind) = match (ka, kb) { 405 + (SketchEntityKind::Point, _) => (a, b, kb), 406 + (_, SketchEntityKind::Point) => (b, a, ka), 407 + _ => unreachable!("Coincident validation guarantees at least one point operand"), 408 + }; 409 + let Some(point) = points.get(&point_id).copied() else { 410 + return Vec::new(); 411 + }; 412 + match other_kind { 413 + SketchEntityKind::Point => points 414 + .get(&other_id) 415 + .copied() 416 + .map(|q| vec![Residual::CoincidentPointPoint(point, q)]) 417 + .unwrap_or_default(), 418 + SketchEntityKind::Line => line_handle(sketch, other_id, points) 419 + .map(|line| vec![Residual::CoincidentPointLine { point, line }]) 420 + .unwrap_or_default(), 421 + SketchEntityKind::Circle | SketchEntityKind::Arc => { 422 + curve_radius(sketch, other_id, points, radii) 423 + .map(|curve| vec![Residual::CoincidentPointCurve { point, curve }]) 424 + .unwrap_or_default() 425 + } 426 + } 427 + } 428 + 429 + fn combine_lines( 430 + sketch: &Sketch, 431 + a: SketchEntityId, 432 + b: SketchEntityId, 433 + points: &BTreeMap<SketchEntityId, PointHandle>, 434 + build: impl Fn(LineHandle, LineHandle) -> Residual, 435 + ) -> Vec<Residual> { 436 + match ( 437 + line_handle(sketch, a, points), 438 + line_handle(sketch, b, points), 439 + ) { 440 + (Some(la), Some(lb)) => vec![build(la, lb)], 441 + _ => Vec::new(), 442 + } 443 + } 444 + 445 + fn lower_tangent( 446 + sketch: &Sketch, 447 + a: SketchEntityId, 448 + b: SketchEntityId, 449 + points: &BTreeMap<SketchEntityId, PointHandle>, 450 + radii: &BTreeMap<SketchEntityId, ParameterIndex>, 451 + ) -> Vec<Residual> { 452 + let ka = kind(sketch, a); 453 + let kb = kind(sketch, b); 454 + let line_id; 455 + let curve_id; 456 + match (ka, kb) { 457 + (SketchEntityKind::Line, _) => { 458 + line_id = Some(a); 459 + curve_id = b; 460 + } 461 + (_, SketchEntityKind::Line) => { 462 + line_id = Some(b); 463 + curve_id = a; 464 + } 465 + _ => { 466 + line_id = None; 467 + curve_id = a; 468 + } 469 + } 470 + if let Some(lid) = line_id { 471 + return match ( 472 + line_handle(sketch, lid, points), 473 + curve_radius(sketch, curve_id, points, radii), 474 + ) { 475 + (Some(line), Some(curve)) => vec![Residual::TangentLineCurve { line, curve }], 476 + _ => Vec::new(), 477 + }; 478 + } 479 + match ( 480 + curve_radius(sketch, a, points, radii), 481 + curve_radius(sketch, b, points, radii), 482 + ) { 483 + (Some(ca), Some(cb)) => vec![Residual::TangentCurveCurve { a: ca, b: cb }], 484 + _ => Vec::new(), 485 + } 486 + } 487 + 488 + fn lower_equal( 489 + sketch: &Sketch, 490 + a: SketchEntityId, 491 + b: SketchEntityId, 492 + points: &BTreeMap<SketchEntityId, PointHandle>, 493 + radii: &BTreeMap<SketchEntityId, ParameterIndex>, 494 + ) -> Vec<Residual> { 495 + match (kind(sketch, a), kind(sketch, b)) { 496 + (SketchEntityKind::Line, SketchEntityKind::Line) => { 497 + combine_lines(sketch, a, b, points, Residual::EqualLength) 498 + } 499 + (ka, kb) if is_curve(ka) && is_curve(kb) => match ( 500 + curve_radius(sketch, a, points, radii), 501 + curve_radius(sketch, b, points, radii), 502 + ) { 503 + (Some(ca), Some(cb)) => vec![Residual::EqualRadius(ca, cb)], 504 + _ => Vec::new(), 505 + }, 506 + _ => unreachable!("Equal validation guarantees matching-kind operands"), 507 + } 508 + } 509 + 510 + fn lower_concentric( 511 + sketch: &Sketch, 512 + a: SketchEntityId, 513 + b: SketchEntityId, 514 + points: &BTreeMap<SketchEntityId, PointHandle>, 515 + ) -> Vec<Residual> { 516 + let ca = curve_center(sketch, a).and_then(|c| points.get(&c).copied()); 517 + let cb = curve_center(sketch, b).and_then(|c| points.get(&c).copied()); 518 + match (ca, cb) { 519 + (Some(p), Some(q)) => vec![Residual::CoincidentPointPoint(p, q)], 520 + _ => Vec::new(), 521 + } 522 + } 523 + 524 + fn lower_fix( 525 + sketch: &Sketch, 526 + id: SketchEntityId, 527 + points: &BTreeMap<SketchEntityId, PointHandle>, 528 + radii: &BTreeMap<SketchEntityId, ParameterIndex>, 529 + parameters: &[Parameter], 530 + ) -> Vec<Residual> { 531 + let Some(entity) = sketch.entities().get(id).copied() else { 532 + return Vec::new(); 533 + }; 534 + let point_ids: Vec<SketchEntityId> = match entity { 535 + SketchEntity::Point(_) => vec![id], 536 + _ => entity.references().into_iter().collect(), 537 + }; 538 + let pins_from_points = point_ids 539 + .into_iter() 540 + .filter_map(|pid| points.get(&pid).copied()) 541 + .flat_map(|h| { 542 + [ 543 + Residual::Pin { 544 + param: h.x, 545 + target: parameters[h.x.as_usize()].value(), 546 + }, 547 + Residual::Pin { 548 + param: h.y, 549 + target: parameters[h.y.as_usize()].value(), 550 + }, 551 + ] 552 + }); 553 + let radius_pin = matches!(entity, SketchEntity::Circle(_)) 554 + .then(|| radii.get(&id).copied()) 555 + .flatten() 556 + .map(|idx| Residual::Pin { 557 + param: idx, 558 + target: parameters[idx.as_usize()].value(), 559 + }); 560 + pins_from_points.chain(radius_pin).collect() 561 + } 562 + 563 + fn lower_dimension( 564 + sketch: &Sketch, 565 + dim: SketchDimension, 566 + points: &BTreeMap<SketchEntityId, PointHandle>, 567 + radii: &BTreeMap<SketchEntityId, ParameterIndex>, 568 + ) -> Vec<Residual> { 569 + match dim { 570 + SketchDimension::Linear { a, b, value, .. } => match (points.get(&a), points.get(&b)) { 571 + (Some(pa), Some(pb)) => vec![Residual::LinearDistance { 572 + a: *pa, 573 + b: *pb, 574 + value_mm: value.get::<millimeter>(), 575 + }], 576 + _ => Vec::new(), 577 + }, 578 + SketchDimension::Radius { target, value, .. } => { 579 + curve_radius(sketch, target, points, radii) 580 + .map(|curve| { 581 + vec![Residual::RadiusCurve { 582 + curve, 583 + value_mm: value.get::<millimeter>(), 584 + }] 585 + }) 586 + .unwrap_or_default() 587 + } 588 + SketchDimension::Diameter { target, value, .. } => { 589 + curve_radius(sketch, target, points, radii) 590 + .map(|curve| { 591 + vec![Residual::RadiusCurve { 592 + curve, 593 + value_mm: value.get::<millimeter>() * 0.5, 594 + }] 595 + }) 596 + .unwrap_or_default() 597 + } 598 + SketchDimension::Angular { a, b, value, .. } => { 599 + combine_lines(sketch, a, b, points, move |la, lb| { 600 + Residual::AngularBetweenLines { 601 + a: la, 602 + b: lb, 603 + angle_rad: value.get::<radian>(), 604 + } 605 + }) 606 + } 607 + } 608 + } 609 + 610 + fn kind(sketch: &Sketch, id: SketchEntityId) -> SketchEntityKind { 611 + sketch 612 + .entities() 613 + .get(id) 614 + .map_or(SketchEntityKind::Point, SketchEntity::kind) 615 + } 616 + 617 + fn is_curve(k: SketchEntityKind) -> bool { 618 + matches!(k, SketchEntityKind::Circle | SketchEntityKind::Arc) 619 + } 620 + 621 + fn line_handle( 622 + sketch: &Sketch, 623 + id: SketchEntityId, 624 + points: &BTreeMap<SketchEntityId, PointHandle>, 625 + ) -> Option<LineHandle> { 626 + let entity = sketch.entities().get(id)?; 627 + let SketchEntity::Line(l) = entity else { 628 + return None; 629 + }; 630 + Some(LineHandle { 631 + a: *points.get(&l.a())?, 632 + b: *points.get(&l.b())?, 633 + }) 634 + } 635 + 636 + fn curve_radius( 637 + sketch: &Sketch, 638 + id: SketchEntityId, 639 + points: &BTreeMap<SketchEntityId, PointHandle>, 640 + radii: &BTreeMap<SketchEntityId, ParameterIndex>, 641 + ) -> Option<CurveRadius> { 642 + match sketch.entities().get(id)? { 643 + SketchEntity::Circle(c) => { 644 + let center = *points.get(&c.center())?; 645 + let radius = *radii.get(&id)?; 646 + Some(CurveRadius::Explicit { center, radius }) 647 + } 648 + SketchEntity::Arc(a) => { 649 + let center = *points.get(&a.center())?; 650 + let spoke = *points.get(&a.start())?; 651 + Some(CurveRadius::FromSpoke { center, spoke }) 652 + } 653 + _ => None, 654 + } 655 + } 656 + 657 + fn curve_center(sketch: &Sketch, id: SketchEntityId) -> Option<SketchEntityId> { 658 + match sketch.entities().get(id)? { 659 + SketchEntity::Circle(c) => Some(c.center()), 660 + SketchEntity::Arc(a) => Some(a.center()), 661 + _ => None, 662 + } 663 + } 664 + 665 + fn recompute_driven(sketch: Sketch) -> Sketch { 666 + let driven_updates: Vec<(SketchDimensionId, DimensionValue)> = sketch 667 + .dimension_order() 668 + .iter() 669 + .filter_map(|did| { 670 + let dim = *sketch.dimensions().get(*did)?; 671 + if dim.kind() != DimensionKind::Driven { 672 + return None; 673 + } 674 + let value = measure_dimension(&sketch, dim)?; 675 + Some((*did, value)) 676 + }) 677 + .collect(); 678 + sketch.with_driven_dimension_values(&driven_updates) 679 + } 680 + 681 + fn measure_dimension(sketch: &Sketch, dim: SketchDimension) -> Option<DimensionValue> { 682 + match dim { 683 + SketchDimension::Linear { a, b, .. } => { 684 + let pa = point_position(sketch, a)?; 685 + let pb = point_position(sketch, b)?; 686 + let (ax, ay) = pa.coords_mm(); 687 + let (bx, by) = pb.coords_mm(); 688 + let dx = bx - ax; 689 + let dy = by - ay; 690 + Some(DimensionValue::Length(Length::new::<millimeter>( 691 + (dx * dx + dy * dy).sqrt(), 692 + ))) 693 + } 694 + SketchDimension::Radius { target, .. } => measure_radius(sketch, target) 695 + .map(|r| DimensionValue::Length(Length::new::<millimeter>(r))), 696 + SketchDimension::Diameter { target, .. } => measure_radius(sketch, target) 697 + .map(|r| DimensionValue::Length(Length::new::<millimeter>(2.0 * r))), 698 + SketchDimension::Angular { a, b, .. } => { 699 + measure_angle(sketch, a, b).map(|r| DimensionValue::Angle(Angle::new::<radian>(r))) 700 + } 701 + } 702 + } 703 + 704 + fn point_position(sketch: &Sketch, id: SketchEntityId) -> Option<bone_types::Point2> { 705 + match sketch.entities().get(id)? { 706 + SketchEntity::Point(p) => Some(p.at()), 707 + _ => None, 708 + } 709 + } 710 + 711 + fn measure_radius(sketch: &Sketch, id: SketchEntityId) -> Option<f64> { 712 + match sketch.entities().get(id)? { 713 + SketchEntity::Circle(c) => Some(c.radius().get::<millimeter>()), 714 + SketchEntity::Arc(a) => { 715 + let center = point_position(sketch, a.center())?; 716 + let start = point_position(sketch, a.start())?; 717 + let (cx, cy) = center.coords_mm(); 718 + let (sx, sy) = start.coords_mm(); 719 + let dx = sx - cx; 720 + let dy = sy - cy; 721 + Some((dx * dx + dy * dy).sqrt()) 722 + } 723 + _ => None, 724 + } 725 + } 726 + 727 + fn measure_angle(sketch: &Sketch, a: SketchEntityId, b: SketchEntityId) -> Option<f64> { 728 + let la = line_endpoints(sketch, a)?; 729 + let lb = line_endpoints(sketch, b)?; 730 + let (d1x, d1y) = (la.1.0 - la.0.0, la.1.1 - la.0.1); 731 + let (d2x, d2y) = (lb.1.0 - lb.0.0, lb.1.1 - lb.0.1); 732 + let cross = d1x * d2y - d1y * d2x; 733 + let dot = d1x * d2x + d1y * d2y; 734 + Some(cross.atan2(dot)) 735 + } 736 + 737 + fn line_endpoints(sketch: &Sketch, id: SketchEntityId) -> Option<((f64, f64), (f64, f64))> { 738 + match sketch.entities().get(id)? { 739 + SketchEntity::Line(l) => { 740 + let a = point_position(sketch, l.a())?.coords_mm(); 741 + let b = point_position(sketch, l.b())?.coords_mm(); 742 + Some((a, b)) 743 + } 744 + _ => None, 745 + } 746 + } 747 + 748 + #[cfg(test)] 749 + mod tests { 750 + use std::collections::BTreeSet; 751 + 752 + use super::*; 753 + use crate::sketch::{EditOutcome, SketchEdit}; 754 + use bone_solver::{assemble_jacobian, evaluate_residuals, sparsity_pattern}; 755 + use bone_types::{Point2, Point3, SketchPlaneBasis, Tolerance, UnitVec3}; 756 + use nalgebra::DMatrix; 757 + 758 + fn xy_plane() -> SketchPlaneBasis { 759 + let Ok(basis) = SketchPlaneBasis::new( 760 + Point3::origin(), 761 + UnitVec3::x_axis(), 762 + UnitVec3::y_axis(), 763 + Tolerance::new(1e-9), 764 + ) else { 765 + panic!("xy axes are orthogonal"); 766 + }; 767 + basis 768 + } 769 + 770 + fn apply(sketch: &Sketch, edits: Vec<SketchEdit>) -> (Sketch, Vec<EditOutcome>) { 771 + let Ok(pair) = sketch.apply_all(edits) else { 772 + panic!("fixture edits apply cleanly"); 773 + }; 774 + pair 775 + } 776 + 777 + fn mm_val(v: f64) -> Length { 778 + Length::new::<millimeter>(v) 779 + } 780 + 781 + fn entity_id(outcome: &EditOutcome) -> SketchEntityId { 782 + let EditOutcome::Entity(id) = outcome else { 783 + panic!("expected entity outcome"); 784 + }; 785 + *id 786 + } 787 + 788 + fn generous_budget() -> BudgetCeiling { 789 + BudgetCeiling::new(core::time::Duration::from_secs(5)) 790 + } 791 + 792 + fn rectangle_sketch() -> (Sketch, [SketchEntityId; 4]) { 793 + let base = Sketch::new(xy_plane()); 794 + let (after, out) = apply( 795 + &base, 796 + vec![ 797 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 798 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.0, 0.5))), 799 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.5, 3.2))), 800 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.3, 2.9))), 801 + ], 802 + ); 803 + let ids = [0_usize, 1, 2, 3].map(|i| entity_id(&out[i])); 804 + (after, ids) 805 + } 806 + 807 + fn constrained_rectangle() -> Sketch { 808 + let (base, corners) = rectangle_sketch(); 809 + let [corner_0, corner_1, corner_2, corner_3] = corners; 810 + let (with_edges, out) = apply( 811 + &base, 812 + vec![ 813 + SketchEdit::AddEntity(SketchEntity::line(corner_0, corner_1, false)), 814 + SketchEdit::AddEntity(SketchEntity::line(corner_1, corner_2, false)), 815 + SketchEdit::AddEntity(SketchEntity::line(corner_2, corner_3, false)), 816 + SketchEdit::AddEntity(SketchEntity::line(corner_3, corner_0, false)), 817 + ], 818 + ); 819 + let [edge_bottom, edge_right, edge_top, edge_left] = 820 + [0_usize, 1, 2, 3].map(|i| entity_id(&out[i])); 821 + apply( 822 + &with_edges, 823 + vec![ 824 + SketchEdit::AddRelation(SketchRelation::Horizontal(edge_bottom)), 825 + SketchEdit::AddRelation(SketchRelation::Horizontal(edge_top)), 826 + SketchEdit::AddRelation(SketchRelation::Vertical(edge_right)), 827 + SketchEdit::AddRelation(SketchRelation::Vertical(edge_left)), 828 + SketchEdit::AddRelation(SketchRelation::Fix(corner_0)), 829 + SketchEdit::AddDimension(SketchDimension::Linear { 830 + a: corner_0, 831 + b: corner_1, 832 + value: mm_val(10.0), 833 + kind: DimensionKind::Driving, 834 + }), 835 + SketchEdit::AddDimension(SketchDimension::Linear { 836 + a: corner_1, 837 + b: corner_2, 838 + value: mm_val(5.0), 839 + kind: DimensionKind::Driving, 840 + }), 841 + ], 842 + ) 843 + .0 844 + } 845 + 846 + fn finite_difference_jacobian( 847 + system: &ConstraintSystem, 848 + params: &[f64], 849 + eps: f64, 850 + ) -> DMatrix<f64> { 851 + let rows = system.row_count(); 852 + let cols = params.len(); 853 + (0..cols).fold(DMatrix::<f64>::zeros(rows, cols), |mut m, col| { 854 + let mut plus = params.to_vec(); 855 + plus[col] += eps; 856 + let mut minus = params.to_vec(); 857 + minus[col] -= eps; 858 + let rp = evaluate_residuals(system, &plus); 859 + let rn = evaluate_residuals(system, &minus); 860 + (0..rows).for_each(|row| { 861 + m[(row, col)] = (rp[row] - rn[row]) / (2.0 * eps); 862 + }); 863 + m 864 + }) 865 + } 866 + 867 + fn point_coords(sketch: &Sketch) -> Vec<(f64, f64)> { 868 + sketch 869 + .entity_order() 870 + .iter() 871 + .filter_map(|id| match sketch.entities().get(*id)? { 872 + SketchEntity::Point(p) => Some(p.at().coords_mm()), 873 + _ => None, 874 + }) 875 + .collect() 876 + } 877 + 878 + #[test] 879 + fn rectangle_solves_to_exact_dimensions() { 880 + let Ok(solved) = constrained_rectangle().solve() else { 881 + panic!("rectangle solves"); 882 + }; 883 + let coords = point_coords(&solved); 884 + let expected = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)]; 885 + expected.iter().zip(coords.iter()).for_each(|(want, got)| { 886 + assert!((want.0 - got.0).abs() < 1e-6, "x: {want:?} vs {got:?}"); 887 + assert!((want.1 - got.1).abs() < 1e-6, "y: {want:?} vs {got:?}"); 888 + }); 889 + } 890 + 891 + #[test] 892 + fn rectangle_analytic_jacobian_matches_finite_difference() { 893 + let sketch = constrained_rectangle(); 894 + let (system, _) = sketch.lower(); 895 + let params: Vec<f64> = system.parameters().iter().map(|p| p.value()).collect(); 896 + let analytic = assemble_jacobian(&system, &params); 897 + let fd = finite_difference_jacobian(&system, &params, 1e-6); 898 + let diff = (&analytic - &fd).abs().max(); 899 + assert!(diff < 1e-4, "jacobian mismatch (max diff {diff})"); 900 + } 901 + 902 + #[test] 903 + fn rectangle_sparsity_pattern_is_stable() { 904 + let (system, _) = constrained_rectangle().lower(); 905 + let pattern = sparsity_pattern(&system); 906 + insta::assert_snapshot!("rectangle_sparsity", pattern.display()); 907 + } 908 + 909 + #[test] 910 + fn tangent_line_to_circle_solves() { 911 + let base = Sketch::new(xy_plane()); 912 + let (with_points, out) = apply( 913 + &base, 914 + vec![ 915 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 916 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), 917 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(3.0, 3.5))), 918 + ], 919 + ); 920 + let [line_start, line_end, circle_center] = [0_usize, 1, 2].map(|i| entity_id(&out[i])); 921 + let (with_shapes, out2) = apply( 922 + &with_points, 923 + vec![ 924 + SketchEdit::AddEntity(SketchEntity::line(line_start, line_end, false)), 925 + SketchEdit::AddEntity(SketchEntity::circle(circle_center, mm_val(2.0), false)), 926 + ], 927 + ); 928 + let [line_id, circle_id] = [0_usize, 1].map(|i| entity_id(&out2[i])); 929 + let sketch = apply( 930 + &with_shapes, 931 + vec![ 932 + SketchEdit::AddRelation(SketchRelation::Fix(line_start)), 933 + SketchEdit::AddRelation(SketchRelation::Fix(line_end)), 934 + SketchEdit::AddDimension(SketchDimension::Linear { 935 + a: line_start, 936 + b: circle_center, 937 + value: mm_val(5.0), 938 + kind: DimensionKind::Driving, 939 + }), 940 + SketchEdit::AddDimension(SketchDimension::Radius { 941 + target: circle_id, 942 + value: mm_val(2.0), 943 + kind: DimensionKind::Driving, 944 + }), 945 + SketchEdit::AddRelation(SketchRelation::Tangent(line_id, circle_id)), 946 + ], 947 + ) 948 + .0; 949 + let Ok(solved) = sketch.solve() else { 950 + panic!("tangent line-circle solves"); 951 + }; 952 + let coords = point_coords(&solved); 953 + let (start_x, start_y) = coords[0]; 954 + let (end_x, end_y) = coords[1]; 955 + let (center_x, center_y) = coords[2]; 956 + assert!(start_x.abs() < 1e-6 && start_y.abs() < 1e-6); 957 + assert!((end_x - 10.0).abs() < 1e-6 && end_y.abs() < 1e-6); 958 + assert!( 959 + (center_y.abs() - 2.0).abs() < 1e-6, 960 + "center-to-line {center_y}" 961 + ); 962 + let radial = (center_x * center_x + center_y * center_y).sqrt(); 963 + assert!((radial - 5.0).abs() < 1e-6, "|c| {radial}"); 964 + } 965 + 966 + #[test] 967 + fn item_of_residual_tracks_rows_across_multi_row_residuals() { 968 + let base = Sketch::new(xy_plane()); 969 + let (s, outs) = apply( 970 + &base, 971 + vec![ 972 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 973 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.5, 0.5))), 974 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))), 975 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.5, 0.5))), 976 + ], 977 + ); 978 + let [p0, p1, p2, p3] = [0_usize, 1, 2, 3].map(|i| entity_id(&outs[i])); 979 + let (sketch, rel_outs) = apply( 980 + &s, 981 + vec![ 982 + SketchEdit::AddRelation(SketchRelation::Coincident(p0, p1)), 983 + SketchEdit::AddRelation(SketchRelation::Fix(p2)), 984 + SketchEdit::AddRelation(SketchRelation::Coincident(p2, p3)), 985 + ], 986 + ); 987 + let relation_ids: Vec<SketchRelationId> = rel_outs 988 + .iter() 989 + .map(|o| match *o { 990 + EditOutcome::Relation(id) => id, 991 + _ => panic!("expected relation outcome"), 992 + }) 993 + .collect(); 994 + let (system, mapping) = sketch.lower(); 995 + assert_eq!(system.row_count(), 6); 996 + let expected: [SketchItemId; 6] = [ 997 + SketchItemId::Relation(relation_ids[0]), 998 + SketchItemId::Relation(relation_ids[0]), 999 + SketchItemId::Relation(relation_ids[1]), 1000 + SketchItemId::Relation(relation_ids[1]), 1001 + SketchItemId::Relation(relation_ids[2]), 1002 + SketchItemId::Relation(relation_ids[2]), 1003 + ]; 1004 + expected.iter().enumerate().for_each(|(i, want)| { 1005 + let Ok(row) = u32::try_from(i) else { 1006 + unreachable!("fixture row count fits in u32") 1007 + }; 1008 + let got = mapping.item_of_residual(ResidualIndex::new(row)); 1009 + assert_eq!(got, Some(*want), "row {i}"); 1010 + }); 1011 + } 1012 + 1013 + #[test] 1014 + fn under_constrained_horizontal_line_solves() { 1015 + let base = Sketch::new(xy_plane()); 1016 + let (with_points, outs) = apply( 1017 + &base, 1018 + vec![ 1019 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1020 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 3.0))), 1021 + ], 1022 + ); 1023 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1024 + let (with_line, out_line) = apply( 1025 + &with_points, 1026 + vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))], 1027 + ); 1028 + let line = entity_id(&out_line[0]); 1029 + let sketch = apply( 1030 + &with_line, 1031 + vec![SketchEdit::AddRelation(SketchRelation::Horizontal(line))], 1032 + ) 1033 + .0; 1034 + let Ok(solved) = sketch.solve() else { 1035 + panic!("under-constrained Horizontal should solve, not fail") 1036 + }; 1037 + let coords = point_coords(&solved); 1038 + assert!( 1039 + (coords[0].1 - coords[1].1).abs() < 1e-6, 1040 + "y-values should match after Horizontal: {coords:?}" 1041 + ); 1042 + } 1043 + 1044 + #[test] 1045 + fn driven_dimension_is_recomputed_post_solve() { 1046 + let (base, corners) = rectangle_sketch(); 1047 + let [corner_0, corner_1, _corner_2, _corner_3] = corners; 1048 + let sketch = apply( 1049 + &base, 1050 + vec![SketchEdit::AddDimension(SketchDimension::Linear { 1051 + a: corner_0, 1052 + b: corner_1, 1053 + value: mm_val(0.0), 1054 + kind: DimensionKind::Driven, 1055 + })], 1056 + ) 1057 + .0; 1058 + let Ok(solved) = sketch.solve() else { 1059 + panic!("solve with driven dim"); 1060 + }; 1061 + let driven_id = solved.dimension_order()[0]; 1062 + let SketchDimension::Linear { value, .. } = solved.dimensions()[driven_id] else { 1063 + panic!("driven should remain Linear"); 1064 + }; 1065 + let expected = (7.0_f64.powi(2) + 0.5_f64.powi(2)).sqrt(); 1066 + assert!((value.get::<millimeter>() - expected).abs() < 1e-9); 1067 + } 1068 + 1069 + #[test] 1070 + fn arc_end_stays_on_implicit_circle_after_solve() { 1071 + let base = Sketch::new(xy_plane()); 1072 + let (with_points, outs) = apply( 1073 + &base, 1074 + vec![ 1075 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1076 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(2.0, 0.0))), 1077 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.3, 2.5))), 1078 + ], 1079 + ); 1080 + let [center, start, end] = [0_usize, 1, 2].map(|i| entity_id(&outs[i])); 1081 + let (with_arc, out_arc) = apply( 1082 + &with_points, 1083 + vec![SketchEdit::AddEntity(SketchEntity::arc( 1084 + center, start, end, false, 1085 + ))], 1086 + ); 1087 + let _arc = entity_id(&out_arc[0]); 1088 + let sketch = apply( 1089 + &with_arc, 1090 + vec![ 1091 + SketchEdit::AddRelation(SketchRelation::Fix(center)), 1092 + SketchEdit::AddRelation(SketchRelation::Fix(start)), 1093 + ], 1094 + ) 1095 + .0; 1096 + let Ok(solved) = sketch.solve() else { 1097 + panic!("arc coherence solve"); 1098 + }; 1099 + let coords = point_coords(&solved); 1100 + let (cx, cy) = coords[0]; 1101 + let (sx, sy) = coords[1]; 1102 + let (ex, ey) = coords[2]; 1103 + let r_start = ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt(); 1104 + let r_end = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt(); 1105 + assert!( 1106 + (r_start - r_end).abs() < 1e-6, 1107 + "arc end must lie on implicit circle: r_start={r_start} r_end={r_end}" 1108 + ); 1109 + } 1110 + 1111 + fn horizontal_line_fixture(p0: (f64, f64), p1: (f64, f64), length_mm: f64) -> Sketch { 1112 + let base = Sketch::new(xy_plane()); 1113 + let (with_points, outs) = apply( 1114 + &base, 1115 + vec![ 1116 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(p0.0, p0.1))), 1117 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(p1.0, p1.1))), 1118 + ], 1119 + ); 1120 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1121 + let (with_line, line_outs) = apply( 1122 + &with_points, 1123 + vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))], 1124 + ); 1125 + let line = entity_id(&line_outs[0]); 1126 + apply( 1127 + &with_line, 1128 + vec![ 1129 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1130 + SketchEdit::AddRelation(SketchRelation::Horizontal(line)), 1131 + SketchEdit::AddDimension(SketchDimension::Linear { 1132 + a, 1133 + b, 1134 + value: mm_val(length_mm), 1135 + kind: DimensionKind::Driving, 1136 + }), 1137 + ], 1138 + ) 1139 + .0 1140 + } 1141 + 1142 + fn disjoint_pair_fixture() -> Sketch { 1143 + let base = Sketch::new(xy_plane()); 1144 + let (with_points, outs) = apply( 1145 + &base, 1146 + vec![ 1147 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1148 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.0, 0.3))), 1149 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(100.0, 0.0))), 1150 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(107.0, 0.3))), 1151 + ], 1152 + ); 1153 + let [a0, a1, b0, b1] = [0_usize, 1, 2, 3].map(|i| entity_id(&outs[i])); 1154 + let (with_lines, line_outs) = apply( 1155 + &with_points, 1156 + vec![ 1157 + SketchEdit::AddEntity(SketchEntity::line(a0, a1, false)), 1158 + SketchEdit::AddEntity(SketchEntity::line(b0, b1, false)), 1159 + ], 1160 + ); 1161 + let [line_a, line_b] = [0_usize, 1].map(|i| entity_id(&line_outs[i])); 1162 + apply( 1163 + &with_lines, 1164 + vec![ 1165 + SketchEdit::AddRelation(SketchRelation::Fix(a0)), 1166 + SketchEdit::AddRelation(SketchRelation::Horizontal(line_a)), 1167 + SketchEdit::AddRelation(SketchRelation::Fix(b0)), 1168 + SketchEdit::AddRelation(SketchRelation::Horizontal(line_b)), 1169 + SketchEdit::AddDimension(SketchDimension::Linear { 1170 + a: a0, 1171 + b: a1, 1172 + value: mm_val(5.0), 1173 + kind: DimensionKind::Driving, 1174 + }), 1175 + SketchEdit::AddDimension(SketchDimension::Linear { 1176 + a: b0, 1177 + b: b1, 1178 + value: mm_val(9.0), 1179 + kind: DimensionKind::Driving, 1180 + }), 1181 + ], 1182 + ) 1183 + .0 1184 + } 1185 + 1186 + #[test] 1187 + fn disjoint_sketches_solve_per_component() { 1188 + let Ok(solved_a) = horizontal_line_fixture((0.0, 0.0), (7.0, 0.3), 5.0).solve() else { 1189 + panic!("component A solves alone"); 1190 + }; 1191 + let Ok(solved_b) = horizontal_line_fixture((100.0, 0.0), (107.0, 0.3), 9.0).solve() else { 1192 + panic!("component B solves alone"); 1193 + }; 1194 + let Ok(joint_solved) = disjoint_pair_fixture().solve() else { 1195 + panic!("joint disjoint-component solve"); 1196 + }; 1197 + let joint_coords = point_coords(&joint_solved); 1198 + let expected_joint: Vec<(f64, f64)> = point_coords(&solved_a) 1199 + .into_iter() 1200 + .chain(point_coords(&solved_b)) 1201 + .collect(); 1202 + expected_joint 1203 + .iter() 1204 + .zip(joint_coords.iter()) 1205 + .for_each(|(want, got)| { 1206 + assert!( 1207 + (want.0 - got.0).abs() < 1e-9, 1208 + "x alone-vs-joint {want:?} vs {got:?}" 1209 + ); 1210 + assert!( 1211 + (want.1 - got.1).abs() < 1e-9, 1212 + "y alone-vs-joint {want:?} vs {got:?}" 1213 + ); 1214 + }); 1215 + let expected_absolute = [(0.0, 0.0), (5.0, 0.0), (100.0, 0.0), (109.0, 0.0)]; 1216 + expected_absolute 1217 + .iter() 1218 + .zip(joint_coords.iter()) 1219 + .for_each(|(want, got)| { 1220 + assert!((want.0 - got.0).abs() < 1e-6, "x {want:?} vs {got:?}"); 1221 + assert!((want.1 - got.1).abs() < 1e-6, "y {want:?} vs {got:?}"); 1222 + }); 1223 + } 1224 + 1225 + #[test] 1226 + fn conflicting_dimensions_report_over_defined() { 1227 + let base = Sketch::new(xy_plane()); 1228 + let (with_points, outs) = apply( 1229 + &base, 1230 + vec![ 1231 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1232 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))), 1233 + ], 1234 + ); 1235 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1236 + let (sketch, dim_outs) = apply( 1237 + &with_points, 1238 + vec![ 1239 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1240 + SketchEdit::AddDimension(SketchDimension::Linear { 1241 + a, 1242 + b, 1243 + value: mm_val(10.0), 1244 + kind: DimensionKind::Driving, 1245 + }), 1246 + SketchEdit::AddDimension(SketchDimension::Linear { 1247 + a, 1248 + b, 1249 + value: mm_val(3.0), 1250 + kind: DimensionKind::Driving, 1251 + }), 1252 + ], 1253 + ); 1254 + let dim_ids: Vec<SketchDimensionId> = dim_outs 1255 + .iter() 1256 + .filter_map(|o| match *o { 1257 + EditOutcome::Dimension(id) => Some(id), 1258 + _ => None, 1259 + }) 1260 + .collect(); 1261 + let Err(err) = sketch.solve() else { 1262 + panic!("conflicting dimensions must surface OverDefined"); 1263 + }; 1264 + let SolverError::OverDefined { conflicts } = err else { 1265 + panic!("expected OverDefined, got {err:?}"); 1266 + }; 1267 + assert!(!conflicts.is_empty(), "conflicts populated"); 1268 + let has_dim = conflicts 1269 + .iter() 1270 + .any(|c| matches!(*c, SketchItemId::Dimension(id) if dim_ids.contains(&id))); 1271 + assert!(has_dim, "at least one of the conflicting dimensions names"); 1272 + } 1273 + 1274 + #[test] 1275 + fn redundant_dimension_does_not_block_solve() { 1276 + let base = Sketch::new(xy_plane()); 1277 + let (with_points, outs) = apply( 1278 + &base, 1279 + vec![ 1280 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1281 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), 1282 + ], 1283 + ); 1284 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1285 + let sketch = apply( 1286 + &with_points, 1287 + vec![ 1288 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1289 + SketchEdit::AddDimension(SketchDimension::Linear { 1290 + a, 1291 + b, 1292 + value: mm_val(10.0), 1293 + kind: DimensionKind::Driving, 1294 + }), 1295 + SketchEdit::AddDimension(SketchDimension::Linear { 1296 + a, 1297 + b, 1298 + value: mm_val(10.0), 1299 + kind: DimensionKind::Driving, 1300 + }), 1301 + ], 1302 + ) 1303 + .0; 1304 + let report = sketch.analyze_dof(); 1305 + assert_eq!(report.dof().value(), 1); 1306 + assert!(report.over_constrained().is_empty()); 1307 + assert!(!report.redundant_consistent().is_empty()); 1308 + let Ok(_) = sketch.solve() else { 1309 + panic!("redundant-consistent system must solve"); 1310 + }; 1311 + } 1312 + 1313 + #[test] 1314 + fn redundant_dimension_at_unsatisfied_seed_still_solves() { 1315 + let base = Sketch::new(xy_plane()); 1316 + let (with_points, outs) = apply( 1317 + &base, 1318 + vec![ 1319 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1320 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))), 1321 + ], 1322 + ); 1323 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1324 + let sketch = apply( 1325 + &with_points, 1326 + vec![ 1327 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1328 + SketchEdit::AddDimension(SketchDimension::Linear { 1329 + a, 1330 + b, 1331 + value: mm_val(10.0), 1332 + kind: DimensionKind::Driving, 1333 + }), 1334 + SketchEdit::AddDimension(SketchDimension::Linear { 1335 + a, 1336 + b, 1337 + value: mm_val(10.0), 1338 + kind: DimensionKind::Driving, 1339 + }), 1340 + ], 1341 + ) 1342 + .0; 1343 + let report = sketch.analyze_dof(); 1344 + assert_eq!(report.dof().value(), 1); 1345 + assert!( 1346 + report.over_constrained().is_empty(), 1347 + "redundant-consistent dims at unsatisfied seed must not be flagged as conflicts" 1348 + ); 1349 + assert!(!report.redundant_consistent().is_empty()); 1350 + let Ok(solved) = sketch.solve() else { 1351 + panic!("redundant-consistent dims at unsatisfied seed must solve"); 1352 + }; 1353 + let coords = point_coords(&solved); 1354 + let (bx, by) = coords[1]; 1355 + let radial = (bx * bx + by * by).sqrt(); 1356 + assert!( 1357 + (radial - 10.0).abs() < 1e-6, 1358 + "free endpoint should move to |b|=10mm, got {radial}" 1359 + ); 1360 + } 1361 + 1362 + #[test] 1363 + fn analyze_dof_reports_fully_constrained_rectangle() { 1364 + let sketch = constrained_rectangle(); 1365 + let report = sketch.analyze_dof(); 1366 + assert_eq!(report.dof().value(), 0); 1367 + assert!(report.over_constrained().is_empty()); 1368 + assert!(report.redundant_consistent().is_empty()); 1369 + assert!(report.under_constrained().is_empty()); 1370 + } 1371 + 1372 + #[test] 1373 + fn analyze_dof_reports_free_line() { 1374 + let base = Sketch::new(xy_plane()); 1375 + let (with_points, outs) = apply( 1376 + &base, 1377 + vec![ 1378 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1379 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 3.0))), 1380 + ], 1381 + ); 1382 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1383 + let (with_line, line_outs) = apply( 1384 + &with_points, 1385 + vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))], 1386 + ); 1387 + let line = entity_id(&line_outs[0]); 1388 + let sketch = apply( 1389 + &with_line, 1390 + vec![SketchEdit::AddRelation(SketchRelation::Horizontal(line))], 1391 + ) 1392 + .0; 1393 + let report = sketch.analyze_dof(); 1394 + assert_eq!(report.dof().value(), 3); 1395 + assert!(report.over_constrained().is_empty()); 1396 + assert!(!report.under_constrained().is_empty()); 1397 + } 1398 + 1399 + fn three_conflicting_linear_dims_fixture() -> (Sketch, Vec<SketchDimensionId>) { 1400 + let base = Sketch::new(xy_plane()); 1401 + let (with_points, outs) = apply( 1402 + &base, 1403 + vec![ 1404 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1405 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))), 1406 + ], 1407 + ); 1408 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1409 + let (sketch, dim_outs) = apply( 1410 + &with_points, 1411 + vec![ 1412 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1413 + SketchEdit::AddDimension(SketchDimension::Linear { 1414 + a, 1415 + b, 1416 + value: mm_val(10.0), 1417 + kind: DimensionKind::Driving, 1418 + }), 1419 + SketchEdit::AddDimension(SketchDimension::Linear { 1420 + a, 1421 + b, 1422 + value: mm_val(7.0), 1423 + kind: DimensionKind::Driving, 1424 + }), 1425 + SketchEdit::AddDimension(SketchDimension::Linear { 1426 + a, 1427 + b, 1428 + value: mm_val(10.0), 1429 + kind: DimensionKind::Driving, 1430 + }), 1431 + ], 1432 + ); 1433 + let dim_ids: Vec<SketchDimensionId> = dim_outs 1434 + .iter() 1435 + .filter_map(|o| match *o { 1436 + EditOutcome::Dimension(id) => Some(id), 1437 + _ => None, 1438 + }) 1439 + .collect(); 1440 + (sketch, dim_ids) 1441 + } 1442 + 1443 + #[test] 1444 + fn mus_narrows_three_conflicting_dims_to_odd_one_out() { 1445 + let (sketch, dim_ids) = three_conflicting_linear_dims_fixture(); 1446 + let Err(err) = sketch.solve() else { 1447 + panic!("three conflicting dims must surface OverDefined"); 1448 + }; 1449 + let SolverError::OverDefined { conflicts } = err else { 1450 + panic!("expected OverDefined, got {err:?}"); 1451 + }; 1452 + let added: BTreeSet<SketchItemId> = dim_ids 1453 + .iter() 1454 + .copied() 1455 + .map(SketchItemId::Dimension) 1456 + .collect(); 1457 + assert!( 1458 + !conflicts.is_empty() && conflicts.len() < dim_ids.len(), 1459 + "MUS must narrow {{10, 7, 10}} strictly below the full dim set: \ 1460 + conflicts={conflicts:?} dim_ids={dim_ids:?}", 1461 + ); 1462 + if let Some(stray) = conflicts.iter().find(|c| !added.contains(c)) { 1463 + panic!("unexpected conflict: {stray:?} not in {dim_ids:?}"); 1464 + } 1465 + assert!( 1466 + conflicts.contains(&SketchItemId::Dimension(dim_ids[1])), 1467 + "MUS must flag the 7mm outlier (dim_ids[1]): \ 1468 + conflicts={conflicts:?} dim_ids={dim_ids:?}", 1469 + ); 1470 + } 1471 + 1472 + #[test] 1473 + fn mus_on_three_distinct_conflicting_dims_returns_minimal_conflict() { 1474 + let base = Sketch::new(xy_plane()); 1475 + let (with_points, outs) = apply( 1476 + &base, 1477 + vec![ 1478 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1479 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))), 1480 + ], 1481 + ); 1482 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1483 + let (sketch, dim_outs) = apply( 1484 + &with_points, 1485 + vec![ 1486 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1487 + SketchEdit::AddDimension(SketchDimension::Linear { 1488 + a, 1489 + b, 1490 + value: mm_val(10.0), 1491 + kind: DimensionKind::Driving, 1492 + }), 1493 + SketchEdit::AddDimension(SketchDimension::Linear { 1494 + a, 1495 + b, 1496 + value: mm_val(7.0), 1497 + kind: DimensionKind::Driving, 1498 + }), 1499 + SketchEdit::AddDimension(SketchDimension::Linear { 1500 + a, 1501 + b, 1502 + value: mm_val(4.0), 1503 + kind: DimensionKind::Driving, 1504 + }), 1505 + ], 1506 + ); 1507 + let dim_ids: Vec<SketchDimensionId> = dim_outs 1508 + .iter() 1509 + .filter_map(|o| match *o { 1510 + EditOutcome::Dimension(id) => Some(id), 1511 + _ => None, 1512 + }) 1513 + .collect(); 1514 + let Err(SolverError::OverDefined { conflicts }) = sketch.solve() else { 1515 + panic!("three distinct-value conflicting dims must surface OverDefined"); 1516 + }; 1517 + assert!(!conflicts.is_empty(), "MUS must be non-empty"); 1518 + assert!( 1519 + conflicts.len() <= dim_ids.len(), 1520 + "MUS must not exceed the added-dim count: conflicts={conflicts:?}", 1521 + ); 1522 + if let Some(bad) = conflicts 1523 + .iter() 1524 + .find(|c| !matches!(c, SketchItemId::Dimension(id) if dim_ids.contains(id))) 1525 + { 1526 + panic!("unexpected conflict: {bad:?} not in {dim_ids:?}"); 1527 + } 1528 + assert_eq!( 1529 + conflicts.iter().collect::<BTreeSet<_>>().len(), 1530 + conflicts.len(), 1531 + "conflicts must be deduped: {conflicts:?}", 1532 + ); 1533 + } 1534 + 1535 + #[test] 1536 + fn narrow_over_defined_dedups_multi_row_parent() { 1537 + let base = Sketch::new(xy_plane()); 1538 + let (with_points, outs) = apply( 1539 + &base, 1540 + vec![ 1541 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1542 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 1.0))), 1543 + ], 1544 + ); 1545 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1546 + let (sketch, rel_outs) = apply( 1547 + &with_points, 1548 + vec![SketchEdit::AddRelation(SketchRelation::Coincident(a, b))], 1549 + ); 1550 + let EditOutcome::Relation(coincident_id) = rel_outs[0] else { 1551 + panic!("expected relation outcome"); 1552 + }; 1553 + let (system, mapping) = sketch.lower(); 1554 + let rows: Vec<ResidualIndex> = (0..system.row_count()) 1555 + .map(|i| { 1556 + let Ok(iv) = u32::try_from(i) else { 1557 + unreachable!("fixture row count fits in u32") 1558 + }; 1559 + ResidualIndex::new(iv) 1560 + }) 1561 + .collect(); 1562 + assert_eq!(rows.len(), 2, "Coincident is a two-row residual"); 1563 + let err = narrow_over_defined(&system, &mapping, &rows); 1564 + let SolverError::OverDefined { conflicts } = err else { 1565 + panic!("narrow_over_defined must surface OverDefined"); 1566 + }; 1567 + assert_eq!( 1568 + conflicts, 1569 + vec![SketchItemId::Relation(coincident_id)], 1570 + "two rows of one Coincident must collapse to a single conflict: {conflicts:?}", 1571 + ); 1572 + } 1573 + 1574 + fn draggable_horizontal_line() -> (Sketch, SketchEntityId, SketchEntityId) { 1575 + let base = Sketch::new(xy_plane()); 1576 + let (with_points, outs) = apply( 1577 + &base, 1578 + vec![ 1579 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1580 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(5.0, 0.0))), 1581 + ], 1582 + ); 1583 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1584 + let (with_line, line_outs) = apply( 1585 + &with_points, 1586 + vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))], 1587 + ); 1588 + let line = entity_id(&line_outs[0]); 1589 + let sketch = apply( 1590 + &with_line, 1591 + vec![ 1592 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1593 + SketchEdit::AddRelation(SketchRelation::Horizontal(line)), 1594 + ], 1595 + ) 1596 + .0; 1597 + (sketch, a, b) 1598 + } 1599 + 1600 + #[test] 1601 + fn solve_with_drag_along_horizontal_lands_on_target() { 1602 + let (sketch, a, b) = draggable_horizontal_line(); 1603 + let target = Point2::from_mm(8.0, 0.0); 1604 + let Ok(dragged) = sketch.solve_with_drag(b, target, generous_budget()) else { 1605 + panic!("feasible drag along Horizontal must converge"); 1606 + }; 1607 + let coords = point_coords(&dragged); 1608 + let (bx, by) = coords[1]; 1609 + assert!( 1610 + (bx - 8.0).abs() < 1e-6, 1611 + "dragged x must land at target.x: {bx}", 1612 + ); 1613 + assert!( 1614 + by.abs() < 1e-6, 1615 + "Horizontal holds: a.y={} b.y={by}", 1616 + coords[0].1, 1617 + ); 1618 + let (ax, ay) = coords[0]; 1619 + assert!( 1620 + ax.abs() < 1e-9 && ay.abs() < 1e-9, 1621 + "Fix on a holds: ({ax}, {ay})", 1622 + ); 1623 + let _ = a; 1624 + } 1625 + 1626 + #[test] 1627 + fn solve_with_drag_rejects_non_point() { 1628 + let base = Sketch::new(xy_plane()); 1629 + let (with_points, outs) = apply( 1630 + &base, 1631 + vec![ 1632 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1633 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 0.0))), 1634 + ], 1635 + ); 1636 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1637 + let (sketch, line_outs) = apply( 1638 + &with_points, 1639 + vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))], 1640 + ); 1641 + let line = entity_id(&line_outs[0]); 1642 + let result = sketch.solve_with_drag(line, Point2::from_mm(0.0, 0.0), generous_budget()); 1643 + let Err(err) = result else { 1644 + panic!("dragging a Line must reject, got {result:?}"); 1645 + }; 1646 + assert!(matches!(err, DragError::NotAPoint(id) if id == line)); 1647 + } 1648 + 1649 + #[test] 1650 + fn solve_with_drag_rejects_unknown_entity_id() { 1651 + let sketch = constrained_rectangle(); 1652 + let stranger = SketchEntityId::default(); 1653 + let result = sketch.solve_with_drag(stranger, Point2::from_mm(0.0, 0.0), generous_budget()); 1654 + let Err(err) = result else { 1655 + panic!("unknown entity id must reject, got {result:?}"); 1656 + }; 1657 + assert!(matches!(err, DragError::NotFound(id) if id == stranger)); 1658 + } 1659 + 1660 + #[test] 1661 + fn solve_with_drag_returns_budget_when_ceiling_is_zero() { 1662 + let sketch = constrained_rectangle(); 1663 + let target = Point2::from_mm(20.0, 20.0); 1664 + let drag_id = sketch.entity_order()[1]; 1665 + let result = sketch.solve_with_drag( 1666 + drag_id, 1667 + target, 1668 + BudgetCeiling::new(core::time::Duration::ZERO), 1669 + ); 1670 + match result { 1671 + Err(DragError::Solver(SolverError::Budget { .. })) => {} 1672 + other => panic!("expected Budget, got {other:?}"), 1673 + } 1674 + } 1675 + 1676 + #[test] 1677 + fn solve_with_drag_on_fixed_point_reanchors_to_target() { 1678 + let base = Sketch::new(xy_plane()); 1679 + let (with_point, outs) = apply( 1680 + &base, 1681 + vec![SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm( 1682 + 0.0, 0.0, 1683 + )))], 1684 + ); 1685 + let p = entity_id(&outs[0]); 1686 + let sketch = apply( 1687 + &with_point, 1688 + vec![SketchEdit::AddRelation(SketchRelation::Fix(p))], 1689 + ) 1690 + .0; 1691 + let target = Point2::from_mm(8.0, 5.0); 1692 + let Ok(dragged) = sketch.solve_with_drag(p, target, generous_budget()) else { 1693 + panic!("Fix'd drag re-anchors via warm-start and must converge"); 1694 + }; 1695 + let (x, y) = point_coords(&dragged)[0]; 1696 + assert!( 1697 + (x - 8.0).abs() < 1e-9, 1698 + "warm-start re-anchors Fix's x pin to target: {x}", 1699 + ); 1700 + assert!( 1701 + (y - 5.0).abs() < 1e-9, 1702 + "warm-start re-anchors Fix's y pin to target: {y}", 1703 + ); 1704 + } 1705 + 1706 + #[test] 1707 + fn solve_is_bit_identical_across_repeated_invocations() { 1708 + let sketch = constrained_rectangle(); 1709 + let Ok(first) = sketch.solve() else { 1710 + panic!("rectangle solves once"); 1711 + }; 1712 + let Ok(second) = sketch.solve() else { 1713 + panic!("rectangle solves twice"); 1714 + }; 1715 + let lhs = point_coords(&first); 1716 + let rhs = point_coords(&second); 1717 + lhs.iter().zip(rhs.iter()).for_each(|(a, b)| { 1718 + assert_eq!( 1719 + a.0.to_bits(), 1720 + b.0.to_bits(), 1721 + "x must match bit-for-bit: {a:?} vs {b:?}", 1722 + ); 1723 + assert_eq!( 1724 + a.1.to_bits(), 1725 + b.1.to_bits(), 1726 + "y must match bit-for-bit: {a:?} vs {b:?}", 1727 + ); 1728 + }); 1729 + } 1730 + 1731 + #[test] 1732 + fn over_defined_conflicts_are_identical_across_repeated_invocations() { 1733 + let (sketch, _) = three_conflicting_linear_dims_fixture(); 1734 + let Err(SolverError::OverDefined { conflicts: first }) = sketch.solve() else { 1735 + panic!("conflicting fixture must surface OverDefined on first solve"); 1736 + }; 1737 + let Err(SolverError::OverDefined { conflicts: second }) = sketch.solve() else { 1738 + panic!("conflicting fixture must surface OverDefined on second solve"); 1739 + }; 1740 + assert_eq!( 1741 + first, second, 1742 + "MUS conflict ordering must be identical across runs", 1743 + ); 1744 + } 1745 + 1746 + #[test] 1747 + fn drag_is_bit_identical_across_repeated_invocations() { 1748 + let (sketch, _a, b) = draggable_horizontal_line(); 1749 + let target = Point2::from_mm(13.5, 0.0); 1750 + let Ok(first) = sketch.solve_with_drag(b, target, generous_budget()) else { 1751 + panic!("first drag solves"); 1752 + }; 1753 + let Ok(second) = sketch.solve_with_drag(b, target, generous_budget()) else { 1754 + panic!("second drag solves"); 1755 + }; 1756 + let lhs = point_coords(&first); 1757 + let rhs = point_coords(&second); 1758 + lhs.iter().zip(rhs.iter()).for_each(|(a, b)| { 1759 + assert_eq!(a.0.to_bits(), b.0.to_bits()); 1760 + assert_eq!(a.1.to_bits(), b.1.to_bits()); 1761 + }); 1762 + } 1763 + 1764 + #[cfg(not(debug_assertions))] 1765 + #[test] 1766 + fn drag_resolve_completes_inside_frame_budget() { 1767 + use std::time::Instant; 1768 + let (sketch, _a, b) = draggable_horizontal_line(); 1769 + (0..16).for_each(|i| { 1770 + let f = f64::from(i); 1771 + let target = Point2::from_mm(8.0 + 0.1 * f, 0.0); 1772 + let started = Instant::now(); 1773 + let Ok(_) = sketch.solve_with_drag(b, target, BudgetCeiling::FRAME_16MS) else { 1774 + panic!("drag solve must converge inside budget at step {i}"); 1775 + }; 1776 + let elapsed = started.elapsed(); 1777 + assert!( 1778 + elapsed <= core::time::Duration::from_millis(16), 1779 + "drag step {i} took {elapsed:?}, exceeding 16 ms budget", 1780 + ); 1781 + }); 1782 + } 1783 + 1784 + mod determinism { 1785 + use super::*; 1786 + use proptest::prelude::*; 1787 + 1788 + #[derive(Copy, Clone, Debug)] 1789 + struct DragSeed { 1790 + base_x: i32, 1791 + base_y: i32, 1792 + target_dx: i32, 1793 + target_dy: i32, 1794 + length: u32, 1795 + } 1796 + 1797 + fn arb_drag_seed() -> impl Strategy<Value = DragSeed> { 1798 + ( 1799 + -10_000_i32..10_000, 1800 + -10_000_i32..10_000, 1801 + -20_000_i32..20_000, 1802 + -20_000_i32..20_000, 1803 + 500_u32..50_000, 1804 + ) 1805 + .prop_map(|(base_x, base_y, target_dx, target_dy, length)| DragSeed { 1806 + base_x, 1807 + base_y, 1808 + target_dx, 1809 + target_dy, 1810 + length, 1811 + }) 1812 + } 1813 + 1814 + fn build_horizontal_line(seed: DragSeed) -> (Sketch, SketchEntityId) { 1815 + let bx = f64::from(seed.base_x) * 1e-3; 1816 + let by = f64::from(seed.base_y) * 1e-3; 1817 + let length_mm_value = f64::from(seed.length) * 1e-3; 1818 + let base = Sketch::new(xy_plane()); 1819 + let (with_points, outs) = apply( 1820 + &base, 1821 + vec![ 1822 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(bx, by))), 1823 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(bx + 1.0, by + 1.0))), 1824 + ], 1825 + ); 1826 + let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i])); 1827 + let (with_line, line_outs) = apply( 1828 + &with_points, 1829 + vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))], 1830 + ); 1831 + let line = entity_id(&line_outs[0]); 1832 + let sketch = apply( 1833 + &with_line, 1834 + vec![ 1835 + SketchEdit::AddRelation(SketchRelation::Fix(a)), 1836 + SketchEdit::AddRelation(SketchRelation::Horizontal(line)), 1837 + SketchEdit::AddDimension(SketchDimension::Linear { 1838 + a, 1839 + b, 1840 + value: mm_val(length_mm_value), 1841 + kind: DimensionKind::Driving, 1842 + }), 1843 + ], 1844 + ) 1845 + .0; 1846 + (sketch, b) 1847 + } 1848 + 1849 + fn assert_coord_pairs_bit_identical( 1850 + lhs: &[(f64, f64)], 1851 + rhs: &[(f64, f64)], 1852 + ) -> Result<(), TestCaseError> { 1853 + lhs.iter().zip(rhs.iter()).try_for_each(|(a, b)| { 1854 + prop_assert_eq!(a.0.to_bits(), b.0.to_bits()); 1855 + prop_assert_eq!(a.1.to_bits(), b.1.to_bits()); 1856 + Ok(()) 1857 + }) 1858 + } 1859 + 1860 + proptest! { 1861 + #[test] 1862 + fn solve_is_bit_identical_for_random_horizontal_lines(seed in arb_drag_seed()) { 1863 + let (sketch, _b) = build_horizontal_line(seed); 1864 + let Ok(first) = sketch.solve() else { 1865 + return Ok(()); 1866 + }; 1867 + let Ok(second) = sketch.solve() else { 1868 + unreachable!("first solve succeeded; second cannot diverge under determinism"); 1869 + }; 1870 + assert_coord_pairs_bit_identical(&point_coords(&first), &point_coords(&second))?; 1871 + } 1872 + 1873 + #[test] 1874 + fn drag_is_bit_identical_for_random_horizontal_lines(seed in arb_drag_seed()) { 1875 + let (sketch, drag_id) = build_horizontal_line(seed); 1876 + let tx = f64::from(seed.base_x + seed.target_dx) * 1e-3; 1877 + let ty = f64::from(seed.base_y + seed.target_dy) * 1e-3; 1878 + let target = Point2::from_mm(tx, ty); 1879 + let Ok(first) = sketch.solve_with_drag(drag_id, target, generous_budget()) else { 1880 + return Ok(()); 1881 + }; 1882 + let Ok(second) = sketch.solve_with_drag(drag_id, target, generous_budget()) else { 1883 + unreachable!("first drag succeeded; second must too under determinism"); 1884 + }; 1885 + assert_coord_pairs_bit_identical(&point_coords(&first), &point_coords(&second))?; 1886 + } 1887 + } 1888 + } 1889 + 1890 + #[test] 1891 + fn arc_intrinsic_residual_tags_entity_origin() { 1892 + let base = Sketch::new(xy_plane()); 1893 + let (with_points, outs) = apply( 1894 + &base, 1895 + vec![ 1896 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 1897 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(2.0, 0.0))), 1898 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 2.0))), 1899 + ], 1900 + ); 1901 + let [c, s, e] = [0_usize, 1, 2].map(|i| entity_id(&outs[i])); 1902 + let sketch = apply( 1903 + &with_points, 1904 + vec![SketchEdit::AddEntity(SketchEntity::arc(c, s, e, false))], 1905 + ) 1906 + .0; 1907 + let arc_id = sketch.entity_order()[3]; 1908 + let (system, mapping) = sketch.lower(); 1909 + assert_eq!(system.row_count(), 1, "one intrinsic row per arc"); 1910 + let got = mapping.item_of_residual(ResidualIndex::new(0)); 1911 + assert_eq!(got, Some(SketchItemId::Entity(arc_id))); 1912 + } 1913 + }
+74
crates/bone-document/src/undo.rs
··· 1 + use std::collections::VecDeque; 2 + use std::mem; 3 + use std::num::NonZeroUsize; 4 + 5 + use crate::Document; 6 + 7 + #[derive(Clone, Debug)] 8 + pub struct UndoStack { 9 + past: VecDeque<Document>, 10 + future: VecDeque<Document>, 11 + capacity: NonZeroUsize, 12 + } 13 + 14 + impl UndoStack { 15 + #[must_use] 16 + pub fn with_capacity(capacity: NonZeroUsize) -> Self { 17 + Self { 18 + past: VecDeque::new(), 19 + future: VecDeque::new(), 20 + capacity, 21 + } 22 + } 23 + 24 + pub fn record(&mut self, previous: Document) { 25 + self.future.clear(); 26 + if self.past.len() == self.capacity.get() { 27 + self.past.pop_front(); 28 + } 29 + self.past.push_back(previous); 30 + } 31 + 32 + pub fn undo(&mut self, current: &mut Document) -> bool { 33 + let Some(previous) = self.past.pop_back() else { 34 + return false; 35 + }; 36 + let old = mem::replace(current, previous); 37 + self.future.push_back(old); 38 + true 39 + } 40 + 41 + pub fn redo(&mut self, current: &mut Document) -> bool { 42 + let Some(next) = self.future.pop_back() else { 43 + return false; 44 + }; 45 + let old = mem::replace(current, next); 46 + self.past.push_back(old); 47 + true 48 + } 49 + 50 + #[must_use] 51 + pub fn can_undo(&self) -> bool { 52 + !self.past.is_empty() 53 + } 54 + 55 + #[must_use] 56 + pub fn can_redo(&self) -> bool { 57 + !self.future.is_empty() 58 + } 59 + 60 + #[must_use] 61 + pub fn capacity(&self) -> NonZeroUsize { 62 + self.capacity 63 + } 64 + 65 + #[must_use] 66 + pub fn past_len(&self) -> usize { 67 + self.past.len() 68 + } 69 + 70 + #[must_use] 71 + pub fn future_len(&self) -> usize { 72 + self.future.len() 73 + } 74 + }
+254
crates/bone-document/tests/evaluator.rs
··· 1 + use bone_document::{ 2 + DimensionKind, Document, EditOutcome, EvaluatedSketch, FeatureCache, FeatureNode, Sketch, 3 + SketchDimension, SketchEdit, SketchEntity, SketchRelation, evaluate_sketch, 4 + }; 5 + use bone_types::{ 6 + DocumentId, FeatureId, Length, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, 7 + millimeter, 8 + }; 9 + use slotmap::KeyData; 10 + 11 + fn plane() -> SketchPlaneBasis { 12 + let Ok(basis) = SketchPlaneBasis::new( 13 + Point3::origin(), 14 + UnitVec3::x_axis(), 15 + UnitVec3::y_axis(), 16 + Tolerance::new(1e-9), 17 + ) else { 18 + panic!("xy plane"); 19 + }; 20 + basis 21 + } 22 + 23 + fn mm(v: f64) -> Length { 24 + Length::new::<millimeter>(v) 25 + } 26 + 27 + fn sketch_id(idx: u32) -> SketchId { 28 + SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 29 + } 30 + 31 + fn document_id(idx: u32) -> DocumentId { 32 + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 33 + } 34 + 35 + fn feature_id(idx: u32) -> FeatureId { 36 + FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 37 + } 38 + 39 + fn horizontal_line_sketch(dim_mm: f64) -> Sketch { 40 + let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 41 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 42 + )) else { 43 + panic!("a"); 44 + }; 45 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 46 + Point2::from_mm(1.0, 0.0), 47 + ))) else { 48 + panic!("b"); 49 + }; 50 + let Ok((s, EditOutcome::Entity(line))) = 51 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 52 + else { 53 + panic!("line"); 54 + }; 55 + let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Fix(a))) else { 56 + panic!("fix"); 57 + }; 58 + let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line))) else { 59 + panic!("horizontal"); 60 + }; 61 + let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 62 + a, 63 + b, 64 + value: mm(dim_mm), 65 + kind: DimensionKind::Driving, 66 + })) else { 67 + panic!("dim"); 68 + }; 69 + s 70 + } 71 + 72 + fn conflicting_sketch() -> Sketch { 73 + let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 74 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 75 + )) else { 76 + panic!("a"); 77 + }; 78 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 79 + Point2::from_mm(1.0, 0.0), 80 + ))) else { 81 + panic!("b"); 82 + }; 83 + let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Fix(a))) else { 84 + panic!("fix"); 85 + }; 86 + let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 87 + a, 88 + b, 89 + value: mm(5.0), 90 + kind: DimensionKind::Driving, 91 + })) else { 92 + panic!("dim1"); 93 + }; 94 + let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 95 + a, 96 + b, 97 + value: mm(10.0), 98 + kind: DimensionKind::Driving, 99 + })) else { 100 + panic!("dim2"); 101 + }; 102 + s 103 + } 104 + 105 + #[test] 106 + fn evaluate_sketch_returns_solved_variant_on_success() { 107 + let sketch = horizontal_line_sketch(10.0); 108 + let output = evaluate_sketch(&sketch); 109 + let EvaluatedSketch::Solved(solved) = output else { 110 + panic!("expected Solved, got {output:?}"); 111 + }; 112 + let order = solved.entity_order(); 113 + let b = order[1]; 114 + let SketchEntity::Point(p) = solved.entities()[b] else { 115 + panic!("b is point"); 116 + }; 117 + let (x, y) = p.at().coords_mm(); 118 + assert!((x - 10.0).abs() < 1e-9, "x = {x}"); 119 + assert!(y.abs() < 1e-9, "y = {y}"); 120 + } 121 + 122 + #[test] 123 + fn evaluate_sketch_returns_failed_variant_on_conflict() { 124 + let sketch = conflicting_sketch(); 125 + let output = evaluate_sketch(&sketch); 126 + assert!( 127 + matches!(output, EvaluatedSketch::Failed(_)), 128 + "expected Failed, got {output:?}" 129 + ); 130 + } 131 + 132 + #[test] 133 + fn feature_cache_reuses_solution_for_identical_input() { 134 + let sketch = horizontal_line_sketch(10.0); 135 + let mut cache = FeatureCache::new(); 136 + let fid = feature_id(7); 137 + let first = cache.evaluate(fid, &sketch).clone(); 138 + let second = cache.evaluate(fid, &sketch).clone(); 139 + assert_eq!(first, second); 140 + assert_eq!(cache.len(), 1); 141 + assert!(cache.lookup(fid).is_some()); 142 + } 143 + 144 + #[test] 145 + fn feature_cache_refreshes_on_input_change() { 146 + let mut cache = FeatureCache::new(); 147 + let fid = feature_id(7); 148 + let first = cache.evaluate(fid, &horizontal_line_sketch(10.0)).clone(); 149 + let second = cache.evaluate(fid, &horizontal_line_sketch(20.0)).clone(); 150 + let EvaluatedSketch::Solved(first_solved) = first else { 151 + panic!("first Solved"); 152 + }; 153 + let EvaluatedSketch::Solved(second_solved) = second else { 154 + panic!("second Solved"); 155 + }; 156 + assert_ne!(first_solved, second_solved); 157 + assert_eq!(cache.len(), 1); 158 + } 159 + 160 + #[test] 161 + fn feature_cache_keeps_entries_per_feature() { 162 + let mut cache = FeatureCache::new(); 163 + let a = feature_id(1); 164 + let b = feature_id(2); 165 + cache.evaluate(a, &horizontal_line_sketch(10.0)); 166 + cache.evaluate(b, &horizontal_line_sketch(20.0)); 167 + assert_eq!(cache.len(), 2); 168 + assert!(cache.lookup(a).is_some()); 169 + assert!(cache.lookup(b).is_some()); 170 + } 171 + 172 + #[test] 173 + fn feature_cache_invalidate_drops_entry() { 174 + let mut cache = FeatureCache::new(); 175 + let fid = feature_id(7); 176 + cache.evaluate(fid, &horizontal_line_sketch(10.0)); 177 + assert!(cache.invalidate(fid)); 178 + assert!(cache.lookup(fid).is_none()); 179 + assert!(!cache.invalidate(fid)); 180 + } 181 + 182 + #[test] 183 + fn feature_cache_retain_drops_absent_features() { 184 + let mut cache = FeatureCache::new(); 185 + let keep = feature_id(1); 186 + let drop = feature_id(2); 187 + cache.evaluate(keep, &horizontal_line_sketch(10.0)); 188 + cache.evaluate(drop, &horizontal_line_sketch(20.0)); 189 + cache.retain([keep]); 190 + assert!(cache.lookup(keep).is_some()); 191 + assert!(cache.lookup(drop).is_none()); 192 + assert_eq!(cache.len(), 1); 193 + } 194 + 195 + #[test] 196 + fn document_resolves_feature_id_back_to_sketch() { 197 + let mut doc = Document::new(document_id(1), "d".to_owned()); 198 + let sid = sketch_id(7); 199 + doc.insert_sketch(sid, "S".to_owned(), horizontal_line_sketch(10.0)); 200 + let feature = doc.feature_tree().iter().find_map(|(fid, node)| { 201 + matches!(node, FeatureNode::Sketch(id) if id == sid).then_some(fid) 202 + }); 203 + let Some(fid) = feature else { 204 + panic!("sketch feature present"); 205 + }; 206 + let Some(sketch) = doc.sketch_of_feature(fid) else { 207 + panic!("sketch addressable through feature id"); 208 + }; 209 + let Some(direct) = doc.sketch(sid) else { 210 + panic!("sketch in map"); 211 + }; 212 + assert_eq!(sketch, direct); 213 + } 214 + 215 + #[test] 216 + fn feature_cache_refreshes_when_feature_id_is_reused() { 217 + let mut doc = Document::new(document_id(1), "d".to_owned()); 218 + let sid_a = sketch_id(10); 219 + let sid_b = sketch_id(11); 220 + doc.insert_sketch(sid_a, "A".to_owned(), horizontal_line_sketch(10.0)); 221 + let Some(fid_a) = doc.feature_tree().iter().find_map(|(fid, node)| { 222 + matches!(node, FeatureNode::Sketch(id) if id == sid_a).then_some(fid) 223 + }) else { 224 + panic!("A feature present"); 225 + }; 226 + 227 + let mut cache = FeatureCache::new(); 228 + let Some(sketch_a) = doc.sketch(sid_a) else { 229 + panic!("A in map"); 230 + }; 231 + let EvaluatedSketch::Solved(solved_a) = cache.evaluate(fid_a, sketch_a).clone() else { 232 + panic!("A solves"); 233 + }; 234 + 235 + doc.remove_sketch(sid_a); 236 + doc.insert_sketch(sid_b, "B".to_owned(), horizontal_line_sketch(20.0)); 237 + let Some(fid_b) = doc.feature_tree().iter().find_map(|(fid, node)| { 238 + matches!(node, FeatureNode::Sketch(id) if id == sid_b).then_some(fid) 239 + }) else { 240 + panic!("B feature present"); 241 + }; 242 + assert_eq!( 243 + fid_a, fid_b, 244 + "precondition: FeatureTree reuses the id of a removed feature" 245 + ); 246 + 247 + let Some(sketch_b) = doc.sketch(sid_b) else { 248 + panic!("B in map"); 249 + }; 250 + let EvaluatedSketch::Solved(solved_b) = cache.evaluate(fid_b, sketch_b).clone() else { 251 + panic!("B solves"); 252 + }; 253 + assert_ne!(solved_a, solved_b, "cache must refresh on reused FeatureId"); 254 + }
+178
crates/bone-document/tests/folder_jj.rs
··· 1 + use std::process::Command; 2 + 3 + use bone_document::{ 4 + DimensionKind, Document, DocumentFolder, EditOutcome, Sketch, SketchDimension, SketchEdit, 5 + SketchEntity, load, save, 6 + }; 7 + use bone_types::{ 8 + DocumentId, Length, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter, 9 + }; 10 + use slotmap::KeyData; 11 + use tempfile::{TempDir, tempdir}; 12 + 13 + fn plane() -> SketchPlaneBasis { 14 + let Ok(basis) = SketchPlaneBasis::new( 15 + Point3::origin(), 16 + UnitVec3::x_axis(), 17 + UnitVec3::y_axis(), 18 + Tolerance::new(1e-9), 19 + ) else { 20 + panic!("xy plane"); 21 + }; 22 + basis 23 + } 24 + 25 + fn mm(v: f64) -> Length { 26 + Length::new::<millimeter>(v) 27 + } 28 + 29 + fn ok_dir() -> TempDir { 30 + let Ok(dir) = tempdir() else { 31 + panic!("tempdir"); 32 + }; 33 + dir 34 + } 35 + 36 + fn sketch_id(idx: u32) -> SketchId { 37 + SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 38 + } 39 + 40 + fn document_id(idx: u32) -> DocumentId { 41 + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 42 + } 43 + 44 + fn jj_available() -> bool { 45 + let Ok(out) = Command::new("jj").arg("--version").output() else { 46 + return false; 47 + }; 48 + out.status.success() 49 + } 50 + 51 + fn run(cmd: &mut Command) -> String { 52 + let Ok(out) = cmd.output() else { 53 + panic!("{cmd:?}"); 54 + }; 55 + assert!( 56 + out.status.success(), 57 + "{cmd:?} failed: {}\n{}", 58 + String::from_utf8_lossy(&out.stdout), 59 + String::from_utf8_lossy(&out.stderr), 60 + ); 61 + String::from_utf8_lossy(&out.stdout).into_owned() 62 + } 63 + 64 + fn rectangle() -> Sketch { 65 + let script = [ 66 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 67 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), 68 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))), 69 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))), 70 + ]; 71 + let Ok((s, _)) = Sketch::new(plane()).apply_all(script) else { 72 + panic!("rectangle"); 73 + }; 74 + s 75 + } 76 + 77 + fn assert_save(doc: &Document, folder: &DocumentFolder) { 78 + let Ok(()) = save(doc, folder) else { 79 + panic!("save"); 80 + }; 81 + } 82 + 83 + fn assert_load(folder: &DocumentFolder) -> Document { 84 + let Ok(doc) = load(folder) else { 85 + panic!("load"); 86 + }; 87 + doc 88 + } 89 + 90 + #[test] 91 + fn jj_diff_on_dimension_edit_is_text_only() { 92 + if !jj_available() { 93 + eprintln!("skip: jj not on PATH"); 94 + return; 95 + } 96 + 97 + let dir = ok_dir(); 98 + let folder = DocumentFolder::new(dir.path().join("demo.bone")); 99 + 100 + let mut doc = Document::new(document_id(1), "demo".to_owned()); 101 + let sid = sketch_id(1); 102 + let rect = rectangle(); 103 + let Some(&a) = rect.entity_order().first() else { 104 + panic!("rect has points"); 105 + }; 106 + let Some(&b) = rect.entity_order().get(1) else { 107 + panic!("rect has points"); 108 + }; 109 + let Ok((rect, EditOutcome::Dimension(dim_id))) = 110 + rect.apply(SketchEdit::AddDimension(SketchDimension::Linear { 111 + a, 112 + b, 113 + value: mm(10.0), 114 + kind: DimensionKind::Driving, 115 + })) 116 + else { 117 + panic!("dim"); 118 + }; 119 + doc.insert_sketch(sid, "Sketch1".to_owned(), rect.clone()); 120 + assert_save(&doc, &folder); 121 + 122 + run(Command::new("jj") 123 + .arg("git") 124 + .arg("init") 125 + .arg("--colocate") 126 + .current_dir(folder.path())); 127 + run(Command::new("jj") 128 + .arg("config") 129 + .arg("set") 130 + .arg("--repo") 131 + .arg("user.name") 132 + .arg("test") 133 + .current_dir(folder.path())); 134 + run(Command::new("jj") 135 + .arg("config") 136 + .arg("set") 137 + .arg("--repo") 138 + .arg("user.email") 139 + .arg("test@example.com") 140 + .current_dir(folder.path())); 141 + run(Command::new("jj") 142 + .arg("describe") 143 + .arg("--message") 144 + .arg("initial") 145 + .current_dir(folder.path())); 146 + run(Command::new("jj").arg("new").current_dir(folder.path())); 147 + 148 + let reopened = assert_load(&folder); 149 + let Some(sketch) = reopened.sketch(sid) else { 150 + panic!("sketch missing"); 151 + }; 152 + let Ok((updated_sketch, _)) = sketch.clone().apply(SketchEdit::UpdateDimensionValue { 153 + id: dim_id, 154 + value: bone_document::DimensionValue::Length(mm(12.5)), 155 + }) else { 156 + panic!("update dim"); 157 + }; 158 + let mut next_doc = reopened.clone(); 159 + next_doc.replace_sketch(sid, updated_sketch); 160 + assert_save(&next_doc, &folder); 161 + 162 + let diff = run(Command::new("jj") 163 + .arg("diff") 164 + .arg("--git") 165 + .current_dir(folder.path())); 166 + assert!( 167 + diff.contains("sketches/"), 168 + "expected sketch file in diff:\n{diff}" 169 + ); 170 + assert!( 171 + diff.contains('+') && diff.contains('-'), 172 + "expected text diff markers:\n{diff}" 173 + ); 174 + assert!( 175 + !diff.contains("Binary files"), 176 + "expected no binary diff:\n{diff}" 177 + ); 178 + }
+7
crates/bone-document/tests/folder_property.proptest-regressions
··· 1 + # Seeds for failure cases proptest has generated in the past. It is 2 + # automatically read and these particular cases re-run before any 3 + # novel cases are generated. 4 + # 5 + # It is recommended to check this file in to source control so that 6 + # everyone who runs the test benefits from these saved cases. 7 + cc b546c8b5dd00350941227512211737f6516fcf6278d102fa85ee6aa30bb41584 # shrinks to steps = [Point(0, 0), Point(0, 0), LinearDim { ai: 0, bi: 0, v: 56114, driven: false }]
+428
crates/bone-document/tests/folder_property.rs
··· 1 + use bone_document::{ 2 + DimensionKind, Document, DocumentFolder, Sketch, SketchDimension, SketchEdit, SketchEntity, 3 + SketchEntityKind, SketchParameter, SketchRelation, load, save, 4 + }; 5 + use bone_types::{ 6 + Angle, DocumentId, Length, Parameter, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance, 7 + UnitVec3, degree, millimeter, 8 + }; 9 + use proptest::prelude::*; 10 + use slotmap::KeyData; 11 + use tempfile::tempdir; 12 + 13 + fn plane() -> SketchPlaneBasis { 14 + let Ok(basis) = SketchPlaneBasis::new( 15 + Point3::origin(), 16 + UnitVec3::x_axis(), 17 + UnitVec3::y_axis(), 18 + Tolerance::new(1e-9), 19 + ) else { 20 + panic!("xy plane"); 21 + }; 22 + basis 23 + } 24 + 25 + fn mm(v: f64) -> Length { 26 + Length::new::<millimeter>(v) 27 + } 28 + 29 + fn deg(v: f64) -> Angle { 30 + Angle::new::<degree>(v) 31 + } 32 + 33 + fn sketch_id(idx: u32) -> SketchId { 34 + SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 35 + } 36 + 37 + fn document_id(idx: u32) -> DocumentId { 38 + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 39 + } 40 + 41 + #[derive(Copy, Clone, Debug)] 42 + enum Step { 43 + Point(i16, i16), 44 + Parameter(i16), 45 + Line { 46 + ai: u8, 47 + bi: u8, 48 + cons: bool, 49 + }, 50 + Circle { 51 + ci: u8, 52 + r: u16, 53 + cons: bool, 54 + }, 55 + Arc { 56 + ci: u8, 57 + si: u8, 58 + ei: u8, 59 + }, 60 + Coincident { 61 + pi: u8, 62 + xi: u8, 63 + }, 64 + Horizontal { 65 + li: u8, 66 + }, 67 + Vertical { 68 + li: u8, 69 + }, 70 + Parallel { 71 + ai: u8, 72 + bi: u8, 73 + }, 74 + Perpendicular { 75 + ai: u8, 76 + bi: u8, 77 + }, 78 + Tangent { 79 + ai: u8, 80 + bi: u8, 81 + }, 82 + EqualLines { 83 + ai: u8, 84 + bi: u8, 85 + }, 86 + EqualRounds { 87 + ai: u8, 88 + bi: u8, 89 + }, 90 + Concentric { 91 + ai: u8, 92 + bi: u8, 93 + }, 94 + Fix { 95 + ei: u8, 96 + }, 97 + LinearDim { 98 + ai: u8, 99 + bi: u8, 100 + v: u16, 101 + driven: bool, 102 + }, 103 + RadiusDim { 104 + ti: u8, 105 + v: u16, 106 + driven: bool, 107 + }, 108 + DiameterDim { 109 + ti: u8, 110 + v: u16, 111 + driven: bool, 112 + }, 113 + AngularDim { 114 + ai: u8, 115 + bi: u8, 116 + deg: u16, 117 + }, 118 + ToggleConstruction { 119 + ei: u8, 120 + }, 121 + } 122 + 123 + fn arb_step() -> impl Strategy<Value = Step> { 124 + prop_oneof![ 125 + (any::<i16>(), any::<i16>()).prop_map(|(x, y)| Step::Point(x, y)), 126 + any::<i16>().prop_map(Step::Parameter), 127 + (any::<u8>(), any::<u8>(), any::<bool>()).prop_map(|(ai, bi, cons)| Step::Line { 128 + ai, 129 + bi, 130 + cons 131 + }), 132 + (any::<u8>(), any::<u16>(), any::<bool>()).prop_map(|(ci, r, cons)| Step::Circle { 133 + ci, 134 + r: r % 200 + 1, 135 + cons 136 + }), 137 + (any::<u8>(), any::<u8>(), any::<u8>()).prop_map(|(ci, si, ei)| Step::Arc { ci, si, ei }), 138 + (any::<u8>(), any::<u8>()).prop_map(|(pi, xi)| Step::Coincident { pi, xi }), 139 + any::<u8>().prop_map(|li| Step::Horizontal { li }), 140 + any::<u8>().prop_map(|li| Step::Vertical { li }), 141 + (any::<u8>(), any::<u8>()).prop_map(|(ai, bi)| Step::Parallel { ai, bi }), 142 + (any::<u8>(), any::<u8>()).prop_map(|(ai, bi)| Step::Perpendicular { ai, bi }), 143 + (any::<u8>(), any::<u8>()).prop_map(|(ai, bi)| Step::Tangent { ai, bi }), 144 + (any::<u8>(), any::<u8>()).prop_map(|(ai, bi)| Step::EqualLines { ai, bi }), 145 + (any::<u8>(), any::<u8>()).prop_map(|(ai, bi)| Step::EqualRounds { ai, bi }), 146 + (any::<u8>(), any::<u8>()).prop_map(|(ai, bi)| Step::Concentric { ai, bi }), 147 + any::<u8>().prop_map(|ei| Step::Fix { ei }), 148 + (any::<u8>(), any::<u8>(), any::<u16>(), any::<bool>()) 149 + .prop_map(|(ai, bi, v, driven)| Step::LinearDim { ai, bi, v, driven }), 150 + (any::<u8>(), any::<u16>(), any::<bool>()).prop_map(|(ti, v, driven)| Step::RadiusDim { 151 + ti, 152 + v: v % 200 + 1, 153 + driven 154 + }), 155 + (any::<u8>(), any::<u16>(), any::<bool>()).prop_map(|(ti, v, driven)| Step::DiameterDim { 156 + ti, 157 + v: v % 200 + 1, 158 + driven 159 + }), 160 + (any::<u8>(), any::<u8>(), any::<u16>()).prop_map(|(ai, bi, deg)| Step::AngularDim { 161 + ai, 162 + bi, 163 + deg: deg % 359 + 1 164 + }), 165 + any::<u8>().prop_map(|ei| Step::ToggleConstruction { ei }), 166 + ] 167 + } 168 + 169 + fn entities_of_kind(s: &Sketch, kind: SketchEntityKind) -> Vec<bone_types::SketchEntityId> { 170 + s.entity_order() 171 + .iter() 172 + .copied() 173 + .filter(|id| s.entities()[*id].kind() == kind) 174 + .collect() 175 + } 176 + 177 + fn pick<T: Copy>(xs: &[T], i: u8) -> Option<T> { 178 + if xs.is_empty() { 179 + None 180 + } else { 181 + Some(xs[usize::from(i) % xs.len()]) 182 + } 183 + } 184 + 185 + fn pick_two_distinct<T: Copy + Eq>(xs: &[T], ai: u8, bi: u8) -> Option<(T, T)> { 186 + if xs.len() < 2 { 187 + return None; 188 + } 189 + let a = usize::from(ai) % xs.len(); 190 + let offset = usize::from(bi) % (xs.len() - 1) + 1; 191 + let b = (a + offset) % xs.len(); 192 + Some((xs[a], xs[b])) 193 + } 194 + 195 + fn pick_three_distinct<T: Copy + Eq>(xs: &[T], ai: u8, bi: u8, ci: u8) -> Option<(T, T, T)> { 196 + if xs.len() < 3 { 197 + return None; 198 + } 199 + let a = usize::from(ai) % xs.len(); 200 + let b_off = usize::from(bi) % (xs.len() - 1) + 1; 201 + let b = (a + b_off) % xs.len(); 202 + let remaining: Vec<_> = (0..xs.len()).filter(|&i| i != a && i != b).collect(); 203 + let c = remaining[usize::from(ci) % remaining.len()]; 204 + Some((xs[a], xs[b], xs[c])) 205 + } 206 + 207 + fn dim_kind(driven: bool) -> DimensionKind { 208 + if driven { 209 + DimensionKind::Driven 210 + } else { 211 + DimensionKind::Driving 212 + } 213 + } 214 + 215 + fn rounds(s: &Sketch) -> Vec<bone_types::SketchEntityId> { 216 + entities_of_kind(s, SketchEntityKind::Arc) 217 + .into_iter() 218 + .chain(entities_of_kind(s, SketchEntityKind::Circle)) 219 + .collect() 220 + } 221 + 222 + fn resolve(s: &Sketch, step: Step) -> Option<SketchEdit> { 223 + match step { 224 + Step::Point(..) | Step::Parameter(..) => Some(resolve_atom(step)), 225 + Step::Line { .. } | Step::Circle { .. } | Step::Arc { .. } => resolve_entity(s, step), 226 + Step::Coincident { .. } 227 + | Step::Horizontal { .. } 228 + | Step::Vertical { .. } 229 + | Step::Parallel { .. } 230 + | Step::Perpendicular { .. } 231 + | Step::Tangent { .. } 232 + | Step::EqualLines { .. } 233 + | Step::EqualRounds { .. } 234 + | Step::Concentric { .. } 235 + | Step::Fix { .. } => resolve_relation(s, step), 236 + Step::LinearDim { .. } 237 + | Step::RadiusDim { .. } 238 + | Step::DiameterDim { .. } 239 + | Step::AngularDim { .. } => resolve_dimension(s, step), 240 + Step::ToggleConstruction { ei } => { 241 + let id = pick(s.entity_order(), ei)?; 242 + (!s.entities()[id].is_point()).then_some(SketchEdit::SetConstruction { 243 + id, 244 + for_construction: true, 245 + }) 246 + } 247 + } 248 + } 249 + 250 + fn resolve_atom(step: Step) -> SketchEdit { 251 + match step { 252 + Step::Point(x, y) => SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm( 253 + f64::from(x) / 100.0, 254 + f64::from(y) / 100.0, 255 + ))), 256 + Step::Parameter(v) => { 257 + SketchEdit::AddParameter(SketchParameter::new(Parameter::new(f64::from(v) / 100.0))) 258 + } 259 + _ => unreachable!("caller routes only Point and Parameter here"), 260 + } 261 + } 262 + 263 + fn resolve_entity(s: &Sketch, step: Step) -> Option<SketchEdit> { 264 + match step { 265 + Step::Line { ai, bi, cons } => { 266 + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?; 267 + Some(SketchEdit::AddEntity(SketchEntity::line(a, b, cons))) 268 + } 269 + Step::Circle { ci, r, cons } => { 270 + let c = pick(&entities_of_kind(s, SketchEntityKind::Point), ci)?; 271 + Some(SketchEdit::AddEntity(SketchEntity::circle( 272 + c, 273 + mm(f64::from(r)), 274 + cons, 275 + ))) 276 + } 277 + Step::Arc { ci, si, ei } => { 278 + let (c, start, end) = 279 + pick_three_distinct(&entities_of_kind(s, SketchEntityKind::Point), ci, si, ei)?; 280 + Some(SketchEdit::AddEntity(SketchEntity::arc( 281 + c, start, end, false, 282 + ))) 283 + } 284 + _ => unreachable!("caller routes only Line, Circle, Arc here"), 285 + } 286 + } 287 + 288 + fn resolve_relation(s: &Sketch, step: Step) -> Option<SketchEdit> { 289 + let rel = match step { 290 + Step::Coincident { pi, xi } => { 291 + let p = pick(&entities_of_kind(s, SketchEntityKind::Point), pi)?; 292 + let others: Vec<_> = s 293 + .entity_order() 294 + .iter() 295 + .copied() 296 + .filter(|id| *id != p) 297 + .collect(); 298 + SketchRelation::Coincident(p, pick(&others, xi)?) 299 + } 300 + Step::Horizontal { li } => { 301 + SketchRelation::Horizontal(pick(&entities_of_kind(s, SketchEntityKind::Line), li)?) 302 + } 303 + Step::Vertical { li } => { 304 + SketchRelation::Vertical(pick(&entities_of_kind(s, SketchEntityKind::Line), li)?) 305 + } 306 + Step::Parallel { ai, bi } => { 307 + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; 308 + SketchRelation::Parallel(a, b) 309 + } 310 + Step::Perpendicular { ai, bi } => { 311 + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; 312 + SketchRelation::Perpendicular(a, b) 313 + } 314 + Step::Tangent { ai, bi } => { 315 + let round = rounds(s); 316 + let lines = entities_of_kind(s, SketchEntityKind::Line); 317 + let a = pick(&round, ai)?; 318 + let b = if lines.is_empty() { 319 + let remaining: Vec<_> = round.iter().copied().filter(|id| *id != a).collect(); 320 + pick(&remaining, bi)? 321 + } else { 322 + pick(&lines, bi)? 323 + }; 324 + SketchRelation::Tangent(a, b) 325 + } 326 + Step::EqualLines { ai, bi } => { 327 + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; 328 + SketchRelation::Equal(a, b) 329 + } 330 + Step::EqualRounds { ai, bi } => { 331 + let (a, b) = pick_two_distinct(&rounds(s), ai, bi)?; 332 + SketchRelation::Equal(a, b) 333 + } 334 + Step::Concentric { ai, bi } => { 335 + let (a, b) = pick_two_distinct(&rounds(s), ai, bi)?; 336 + SketchRelation::Concentric(a, b) 337 + } 338 + Step::Fix { ei } => SketchRelation::Fix(pick(s.entity_order(), ei)?), 339 + _ => unreachable!("caller routes only relations here"), 340 + }; 341 + Some(SketchEdit::AddRelation(rel)) 342 + } 343 + 344 + fn resolve_dimension(s: &Sketch, step: Step) -> Option<SketchEdit> { 345 + let dim = match step { 346 + Step::LinearDim { ai, bi, v, driven } => { 347 + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Point), ai, bi)?; 348 + SketchDimension::Linear { 349 + a, 350 + b, 351 + value: mm(f64::from(v)), 352 + kind: dim_kind(driven), 353 + } 354 + } 355 + Step::RadiusDim { ti, v, driven } => SketchDimension::Radius { 356 + target: pick(&rounds(s), ti)?, 357 + value: mm(f64::from(v)), 358 + kind: dim_kind(driven), 359 + }, 360 + Step::DiameterDim { ti, v, driven } => SketchDimension::Diameter { 361 + target: pick(&rounds(s), ti)?, 362 + value: mm(f64::from(v)), 363 + kind: dim_kind(driven), 364 + }, 365 + Step::AngularDim { ai, bi, deg: d } => { 366 + let (a, b) = pick_two_distinct(&entities_of_kind(s, SketchEntityKind::Line), ai, bi)?; 367 + SketchDimension::Angular { 368 + a, 369 + b, 370 + value: deg(f64::from(d)), 371 + kind: DimensionKind::Driving, 372 + } 373 + } 374 + _ => unreachable!("caller routes only dimensions here"), 375 + }; 376 + Some(SketchEdit::AddDimension(dim)) 377 + } 378 + 379 + fn build(steps: Vec<Step>) -> Sketch { 380 + steps.into_iter().fold(Sketch::new(plane()), |sk, step| { 381 + resolve(&sk, step) 382 + .and_then(|edit| sk.clone().apply(edit).ok().map(|(s, _)| s)) 383 + .unwrap_or(sk) 384 + }) 385 + } 386 + 387 + proptest! { 388 + #![proptest_config(ProptestConfig { 389 + cases: 32, 390 + .. ProptestConfig::default() 391 + })] 392 + 393 + #[test] 394 + fn load_save_roundtrip_preserves_sketch(steps in prop::collection::vec(arb_step(), 0..30)) { 395 + let sketch = build(steps); 396 + let dir = tempdir().map_err(|e| TestCaseError::Fail(format!("tempdir: {e}").into()))?; 397 + let folder = DocumentFolder::new(dir.path().join("fuzz.bone")); 398 + let mut doc = Document::new(document_id(1), "fuzz".to_owned()); 399 + doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch.clone()); 400 + save(&doc, &folder).map_err(|e| TestCaseError::Fail(format!("save: {e}").into()))?; 401 + let loaded = load(&folder).map_err(|e| TestCaseError::Fail(format!("load: {e}").into()))?; 402 + let Some(round) = loaded.sketch(sketch_id(1)) else { 403 + return Err(TestCaseError::Fail("sketch missing".into())); 404 + }; 405 + prop_assert_eq!(round, &sketch); 406 + } 407 + 408 + #[test] 409 + fn double_save_is_bit_identical(steps in prop::collection::vec(arb_step(), 0..30)) { 410 + let sketch = build(steps); 411 + let dir = tempdir().map_err(|e| TestCaseError::Fail(format!("tempdir: {e}").into()))?; 412 + let folder = DocumentFolder::new(dir.path().join("det.bone")); 413 + let mut doc = Document::new(document_id(1), "det".to_owned()); 414 + doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch); 415 + save(&doc, &folder).map_err(|e| TestCaseError::Fail(format!("save: {e}").into()))?; 416 + let first_doc = std::fs::read(folder.document_file()) 417 + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; 418 + let first_sketch = std::fs::read(folder.sketch_path(sketch_id(1))) 419 + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; 420 + save(&doc, &folder).map_err(|e| TestCaseError::Fail(format!("save2: {e}").into()))?; 421 + let second_doc = std::fs::read(folder.document_file()) 422 + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; 423 + let second_sketch = std::fs::read(folder.sketch_path(sketch_id(1))) 424 + .map_err(|e| TestCaseError::Fail(format!("read: {e}").into()))?; 425 + prop_assert_eq!(first_doc, second_doc); 426 + prop_assert_eq!(first_sketch, second_sketch); 427 + } 428 + }
+610
crates/bone-document/tests/folder_roundtrip.rs
··· 1 + use bone_document::{ 2 + BlobHash, BlobKind, Document, DocumentFolder, DocumentHeader, EditOutcome, Sketch, 3 + SketchDimension, SketchEdit, SketchEntity, SketchRegistry, SketchRegistryEntry, SketchRelation, 4 + from_str, load, save, to_string, 5 + }; 6 + use bone_types::{ 7 + Angle, DocumentId, Length, Point2, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, 8 + degree, millimeter, 9 + }; 10 + use slotmap::{Key, KeyData}; 11 + use tempfile::{TempDir, tempdir}; 12 + 13 + fn plane() -> SketchPlaneBasis { 14 + let Ok(basis) = SketchPlaneBasis::new( 15 + Point3::origin(), 16 + UnitVec3::x_axis(), 17 + UnitVec3::y_axis(), 18 + Tolerance::new(1e-9), 19 + ) else { 20 + panic!("xy plane basis is orthogonal"); 21 + }; 22 + basis 23 + } 24 + 25 + fn mm(v: f64) -> Length { 26 + Length::new::<millimeter>(v) 27 + } 28 + 29 + fn deg(v: f64) -> Angle { 30 + Angle::new::<degree>(v) 31 + } 32 + 33 + fn ok_dir() -> TempDir { 34 + let Ok(dir) = tempdir() else { 35 + panic!("tempdir"); 36 + }; 37 + dir 38 + } 39 + 40 + fn sketch_id(idx: u32) -> SketchId { 41 + SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 42 + } 43 + 44 + fn document_id(idx: u32) -> DocumentId { 45 + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 46 + } 47 + 48 + fn rectangle() -> Sketch { 49 + let script = [ 50 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 51 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), 52 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))), 53 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))), 54 + ]; 55 + let Ok((s, _)) = Sketch::new(plane()).apply_all(script) else { 56 + panic!("rectangle"); 57 + }; 58 + s 59 + } 60 + 61 + fn assert_save(doc: &Document, folder: &DocumentFolder) { 62 + let Ok(()) = save(doc, folder) else { 63 + panic!("save clean"); 64 + }; 65 + } 66 + 67 + fn assert_load(folder: &DocumentFolder) -> Document { 68 + let Ok(doc) = load(folder) else { 69 + panic!("load clean"); 70 + }; 71 + doc 72 + } 73 + 74 + fn read_file(path: &std::path::Path) -> String { 75 + let Ok(text) = std::fs::read_to_string(path) else { 76 + panic!("read {}", path.display()); 77 + }; 78 + text 79 + } 80 + 81 + fn write_file(path: &std::path::Path, contents: &str) { 82 + let Ok(()) = std::fs::write(path, contents) else { 83 + panic!("write {}", path.display()); 84 + }; 85 + } 86 + 87 + #[test] 88 + fn rectangle_roundtrips_through_folder() { 89 + let dir = ok_dir(); 90 + let folder = DocumentFolder::new(dir.path().join("part.bone")); 91 + 92 + let mut doc = Document::new(document_id(1), "part".to_owned()); 93 + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 94 + doc.set_parameter("width".to_owned(), 10.0); 95 + 96 + assert_save(&doc, &folder); 97 + let loaded = assert_load(&folder); 98 + 99 + assert_eq!(loaded.name(), doc.name()); 100 + assert_eq!(loaded.registry().order(), doc.registry().order()); 101 + assert_eq!(loaded.feature_tree(), doc.feature_tree()); 102 + assert_eq!(loaded.sketches_map(), doc.sketches_map()); 103 + assert_eq!(loaded.parameters().get("width"), Some(10.0)); 104 + } 105 + 106 + #[test] 107 + fn rich_sketch_with_every_variant_roundtrips() { 108 + let dir = ok_dir(); 109 + let folder = DocumentFolder::new(dir.path().join("rich.bone")); 110 + 111 + let mut s = Sketch::new(plane()); 112 + let Ok((s_next, EditOutcome::Entity(p0))) = s.apply(SketchEdit::AddEntity( 113 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 114 + )) else { 115 + panic!("p0"); 116 + }; 117 + s = s_next; 118 + let Ok((s_next, EditOutcome::Entity(p1))) = s.apply(SketchEdit::AddEntity( 119 + SketchEntity::point(Point2::from_mm(10.0, 0.0)), 120 + )) else { 121 + panic!("p1"); 122 + }; 123 + s = s_next; 124 + let Ok((s_next, EditOutcome::Entity(p2))) = s.apply(SketchEdit::AddEntity( 125 + SketchEntity::point(Point2::from_mm(5.0, 6.0)), 126 + )) else { 127 + panic!("p2"); 128 + }; 129 + s = s_next; 130 + 131 + let Ok((s_next, EditOutcome::Entity(line))) = 132 + s.apply(SketchEdit::AddEntity(SketchEntity::line(p0, p1, false))) 133 + else { 134 + panic!("line"); 135 + }; 136 + s = s_next; 137 + let Ok((s_next, EditOutcome::Entity(line2))) = 138 + s.apply(SketchEdit::AddEntity(SketchEntity::line(p0, p2, true))) 139 + else { 140 + panic!("line2"); 141 + }; 142 + s = s_next; 143 + let Ok((s_next, EditOutcome::Entity(arc))) = 144 + s.apply(SketchEdit::AddEntity(SketchEntity::arc(p0, p1, p2, false))) 145 + else { 146 + panic!("arc"); 147 + }; 148 + s = s_next; 149 + let Ok((s_next, EditOutcome::Entity(circle))) = s.apply(SketchEdit::AddEntity( 150 + SketchEntity::circle(p0, mm(3.0), false), 151 + )) else { 152 + panic!("circle"); 153 + }; 154 + s = s_next; 155 + 156 + for relation in [ 157 + SketchRelation::Coincident(p0, line), 158 + SketchRelation::Horizontal(line), 159 + SketchRelation::Vertical(line2), 160 + SketchRelation::Parallel(line, line2), 161 + SketchRelation::Perpendicular(line, line2), 162 + SketchRelation::Tangent(line, circle), 163 + SketchRelation::Equal(line, line2), 164 + SketchRelation::Concentric(arc, circle), 165 + SketchRelation::Fix(p0), 166 + ] { 167 + let Ok((next, _)) = s.apply(SketchEdit::AddRelation(relation)) else { 168 + panic!("relation {relation:?}"); 169 + }; 170 + s = next; 171 + } 172 + 173 + for dim in [ 174 + SketchDimension::Linear { 175 + a: p0, 176 + b: p1, 177 + value: mm(10.0), 178 + kind: bone_document::DimensionKind::Driving, 179 + }, 180 + SketchDimension::Radius { 181 + target: circle, 182 + value: mm(3.0), 183 + kind: bone_document::DimensionKind::Driven, 184 + }, 185 + SketchDimension::Diameter { 186 + target: circle, 187 + value: mm(6.0), 188 + kind: bone_document::DimensionKind::Driving, 189 + }, 190 + SketchDimension::Angular { 191 + a: line, 192 + b: line2, 193 + value: deg(45.0), 194 + kind: bone_document::DimensionKind::Driving, 195 + }, 196 + ] { 197 + let Ok((next, _)) = s.apply(SketchEdit::AddDimension(dim)) else { 198 + panic!("dim {dim:?}"); 199 + }; 200 + s = next; 201 + } 202 + 203 + let mut doc = Document::new(document_id(1), "rich".to_owned()); 204 + doc.insert_sketch(sketch_id(1), "Rich".to_owned(), s.clone()); 205 + 206 + assert_save(&doc, &folder); 207 + let loaded = assert_load(&folder); 208 + let Some(loaded_sketch) = loaded.sketch(sketch_id(1)) else { 209 + panic!("sketch missing after load"); 210 + }; 211 + assert_eq!(loaded_sketch, &s); 212 + } 213 + 214 + #[test] 215 + fn load_refuses_unknown_schema_major() { 216 + use bone_types::{SchemaHeader, SchemaVersion}; 217 + 218 + let dir = ok_dir(); 219 + let folder = DocumentFolder::new(dir.path().join("future.bone")); 220 + let mut doc = Document::new(document_id(1), "future".to_owned()); 221 + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 222 + assert_save(&doc, &folder); 223 + 224 + let header_path = folder.document_file(); 225 + let text = read_file(&header_path); 226 + write_file(&header_path, &text.replace("major: 1,", "major: 9999,")); 227 + 228 + let load_result = load(&folder).map_err(bone_document::FolderError::into_kind); 229 + let Err(bone_document::FolderErrorKind::UnsupportedMajor { 230 + found, supported, .. 231 + }) = load_result 232 + else { 233 + panic!("expected UnsupportedMajor"); 234 + }; 235 + assert_eq!(found, SchemaVersion::new(9999, 0)); 236 + assert_eq!( 237 + supported, 238 + SchemaVersion::new( 239 + SchemaHeader::BONE_DOCUMENT_MAJOR, 240 + SchemaHeader::BONE_DOCUMENT_MINOR 241 + ) 242 + ); 243 + } 244 + 245 + #[test] 246 + fn load_refuses_unknown_schema_name() { 247 + let dir = ok_dir(); 248 + let folder = DocumentFolder::new(dir.path().join("foreign.bone")); 249 + let mut doc = Document::new(document_id(1), "foreign".to_owned()); 250 + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 251 + assert_save(&doc, &folder); 252 + 253 + let header_path = folder.document_file(); 254 + let text = read_file(&header_path); 255 + write_file( 256 + &header_path, 257 + &text.replace("\"bone-document\"", "\"kicad-pcb\""), 258 + ); 259 + 260 + let load_result = load(&folder).map_err(bone_document::FolderError::into_kind); 261 + let Err(bone_document::FolderErrorKind::UnknownSchema { found, .. }) = load_result else { 262 + panic!("expected UnknownSchema"); 263 + }; 264 + assert_eq!(found, "kicad-pcb"); 265 + } 266 + 267 + #[test] 268 + fn save_ships_vcs_files() { 269 + let dir = ok_dir(); 270 + let folder = DocumentFolder::new(dir.path().join("vcs.bone")); 271 + let doc = Document::new(document_id(1), "vcs".to_owned()); 272 + assert_save(&doc, &folder); 273 + 274 + let gitignore = read_file(&folder.path().join(".gitignore")); 275 + assert!(gitignore.contains("caches/")); 276 + let gitattributes = read_file(&folder.path().join(".gitattributes")); 277 + assert!(gitattributes.contains("* text=auto eol=lf")); 278 + assert!(gitattributes.contains("*.boneblob binary")); 279 + let caches_tag = read_file(&folder.caches_dir().join("CACHEDIR.TAG")); 280 + assert!(caches_tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55")); 281 + let caches_gitignore = read_file(&folder.caches_dir().join(".gitignore")); 282 + assert_eq!(caches_gitignore, "*\n!.gitignore\n!CACHEDIR.TAG\n"); 283 + } 284 + 285 + #[test] 286 + fn save_is_idempotent_byte_for_byte() { 287 + let dir = ok_dir(); 288 + let folder = DocumentFolder::new(dir.path().join("idem.bone")); 289 + let mut doc = Document::new(document_id(1), "idem".to_owned()); 290 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 291 + assert_save(&doc, &folder); 292 + let Ok(first) = std::fs::read(folder.document_file()) else { 293 + panic!("read first"); 294 + }; 295 + assert_save(&doc, &folder); 296 + let Ok(second) = std::fs::read(folder.document_file()) else { 297 + panic!("read second"); 298 + }; 299 + assert_eq!(first, second, "document.ron drifted between saves"); 300 + } 301 + 302 + #[test] 303 + fn loaded_document_preserves_slotmap_keys() { 304 + let dir = ok_dir(); 305 + let folder = DocumentFolder::new(dir.path().join("keys.bone")); 306 + 307 + let mut s = Sketch::new(plane()); 308 + let Ok((s_next, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 309 + Point2::from_mm(0.0, 0.0), 310 + ))) else { 311 + panic!("a"); 312 + }; 313 + s = s_next; 314 + let Ok((s_next, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 315 + Point2::from_mm(1.0, 0.0), 316 + ))) else { 317 + panic!("b"); 318 + }; 319 + s = s_next; 320 + let Ok((s_next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { 321 + panic!("line"); 322 + }; 323 + s = s_next; 324 + 325 + let mut doc = Document::new(document_id(1), "keys".to_owned()); 326 + let id = sketch_id(1); 327 + doc.insert_sketch(id, "S".to_owned(), s.clone()); 328 + assert_save(&doc, &folder); 329 + 330 + let loaded = assert_load(&folder); 331 + let Some(loaded_sketch) = loaded.sketch(id) else { 332 + panic!("sketch"); 333 + }; 334 + let first_id = loaded_sketch.entity_order()[0]; 335 + assert_eq!(first_id.data(), a.data()); 336 + let entries: Vec<(SketchId, SketchRegistryEntry)> = loaded 337 + .registry() 338 + .iter() 339 + .map(|(id, e)| (id, SketchRegistryEntry::clone(e))) 340 + .collect(); 341 + assert_eq!(entries.len(), 1); 342 + let (_, entry) = &entries[0]; 343 + assert_eq!(entry.label, "S"); 344 + assert!( 345 + std::path::Path::new(&entry.filename) 346 + .extension() 347 + .is_some_and(|ext| ext.eq_ignore_ascii_case("ron")) 348 + ); 349 + } 350 + 351 + #[test] 352 + fn feature_tree_bytes_survive_insert_remove_insert_cycle() { 353 + let dir = ok_dir(); 354 + let with_history = DocumentFolder::new(dir.path().join("hist.bone")); 355 + let fresh = DocumentFolder::new(dir.path().join("fresh.bone")); 356 + 357 + let rect = rectangle(); 358 + let mut doc_a = Document::new(document_id(1), "doc".to_owned()); 359 + doc_a.insert_sketch(sketch_id(1), "S".to_owned(), rect.clone()); 360 + doc_a.insert_sketch(sketch_id(2), "T".to_owned(), rect.clone()); 361 + doc_a.remove_sketch(sketch_id(2)); 362 + assert_save(&doc_a, &with_history); 363 + 364 + let mut doc_b = Document::new(document_id(1), "doc".to_owned()); 365 + doc_b.insert_sketch(sketch_id(1), "S".to_owned(), rect); 366 + assert_save(&doc_b, &fresh); 367 + 368 + let bytes_a = read_file(&with_history.document_file()); 369 + let bytes_b = read_file(&fresh.document_file()); 370 + assert_eq!( 371 + bytes_a, bytes_b, 372 + "remove then re-insert leaks tombstones into document.ron" 373 + ); 374 + } 375 + 376 + #[test] 377 + fn push_sketch_is_idempotent_on_duplicate_id() { 378 + let mut doc = Document::new(document_id(1), "dup".to_owned()); 379 + let id = sketch_id(1); 380 + doc.insert_sketch(id, "S".to_owned(), rectangle()); 381 + doc.insert_sketch(id, "S-renamed".to_owned(), rectangle()); 382 + let sketch_nodes = doc 383 + .feature_tree() 384 + .iter() 385 + .filter(|(_, n)| matches!(n, bone_document::FeatureNode::Sketch(_))) 386 + .count(); 387 + assert_eq!( 388 + sketch_nodes, 1, 389 + "duplicate sketch id must not spawn a second feature node" 390 + ); 391 + } 392 + 393 + #[test] 394 + fn removing_sketch_drops_stale_file() { 395 + let dir = ok_dir(); 396 + let folder = DocumentFolder::new(dir.path().join("drop.bone")); 397 + let mut doc = Document::new(document_id(1), "drop".to_owned()); 398 + let id = sketch_id(1); 399 + doc.insert_sketch(id, "S".to_owned(), rectangle()); 400 + assert_save(&doc, &folder); 401 + assert!(folder.sketch_path(id).exists()); 402 + doc.remove_sketch(id); 403 + assert_save(&doc, &folder); 404 + assert!(!folder.sketch_path(id).exists()); 405 + } 406 + 407 + #[test] 408 + fn registry_kept_sorted_by_order_across_roundtrip() { 409 + let dir = ok_dir(); 410 + let folder = DocumentFolder::new(dir.path().join("order.bone")); 411 + let mut doc = Document::new(document_id(1), "order".to_owned()); 412 + let rect = rectangle(); 413 + let a = sketch_id(1); 414 + let b = sketch_id(2); 415 + let c = sketch_id(3); 416 + doc.insert_sketch(a, "A".to_owned(), rect.clone()); 417 + doc.insert_sketch(b, "B".to_owned(), rect.clone()); 418 + doc.insert_sketch(c, "C".to_owned(), rect); 419 + assert_save(&doc, &folder); 420 + let loaded = assert_load(&folder); 421 + assert_eq!(loaded.registry().order(), &[a, b, c]); 422 + let _: &SketchRegistry = loaded.registry(); 423 + } 424 + 425 + #[test] 426 + fn f64_special_values_roundtrip_bit_identically() { 427 + let dir = ok_dir(); 428 + let folder = DocumentFolder::new(dir.path().join("bits.bone")); 429 + let specials: [(f64, f64); 7] = [ 430 + (-0.0, 0.0), 431 + (0.0, 1.0), 432 + (f64::INFINITY, 2.0), 433 + (f64::NEG_INFINITY, 3.0), 434 + (f64::MIN_POSITIVE, 4.0), 435 + (f64::MIN_POSITIVE / 2.0, 5.0), 436 + (1.0 + f64::EPSILON, 6.0), 437 + ]; 438 + let sketch = specials 439 + .into_iter() 440 + .try_fold(Sketch::new(plane()), |s, (x, y)| { 441 + s.apply(SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm( 442 + x, y, 443 + )))) 444 + .map(|(next, _)| next) 445 + }); 446 + let Ok(sketch) = sketch else { 447 + panic!("apply points"); 448 + }; 449 + let mut doc = Document::new(document_id(1), "bits".to_owned()); 450 + doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch.clone()); 451 + assert_save(&doc, &folder); 452 + let loaded = assert_load(&folder); 453 + let Some(back) = loaded.sketch(sketch_id(1)) else { 454 + panic!("sketch missing"); 455 + }; 456 + let coords = |s: &Sketch| -> Vec<(f64, f64)> { 457 + s.entity_order() 458 + .iter() 459 + .map(|id| match s.entities()[*id] { 460 + SketchEntity::Point(p) => p.at().coords_mm(), 461 + other => panic!("expected Point, got {other:?}"), 462 + }) 463 + .collect() 464 + }; 465 + let originals = coords(&sketch); 466 + let reloaded = coords(back); 467 + assert_eq!(originals.len(), reloaded.len()); 468 + originals 469 + .iter() 470 + .zip(reloaded.iter()) 471 + .for_each(|((ax, ay), (bx, by))| { 472 + assert_eq!(ax.to_bits(), bx.to_bits(), "x bits drift: {ax} -> {bx}"); 473 + assert_eq!(ay.to_bits(), by.to_bits(), "y bits drift: {ay} -> {by}"); 474 + }); 475 + } 476 + 477 + #[test] 478 + fn load_refuses_sketch_file_unsupported_major() { 479 + let dir = ok_dir(); 480 + let folder = DocumentFolder::new(dir.path().join("bad-sketch-schema.bone")); 481 + let mut doc = Document::new(document_id(1), "bad".to_owned()); 482 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 483 + assert_save(&doc, &folder); 484 + 485 + let sketch_path = folder.sketch_path(sketch_id(1)); 486 + let text = read_file(&sketch_path); 487 + let patched = text.replace("major: 1,", "major: 9999,"); 488 + assert_ne!(patched, text, "schema major pattern missing"); 489 + write_file(&sketch_path, &patched); 490 + 491 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 492 + let Err(bone_document::FolderErrorKind::UnsupportedMajor { name, found, .. }) = result else { 493 + panic!("expected UnsupportedMajor on sketch file, got {result:?}"); 494 + }; 495 + assert_eq!(name, "bone-document"); 496 + assert_eq!(found.major, 9999); 497 + } 498 + 499 + #[test] 500 + fn load_refuses_tampered_sketch_integrity() { 501 + let dir = ok_dir(); 502 + let folder = DocumentFolder::new(dir.path().join("neg.bone")); 503 + let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 504 + SketchEntity::point(Point2::from_mm(0.0, 0.0)), 505 + )) else { 506 + panic!("center"); 507 + }; 508 + let Ok((s, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 509 + center, 510 + mm(1.0), 511 + false, 512 + ))) else { 513 + panic!("circle"); 514 + }; 515 + let mut doc = Document::new(document_id(1), "neg".to_owned()); 516 + doc.insert_sketch(sketch_id(1), "S".to_owned(), s); 517 + assert_save(&doc, &folder); 518 + 519 + let sketch_path = folder.sketch_path(sketch_id(1)); 520 + let text = read_file(&sketch_path); 521 + let patched = text.replace("radius: 0.001,", "radius: -0.001,"); 522 + assert_ne!(patched, text, "radius pattern missing"); 523 + write_file(&sketch_path, &patched); 524 + 525 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 526 + let Err(bone_document::FolderErrorKind::SketchIntegrity { source, .. }) = result else { 527 + panic!("expected SketchIntegrity, got {result:?}"); 528 + }; 529 + assert!(matches!( 530 + source, 531 + bone_document::SketchEditError::DegenerateEntity(_) 532 + )); 533 + } 534 + 535 + fn patch_header(path: &std::path::Path, f: impl FnOnce(&mut DocumentHeader)) { 536 + let text = read_file(path); 537 + let Ok(mut header) = from_str::<DocumentHeader>(&text) else { 538 + panic!("parse header"); 539 + }; 540 + f(&mut header); 541 + let Ok(out) = to_string(&header) else { 542 + panic!("serialise header"); 543 + }; 544 + write_file(path, &out); 545 + } 546 + 547 + #[test] 548 + fn load_refuses_dangling_feature_tree_sketch() { 549 + let dir = ok_dir(); 550 + let folder = DocumentFolder::new(dir.path().join("dangle.bone")); 551 + let mut doc = Document::new(document_id(1), "dangle".to_owned()); 552 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 553 + assert_save(&doc, &folder); 554 + 555 + patch_header(&folder.document_file(), |h| { 556 + h.feature_tree.push_sketch(sketch_id(99)); 557 + }); 558 + 559 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 560 + let Err(bone_document::FolderErrorKind::DanglingTreeSketch { id }) = result else { 561 + panic!("expected DanglingTreeSketch, got {result:?}"); 562 + }; 563 + assert_eq!(id, sketch_id(99)); 564 + } 565 + 566 + #[test] 567 + fn load_refuses_orphan_registered_sketch() { 568 + let dir = ok_dir(); 569 + let folder = DocumentFolder::new(dir.path().join("orphan.bone")); 570 + let mut doc = Document::new(document_id(1), "orphan".to_owned()); 571 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 572 + assert_save(&doc, &folder); 573 + 574 + patch_header(&folder.document_file(), |h| { 575 + h.feature_tree.remove_sketch(sketch_id(1)); 576 + }); 577 + 578 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 579 + let Err(bone_document::FolderErrorKind::OrphanRegistered { id }) = result else { 580 + panic!("expected OrphanRegistered, got {result:?}"); 581 + }; 582 + assert_eq!(id, sketch_id(1)); 583 + } 584 + 585 + #[test] 586 + fn blob_path_matches_locked_shape() { 587 + let folder = DocumentFolder::new("/tmp/part.bone"); 588 + let hash = BlobHash::of(b"example"); 589 + let path = folder.blob_path(hash, BlobKind::STEP); 590 + let Ok(suffix) = path.strip_prefix("/tmp/part.bone/blobs") else { 591 + panic!("blob under blobs/: {}", path.display()); 592 + }; 593 + let components: Vec<String> = suffix 594 + .components() 595 + .map(|c| c.as_os_str().to_string_lossy().into_owned()) 596 + .collect(); 597 + assert_eq!( 598 + components.len(), 599 + 2, 600 + "<aa>/<rest>.<kind>: got {components:?}" 601 + ); 602 + assert_eq!(components[0].len(), 2, "fanout is 2 hex chars"); 603 + let Some(stem) = components[1].strip_suffix(".step") else { 604 + panic!("extension is .step: {}", components[1]); 605 + }; 606 + assert_eq!(stem.len(), 30, "remaining 30 hex chars"); 607 + let hex = hash.truncated_128_hex(); 608 + assert_eq!(components[0], hex[..2]); 609 + assert_eq!(stem, &hex[2..]); 610 + }
+244
crates/bone-document/tests/folder_snapshots.rs
··· 1 + use bone_document::{ 2 + DimensionKind, Document, DocumentFolder, DocumentHeader, Sketch, SketchDimension, SketchEdit, 3 + SketchEntity, SketchFile, SketchRelation, save, to_string, 4 + }; 5 + use bone_types::{ 6 + Angle, DocumentId, Length, Point2, Point3, SketchEntityId, SketchId, SketchPlaneBasis, 7 + Tolerance, UnitVec3, degree, millimeter, 8 + }; 9 + use slotmap::KeyData; 10 + use tempfile::{TempDir, tempdir}; 11 + 12 + fn plane() -> SketchPlaneBasis { 13 + let Ok(basis) = SketchPlaneBasis::new( 14 + Point3::origin(), 15 + UnitVec3::x_axis(), 16 + UnitVec3::y_axis(), 17 + Tolerance::new(1e-9), 18 + ) else { 19 + panic!("xy plane"); 20 + }; 21 + basis 22 + } 23 + 24 + fn mm(v: f64) -> Length { 25 + Length::new::<millimeter>(v) 26 + } 27 + 28 + fn deg(v: f64) -> Angle { 29 + Angle::new::<degree>(v) 30 + } 31 + 32 + fn ok_dir() -> TempDir { 33 + let Ok(dir) = tempdir() else { 34 + panic!("tempdir"); 35 + }; 36 + dir 37 + } 38 + 39 + fn sketch_id(idx: u32) -> SketchId { 40 + SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 41 + } 42 + 43 + fn document_id(idx: u32) -> DocumentId { 44 + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 45 + } 46 + 47 + fn point_of(s: &Sketch, i: usize) -> SketchEntityId { 48 + s.entity_order()[i] 49 + } 50 + 51 + fn add_entity(s: Sketch, entity: SketchEntity) -> Sketch { 52 + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(entity)) else { 53 + panic!("add entity"); 54 + }; 55 + next 56 + } 57 + 58 + fn add_relation(s: Sketch, relation: SketchRelation) -> Sketch { 59 + let Ok((next, _)) = s.apply(SketchEdit::AddRelation(relation)) else { 60 + panic!("add relation"); 61 + }; 62 + next 63 + } 64 + 65 + fn add_dimension(s: Sketch, dim: SketchDimension) -> Sketch { 66 + let Ok((next, _)) = s.apply(SketchEdit::AddDimension(dim)) else { 67 + panic!("add dim"); 68 + }; 69 + next 70 + } 71 + 72 + fn first_of_kind(s: &Sketch, kind: bone_document::SketchEntityKind) -> SketchEntityId { 73 + let Some(&id) = s.entity_order().iter().find(|id| { 74 + matches!( 75 + s.entities().get(**id).map(bone_document::SketchEntity::kind), 76 + Some(k) if k == kind 77 + ) 78 + }) else { 79 + panic!("no entity of kind {kind:?}"); 80 + }; 81 + id 82 + } 83 + 84 + fn build_full_sketch() -> Sketch { 85 + let coords = [ 86 + Point2::from_mm(0.0, 0.0), 87 + Point2::from_mm(10.0, 0.0), 88 + Point2::from_mm(10.0, 5.0), 89 + Point2::from_mm(0.0, 5.0), 90 + ]; 91 + let with_points = coords.into_iter().fold(Sketch::new(plane()), |s, p| { 92 + add_entity(s, SketchEntity::point(p)) 93 + }); 94 + let p0 = point_of(&with_points, 0); 95 + let p1 = point_of(&with_points, 1); 96 + let p2 = point_of(&with_points, 2); 97 + let p3 = point_of(&with_points, 3); 98 + 99 + let with_lines = [ 100 + (p0, p1, false), 101 + (p1, p2, false), 102 + (p2, p3, false), 103 + (p3, p0, true), 104 + ] 105 + .into_iter() 106 + .fold(with_points, |s, (a, b, cons)| { 107 + add_entity(s, SketchEntity::line(a, b, cons)) 108 + }); 109 + let with_arc = add_entity(with_lines, SketchEntity::arc(p0, p1, p2, false)); 110 + let with_circle = add_entity(with_arc, SketchEntity::circle(p0, mm(2.5), false)); 111 + 112 + let line_ids: Vec<_> = with_circle 113 + .entity_order() 114 + .iter() 115 + .copied() 116 + .filter(|id| { 117 + matches!( 118 + with_circle 119 + .entities() 120 + .get(*id) 121 + .map(bone_document::SketchEntity::kind), 122 + Some(bone_document::SketchEntityKind::Line) 123 + ) 124 + }) 125 + .collect(); 126 + let arc_id = first_of_kind(&with_circle, bone_document::SketchEntityKind::Arc); 127 + let circle_id = first_of_kind(&with_circle, bone_document::SketchEntityKind::Circle); 128 + 129 + let relations = [ 130 + SketchRelation::Coincident(p0, line_ids[0]), 131 + SketchRelation::Horizontal(line_ids[0]), 132 + SketchRelation::Vertical(line_ids[1]), 133 + SketchRelation::Parallel(line_ids[0], line_ids[2]), 134 + SketchRelation::Perpendicular(line_ids[0], line_ids[1]), 135 + SketchRelation::Tangent(line_ids[0], circle_id), 136 + SketchRelation::Equal(line_ids[0], line_ids[2]), 137 + SketchRelation::Concentric(arc_id, circle_id), 138 + SketchRelation::Fix(p0), 139 + ]; 140 + let with_relations = relations.into_iter().fold(with_circle, add_relation); 141 + 142 + let dimensions = [ 143 + SketchDimension::Linear { 144 + a: p0, 145 + b: p1, 146 + value: mm(10.0), 147 + kind: DimensionKind::Driving, 148 + }, 149 + SketchDimension::Radius { 150 + target: circle_id, 151 + value: mm(2.5), 152 + kind: DimensionKind::Driven, 153 + }, 154 + SketchDimension::Diameter { 155 + target: circle_id, 156 + value: mm(5.0), 157 + kind: DimensionKind::Driving, 158 + }, 159 + SketchDimension::Angular { 160 + a: line_ids[0], 161 + b: line_ids[1], 162 + value: deg(90.0), 163 + kind: DimensionKind::Driving, 164 + }, 165 + ]; 166 + dimensions.into_iter().fold(with_relations, add_dimension) 167 + } 168 + 169 + fn assert_ron<T: serde::Serialize>(value: &T) -> String { 170 + let Ok(ron) = to_string(value) else { 171 + panic!("ron"); 172 + }; 173 + ron 174 + } 175 + 176 + #[test] 177 + fn document_header_ron_surface() { 178 + let mut doc = Document::new(document_id(1), "demo".to_owned()); 179 + let sid = sketch_id(7); 180 + doc.insert_sketch(sid, "Sketch1".to_owned(), build_full_sketch()); 181 + doc.set_parameter("width".to_owned(), 10.0); 182 + 183 + let ron = assert_ron(doc.header()); 184 + insta::assert_snapshot!("document_header", ron); 185 + } 186 + 187 + #[test] 188 + fn sketch_file_ron_surface() { 189 + let file = SketchFile::new(build_full_sketch()); 190 + let ron = assert_ron(&file); 191 + insta::assert_snapshot!("sketch_file", ron); 192 + } 193 + 194 + #[test] 195 + fn saved_folder_layout_is_stable() { 196 + let dir = ok_dir(); 197 + let folder = DocumentFolder::new(dir.path().join("pinned.bone")); 198 + let mut doc = Document::new(document_id(1), "pinned".to_owned()); 199 + doc.insert_sketch(sketch_id(1), "Rect".to_owned(), build_full_sketch()); 200 + let Ok(()) = save(&doc, &folder) else { 201 + panic!("save"); 202 + }; 203 + 204 + let mut entries: Vec<String> = walk_relative(folder.path()).collect(); 205 + entries.sort(); 206 + let listing = entries.join("\n"); 207 + insta::assert_snapshot!("folder_listing", listing); 208 + } 209 + 210 + fn walk_relative(root: &std::path::Path) -> impl Iterator<Item = String> + '_ { 211 + fn walk(root: &std::path::Path, path: &std::path::Path, out: &mut Vec<String>) { 212 + let Ok(iter) = std::fs::read_dir(path) else { 213 + return; 214 + }; 215 + iter.flatten().for_each(|entry| { 216 + let p = entry.path(); 217 + let rel = p 218 + .strip_prefix(root) 219 + .unwrap_or(&p) 220 + .to_string_lossy() 221 + .into_owned(); 222 + if p.is_dir() { 223 + out.push(format!("{rel}/")); 224 + walk(root, &p, out); 225 + } else { 226 + out.push(rel); 227 + } 228 + }); 229 + } 230 + let mut out = Vec::new(); 231 + walk(root, root, &mut out); 232 + out.into_iter() 233 + } 234 + 235 + #[test] 236 + fn document_header_roundtrips_through_ron() { 237 + let mut doc = Document::new(document_id(1), "rt".to_owned()); 238 + doc.insert_sketch(sketch_id(1), "S".to_owned(), build_full_sketch()); 239 + let ron = assert_ron(doc.header()); 240 + let Ok(parsed) = bone_document::from_str::<DocumentHeader>(&ron) else { 241 + panic!("parse"); 242 + }; 243 + assert_eq!(&parsed, doc.header()); 244 + }
+79
crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap
··· 1 + --- 2 + source: crates/bone-document/tests/folder_snapshots.rs 3 + expression: ron 4 + --- 5 + #![enable(unwrap_newtypes)] 6 + #![enable(implicit_some)] 7 + #![enable(explicit_struct_names)] 8 + DocumentHeader( 9 + schema: SchemaHeader( 10 + name: "bone-document", 11 + version: SchemaVersion( 12 + major: 1, 13 + minor: 0, 14 + ), 15 + ), 16 + id: SerKey( 17 + idx: 1, 18 + version: 1, 19 + ), 20 + name: "demo", 21 + units: Millimetre, 22 + parameters: DocumentParameters( 23 + order: ["width"], 24 + entries: { 25 + "width": 10.0, 26 + }, 27 + ), 28 + feature_tree: FeatureTree( 29 + entries: [FeatureEntry( 30 + id: SerKey( 31 + idx: 1, 32 + version: 1, 33 + ), 34 + node: Origin, 35 + ), FeatureEntry( 36 + id: SerKey( 37 + idx: 2, 38 + version: 1, 39 + ), 40 + node: PrincipalPlane(Xy), 41 + ), FeatureEntry( 42 + id: SerKey( 43 + idx: 3, 44 + version: 1, 45 + ), 46 + node: PrincipalPlane(Yz), 47 + ), FeatureEntry( 48 + id: SerKey( 49 + idx: 4, 50 + version: 1, 51 + ), 52 + node: PrincipalPlane(Zx), 53 + ), FeatureEntry( 54 + id: SerKey( 55 + idx: 5, 56 + version: 1, 57 + ), 58 + node: Sketch(SerKey( 59 + idx: 7, 60 + version: 1, 61 + )), 62 + )], 63 + ), 64 + sketches: SketchRegistry( 65 + order: [SerKey( 66 + idx: 7, 67 + version: 1, 68 + )], 69 + entries: { 70 + SerKey( 71 + idx: 7, 72 + version: 1, 73 + ): SketchRegistryEntry( 74 + label: "Sketch1", 75 + filename: "0000000100000007.ron", 76 + ), 77 + }, 78 + ), 79 + )
+13
crates/bone-document/tests/snapshots/folder_snapshots__folder_listing.snap
··· 1 + --- 2 + source: crates/bone-document/tests/folder_snapshots.rs 3 + expression: listing 4 + --- 5 + .gitattributes 6 + .gitignore 7 + blobs/ 8 + caches/ 9 + caches/.gitignore 10 + caches/CACHEDIR.TAG 11 + document.ron 12 + sketches/ 13 + sketches/0000000100000001.ron
+355
crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap
··· 1 + --- 2 + source: crates/bone-document/tests/folder_snapshots.rs 3 + expression: ron 4 + --- 5 + #![enable(unwrap_newtypes)] 6 + #![enable(implicit_some)] 7 + #![enable(explicit_struct_names)] 8 + SketchFile( 9 + schema: SchemaHeader( 10 + name: "bone-document", 11 + version: SchemaVersion( 12 + major: 1, 13 + minor: 0, 14 + ), 15 + ), 16 + sketch: Sketch( 17 + plane: SketchPlaneBasis( 18 + origin: Point3( 19 + x: 0.0, 20 + y: 0.0, 21 + z: 0.0, 22 + ), 23 + x_axis: UnitVec3( 24 + x: 1.0, 25 + y: 0.0, 26 + z: 0.0, 27 + ), 28 + y_axis: UnitVec3( 29 + x: 0.0, 30 + y: 1.0, 31 + z: 0.0, 32 + ), 33 + ), 34 + entities: [SerdeSlot( 35 + value: None, 36 + version: 0, 37 + ), SerdeSlot( 38 + value: Point(PointData( 39 + at: Point2( 40 + x: 0.0, 41 + y: 0.0, 42 + ), 43 + )), 44 + version: 1, 45 + ), SerdeSlot( 46 + value: Point(PointData( 47 + at: Point2( 48 + x: 10.0, 49 + y: 0.0, 50 + ), 51 + )), 52 + version: 1, 53 + ), SerdeSlot( 54 + value: Point(PointData( 55 + at: Point2( 56 + x: 10.0, 57 + y: 5.0, 58 + ), 59 + )), 60 + version: 1, 61 + ), SerdeSlot( 62 + value: Point(PointData( 63 + at: Point2( 64 + x: 0.0, 65 + y: 5.0, 66 + ), 67 + )), 68 + version: 1, 69 + ), SerdeSlot( 70 + value: Line(LineData( 71 + a: SerKey( 72 + idx: 1, 73 + version: 1, 74 + ), 75 + b: SerKey( 76 + idx: 2, 77 + version: 1, 78 + ), 79 + for_construction: false, 80 + )), 81 + version: 1, 82 + ), SerdeSlot( 83 + value: Line(LineData( 84 + a: SerKey( 85 + idx: 2, 86 + version: 1, 87 + ), 88 + b: SerKey( 89 + idx: 3, 90 + version: 1, 91 + ), 92 + for_construction: false, 93 + )), 94 + version: 1, 95 + ), SerdeSlot( 96 + value: Line(LineData( 97 + a: SerKey( 98 + idx: 3, 99 + version: 1, 100 + ), 101 + b: SerKey( 102 + idx: 4, 103 + version: 1, 104 + ), 105 + for_construction: false, 106 + )), 107 + version: 1, 108 + ), SerdeSlot( 109 + value: Line(LineData( 110 + a: SerKey( 111 + idx: 4, 112 + version: 1, 113 + ), 114 + b: SerKey( 115 + idx: 1, 116 + version: 1, 117 + ), 118 + for_construction: true, 119 + )), 120 + version: 1, 121 + ), SerdeSlot( 122 + value: Arc(ArcData( 123 + center: SerKey( 124 + idx: 1, 125 + version: 1, 126 + ), 127 + start: SerKey( 128 + idx: 2, 129 + version: 1, 130 + ), 131 + end: SerKey( 132 + idx: 3, 133 + version: 1, 134 + ), 135 + for_construction: false, 136 + )), 137 + version: 1, 138 + ), SerdeSlot( 139 + value: Circle(CircleData( 140 + center: SerKey( 141 + idx: 1, 142 + version: 1, 143 + ), 144 + radius: 0.0025, 145 + for_construction: false, 146 + )), 147 + version: 1, 148 + )], 149 + entity_order: [SerKey( 150 + idx: 1, 151 + version: 1, 152 + ), SerKey( 153 + idx: 2, 154 + version: 1, 155 + ), SerKey( 156 + idx: 3, 157 + version: 1, 158 + ), SerKey( 159 + idx: 4, 160 + version: 1, 161 + ), SerKey( 162 + idx: 5, 163 + version: 1, 164 + ), SerKey( 165 + idx: 6, 166 + version: 1, 167 + ), SerKey( 168 + idx: 7, 169 + version: 1, 170 + ), SerKey( 171 + idx: 8, 172 + version: 1, 173 + ), SerKey( 174 + idx: 9, 175 + version: 1, 176 + ), SerKey( 177 + idx: 10, 178 + version: 1, 179 + )], 180 + relations: [SerdeSlot( 181 + value: None, 182 + version: 0, 183 + ), SerdeSlot( 184 + value: Coincident(SerKey( 185 + idx: 1, 186 + version: 1, 187 + ), SerKey( 188 + idx: 5, 189 + version: 1, 190 + )), 191 + version: 1, 192 + ), SerdeSlot( 193 + value: Horizontal(SerKey( 194 + idx: 5, 195 + version: 1, 196 + )), 197 + version: 1, 198 + ), SerdeSlot( 199 + value: Vertical(SerKey( 200 + idx: 6, 201 + version: 1, 202 + )), 203 + version: 1, 204 + ), SerdeSlot( 205 + value: Parallel(SerKey( 206 + idx: 5, 207 + version: 1, 208 + ), SerKey( 209 + idx: 7, 210 + version: 1, 211 + )), 212 + version: 1, 213 + ), SerdeSlot( 214 + value: Perpendicular(SerKey( 215 + idx: 5, 216 + version: 1, 217 + ), SerKey( 218 + idx: 6, 219 + version: 1, 220 + )), 221 + version: 1, 222 + ), SerdeSlot( 223 + value: Tangent(SerKey( 224 + idx: 5, 225 + version: 1, 226 + ), SerKey( 227 + idx: 10, 228 + version: 1, 229 + )), 230 + version: 1, 231 + ), SerdeSlot( 232 + value: Equal(SerKey( 233 + idx: 5, 234 + version: 1, 235 + ), SerKey( 236 + idx: 7, 237 + version: 1, 238 + )), 239 + version: 1, 240 + ), SerdeSlot( 241 + value: Concentric(SerKey( 242 + idx: 9, 243 + version: 1, 244 + ), SerKey( 245 + idx: 10, 246 + version: 1, 247 + )), 248 + version: 1, 249 + ), SerdeSlot( 250 + value: Fix(SerKey( 251 + idx: 1, 252 + version: 1, 253 + )), 254 + version: 1, 255 + )], 256 + relation_order: [SerKey( 257 + idx: 1, 258 + version: 1, 259 + ), SerKey( 260 + idx: 2, 261 + version: 1, 262 + ), SerKey( 263 + idx: 3, 264 + version: 1, 265 + ), SerKey( 266 + idx: 4, 267 + version: 1, 268 + ), SerKey( 269 + idx: 5, 270 + version: 1, 271 + ), SerKey( 272 + idx: 6, 273 + version: 1, 274 + ), SerKey( 275 + idx: 7, 276 + version: 1, 277 + ), SerKey( 278 + idx: 8, 279 + version: 1, 280 + ), SerKey( 281 + idx: 9, 282 + version: 1, 283 + )], 284 + dimensions: [SerdeSlot( 285 + value: None, 286 + version: 0, 287 + ), SerdeSlot( 288 + value: Linear( 289 + a: SerKey( 290 + idx: 1, 291 + version: 1, 292 + ), 293 + b: SerKey( 294 + idx: 2, 295 + version: 1, 296 + ), 297 + value: 0.01, 298 + kind: Driving, 299 + ), 300 + version: 1, 301 + ), SerdeSlot( 302 + value: Radius( 303 + target: SerKey( 304 + idx: 10, 305 + version: 1, 306 + ), 307 + value: 0.0025, 308 + kind: Driven, 309 + ), 310 + version: 1, 311 + ), SerdeSlot( 312 + value: Diameter( 313 + target: SerKey( 314 + idx: 10, 315 + version: 1, 316 + ), 317 + value: 0.005, 318 + kind: Driving, 319 + ), 320 + version: 1, 321 + ), SerdeSlot( 322 + value: Angular( 323 + a: SerKey( 324 + idx: 5, 325 + version: 1, 326 + ), 327 + b: SerKey( 328 + idx: 6, 329 + version: 1, 330 + ), 331 + value: 1.5707963267948966, 332 + kind: Driving, 333 + ), 334 + version: 1, 335 + )], 336 + dimension_order: [SerKey( 337 + idx: 1, 338 + version: 1, 339 + ), SerKey( 340 + idx: 2, 341 + version: 1, 342 + ), SerKey( 343 + idx: 3, 344 + version: 1, 345 + ), SerKey( 346 + idx: 4, 347 + version: 1, 348 + )], 349 + parameters: [SerdeSlot( 350 + value: None, 351 + version: 0, 352 + )], 353 + parameter_order: [], 354 + ), 355 + )
+150
crates/bone-document/tests/undo.rs
··· 1 + use std::num::NonZeroUsize; 2 + 3 + use bone_document::{Document, Sketch, UndoStack}; 4 + use bone_types::{DocumentId, Point3, SketchId, SketchPlaneBasis, Tolerance, UnitVec3}; 5 + use slotmap::KeyData; 6 + 7 + fn plane() -> SketchPlaneBasis { 8 + let Ok(basis) = SketchPlaneBasis::new( 9 + Point3::origin(), 10 + UnitVec3::x_axis(), 11 + UnitVec3::y_axis(), 12 + Tolerance::new(1e-9), 13 + ) else { 14 + panic!("xy plane"); 15 + }; 16 + basis 17 + } 18 + 19 + fn sketch_id(idx: u32) -> SketchId { 20 + SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 21 + } 22 + 23 + fn document_id(idx: u32) -> DocumentId { 24 + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 25 + } 26 + 27 + fn cap(n: usize) -> NonZeroUsize { 28 + let Some(nz) = NonZeroUsize::new(n) else { 29 + panic!("capacity"); 30 + }; 31 + nz 32 + } 33 + 34 + fn base_doc() -> Document { 35 + Document::new(document_id(1), "d".to_owned()) 36 + } 37 + 38 + fn with_sketch(mut doc: Document, sid: SketchId) -> Document { 39 + doc.insert_sketch(sid, format!("S{sid:?}"), Sketch::new(plane())); 40 + doc 41 + } 42 + 43 + #[test] 44 + fn fresh_stack_has_no_history() { 45 + let stack = UndoStack::with_capacity(cap(5)); 46 + assert!(!stack.can_undo()); 47 + assert!(!stack.can_redo()); 48 + assert_eq!(stack.past_len(), 0); 49 + assert_eq!(stack.future_len(), 0); 50 + } 51 + 52 + #[test] 53 + fn undo_swaps_current_with_previous() { 54 + let a = base_doc(); 55 + let mut live = with_sketch(a.clone(), sketch_id(1)); 56 + let mut stack = UndoStack::with_capacity(cap(5)); 57 + stack.record(a.clone()); 58 + assert!(stack.undo(&mut live)); 59 + assert_eq!(live, a); 60 + assert!(!stack.can_undo()); 61 + assert!(stack.can_redo()); 62 + } 63 + 64 + #[test] 65 + fn undo_on_empty_leaves_current_unchanged() { 66 + let mut live = base_doc(); 67 + let snapshot = live.clone(); 68 + let mut stack = UndoStack::with_capacity(cap(5)); 69 + assert!(!stack.undo(&mut live)); 70 + assert_eq!(live, snapshot); 71 + assert_eq!(stack.past_len(), 0); 72 + assert_eq!(stack.future_len(), 0); 73 + } 74 + 75 + #[test] 76 + fn redo_on_empty_leaves_current_unchanged() { 77 + let mut live = base_doc(); 78 + let snapshot = live.clone(); 79 + let mut stack = UndoStack::with_capacity(cap(5)); 80 + assert!(!stack.redo(&mut live)); 81 + assert_eq!(live, snapshot); 82 + assert_eq!(stack.past_len(), 0); 83 + assert_eq!(stack.future_len(), 0); 84 + } 85 + 86 + #[test] 87 + fn redo_replays_undone_state() { 88 + let a = base_doc(); 89 + let b = with_sketch(a.clone(), sketch_id(1)); 90 + let mut live = b.clone(); 91 + let mut stack = UndoStack::with_capacity(cap(5)); 92 + stack.record(a.clone()); 93 + assert!(stack.undo(&mut live)); 94 + assert!(stack.redo(&mut live)); 95 + assert_eq!(live, b); 96 + assert!(stack.can_undo()); 97 + assert!(!stack.can_redo()); 98 + } 99 + 100 + #[test] 101 + fn recording_after_undo_clears_redo() { 102 + let a = base_doc(); 103 + let b = with_sketch(a.clone(), sketch_id(1)); 104 + let c = with_sketch(a.clone(), sketch_id(2)); 105 + let mut live = b; 106 + let mut stack = UndoStack::with_capacity(cap(5)); 107 + stack.record(a.clone()); 108 + assert!(stack.undo(&mut live)); 109 + assert_eq!(live, a); 110 + stack.record(live.clone()); 111 + let _ = c; 112 + assert!(stack.can_undo()); 113 + assert!(!stack.can_redo()); 114 + assert_eq!(stack.future_len(), 0); 115 + } 116 + 117 + #[test] 118 + fn capacity_drops_oldest_entries() { 119 + let mut stack = UndoStack::with_capacity(cap(2)); 120 + let snapshots: Vec<Document> = (1..=4) 121 + .map(|i| with_sketch(base_doc(), sketch_id(i))) 122 + .collect(); 123 + snapshots.iter().cloned().for_each(|doc| stack.record(doc)); 124 + assert_eq!(stack.past_len(), 2); 125 + let mut live = with_sketch(base_doc(), sketch_id(99)); 126 + assert!(stack.undo(&mut live)); 127 + assert_eq!(live, snapshots[3]); 128 + assert!(stack.undo(&mut live)); 129 + assert_eq!(live, snapshots[2]); 130 + assert!(!stack.can_undo()); 131 + } 132 + 133 + #[test] 134 + fn undo_redo_cycle_preserves_determinism() { 135 + let a = base_doc(); 136 + let b = with_sketch(a.clone(), sketch_id(1)); 137 + let c = with_sketch(b.clone(), sketch_id(2)); 138 + let mut live = c.clone(); 139 + let mut stack = UndoStack::with_capacity(cap(5)); 140 + stack.record(a.clone()); 141 + stack.record(b.clone()); 142 + assert!(stack.undo(&mut live)); 143 + assert_eq!(live, b); 144 + assert!(stack.undo(&mut live)); 145 + assert_eq!(live, a); 146 + assert!(stack.redo(&mut live)); 147 + assert_eq!(live, b); 148 + assert!(stack.redo(&mut live)); 149 + assert_eq!(live, c); 150 + }
+17
crates/bone-kernel/Cargo.toml
··· 1 + [package] 2 + name = "bone-kernel" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + bone-types = { workspace = true } 10 + thiserror = { workspace = true } 11 + uom = { workspace = true } 12 + 13 + [dev-dependencies] 14 + insta = { workspace = true } 15 + 16 + [lints] 17 + workspace = true
+51
crates/bone-kernel/src/aabb.rs
··· 1 + use bone_types::Point2; 2 + 3 + #[derive(Copy, Clone, Debug, PartialEq)] 4 + pub struct Aabb2 { 5 + min: Point2, 6 + max: Point2, 7 + } 8 + 9 + impl Aabb2 { 10 + #[must_use] 11 + pub fn from_corners(a: Point2, b: Point2) -> Self { 12 + let (ax, ay) = a.coords_mm(); 13 + let (bx, by) = b.coords_mm(); 14 + Self { 15 + min: Point2::from_mm(ax.min(bx), ay.min(by)), 16 + max: Point2::from_mm(ax.max(bx), ay.max(by)), 17 + } 18 + } 19 + 20 + #[must_use] 21 + pub fn min(self) -> Point2 { 22 + self.min 23 + } 24 + 25 + #[must_use] 26 + pub fn max(self) -> Point2 { 27 + self.max 28 + } 29 + 30 + #[must_use] 31 + pub fn extend_point(self, p: Point2) -> Self { 32 + let (px, py) = p.coords_mm(); 33 + let (mnx, mny) = self.min.coords_mm(); 34 + let (mxx, mxy) = self.max.coords_mm(); 35 + Self { 36 + min: Point2::from_mm(mnx.min(px), mny.min(py)), 37 + max: Point2::from_mm(mxx.max(px), mxy.max(py)), 38 + } 39 + } 40 + } 41 + 42 + impl core::fmt::Display for Aabb2 { 43 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 44 + let (mnx, mny) = self.min.coords_mm(); 45 + let (mxx, mxy) = self.max.coords_mm(); 46 + write!( 47 + f, 48 + "aabb2{{ min=({mnx} mm, {mny} mm), max=({mxx} mm, {mxy} mm) }}" 49 + ) 50 + } 51 + }
+332
crates/bone-kernel/src/arc2.rs
··· 1 + use bone_types::{Angle, AngleTolerance, Length, Parameter, Point2, Tolerance, UnitVec2, Vec2}; 2 + use core::f64::consts::{PI, TAU}; 3 + use uom::si::angle::radian; 4 + use uom::si::length::millimeter; 5 + 6 + use crate::KernelError; 7 + use crate::aabb::Aabb2; 8 + use crate::circle2::Circle2; 9 + use crate::closest::ClosestPoint2; 10 + use crate::curvature::Curvature; 11 + use crate::curve2::{Curve2, Curve2Kind}; 12 + 13 + #[derive(Copy, Clone, Debug, PartialEq)] 14 + pub struct Arc2 { 15 + center: Point2, 16 + radius: Length, 17 + start_angle: Angle, 18 + sweep_angle: Angle, 19 + } 20 + 21 + impl Arc2 { 22 + pub fn new( 23 + center: Point2, 24 + radius: Length, 25 + start_angle: Angle, 26 + sweep_angle: Angle, 27 + tolerance: Tolerance, 28 + ) -> Result<Self, KernelError> { 29 + if radius.get::<millimeter>() < tolerance.value() { 30 + return Err(KernelError::DegenerateArc); 31 + } 32 + let sweep = sweep_angle.get::<radian>(); 33 + let angle_eps = AngleTolerance::from_arc_length(tolerance, radius).radians(); 34 + if sweep.abs() < angle_eps { 35 + return Err(KernelError::DegenerateArc); 36 + } 37 + if sweep.abs() > TAU + angle_eps { 38 + return Err(KernelError::DegenerateArc); 39 + } 40 + Ok(Self { 41 + center, 42 + radius, 43 + start_angle, 44 + sweep_angle, 45 + }) 46 + } 47 + 48 + #[must_use] 49 + pub const fn center(self) -> Point2 { 50 + self.center 51 + } 52 + 53 + #[must_use] 54 + pub const fn radius(self) -> Length { 55 + self.radius 56 + } 57 + 58 + #[must_use] 59 + pub const fn start_angle(self) -> Angle { 60 + self.start_angle 61 + } 62 + 63 + #[must_use] 64 + pub const fn sweep_angle(self) -> Angle { 65 + self.sweep_angle 66 + } 67 + 68 + #[must_use] 69 + pub fn radius_mm(self) -> f64 { 70 + self.radius.get::<millimeter>() 71 + } 72 + 73 + #[must_use] 74 + pub fn start_rad(self) -> f64 { 75 + self.start_angle.get::<radian>() 76 + } 77 + 78 + #[must_use] 79 + pub fn sweep_rad(self) -> f64 { 80 + self.sweep_angle.get::<radian>() 81 + } 82 + 83 + #[must_use] 84 + pub fn as_full_circle(self) -> Circle2 { 85 + Circle2::from_raw(self.center, self.radius) 86 + } 87 + 88 + #[must_use] 89 + pub fn contains_point(self, p: Point2, tolerance: Tolerance) -> bool { 90 + let (cx, cy) = self.center.coords_mm(); 91 + let (px, py) = p.coords_mm(); 92 + let dx = px - cx; 93 + let dy = py - cy; 94 + let r = self.radius_mm(); 95 + let radial = (dx * dx + dy * dy).sqrt(); 96 + if (radial - r).abs() > tolerance.value() { 97 + return false; 98 + } 99 + let angle_tol = AngleTolerance::from_arc_length(tolerance, self.radius); 100 + self.contains_angle_rad(dy.atan2(dx), angle_tol) 101 + } 102 + 103 + fn contains_angle_rad(self, theta: f64, tolerance: AngleTolerance) -> bool { 104 + let start = self.start_rad(); 105 + let sweep = self.sweep_rad(); 106 + let eps = tolerance.radians(); 107 + let delta = if sweep >= 0.0 { 108 + (theta - start).rem_euclid(TAU) 109 + } else { 110 + (start - theta).rem_euclid(TAU) 111 + }; 112 + let magnitude = sweep.abs(); 113 + delta <= magnitude + eps || delta >= TAU - eps 114 + } 115 + 116 + fn angle_at(self, t: Parameter) -> f64 { 117 + self.start_rad() + self.sweep_rad() * t.value() 118 + } 119 + 120 + fn clamp_angle_rad(self, theta: f64, tolerance: AngleTolerance) -> f64 { 121 + if self.contains_angle_rad(theta, tolerance) { 122 + return theta; 123 + } 124 + let start = self.start_rad(); 125 + let end = start + self.sweep_rad(); 126 + let to_start = wrap_delta(theta - start).abs(); 127 + let to_end = wrap_delta(theta - end).abs(); 128 + if to_start <= to_end { start } else { end } 129 + } 130 + } 131 + 132 + impl Curve2 for Arc2 { 133 + fn evaluate(&self, t: Parameter) -> Point2 { 134 + let (cx, cy) = self.center.coords_mm(); 135 + let r = self.radius_mm(); 136 + let theta = self.angle_at(t); 137 + Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()) 138 + } 139 + 140 + fn derivative(&self, t: Parameter) -> Vec2 { 141 + let r = self.radius_mm(); 142 + let sweep = self.sweep_rad(); 143 + let theta = self.angle_at(t); 144 + Vec2::from_mm(-r * sweep * theta.sin(), r * sweep * theta.cos()) 145 + } 146 + 147 + fn tangent(&self, t: Parameter) -> UnitVec2 { 148 + let theta = self.angle_at(t); 149 + let sign = self.sweep_rad().signum(); 150 + UnitVec2::new_unchecked(-sign * theta.sin(), sign * theta.cos()) 151 + } 152 + 153 + fn curvature(&self, _t: Parameter) -> Curvature { 154 + Curvature::from_radius(self.radius).with_sign(self.sweep_rad()) 155 + } 156 + 157 + fn bounding_box(&self) -> Aabb2 { 158 + arc_bounding_box(self.center, self.radius, self.start_angle, self.sweep_angle) 159 + } 160 + 161 + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2 { 162 + let (cx, cy) = self.center.coords_mm(); 163 + let (px, py) = p.coords_mm(); 164 + let dx = px - cx; 165 + let dy = py - cy; 166 + let dist = (dx * dx + dy * dy).sqrt(); 167 + let r = self.radius_mm(); 168 + let angle_tol = AngleTolerance::from_arc_length(tolerance, self.radius); 169 + 170 + let theta_unclamped = if dist < tolerance.value() { 171 + self.start_rad() 172 + } else { 173 + dy.atan2(dx) 174 + }; 175 + 176 + let theta = self.clamp_angle_rad(theta_unclamped, angle_tol); 177 + let sweep = self.sweep_rad(); 178 + let delta = if sweep >= 0.0 { 179 + (theta - self.start_rad()).rem_euclid(TAU) 180 + } else { 181 + (self.start_rad() - theta).rem_euclid(TAU) 182 + }; 183 + let t = (delta / sweep.abs()).clamp(0.0, 1.0); 184 + let proj_x = cx + r * theta.cos(); 185 + let proj_y = cy + r * theta.sin(); 186 + let projected = Point2::from_mm(proj_x, proj_y); 187 + let distance = 188 + Length::new::<millimeter>(((px - proj_x).powi(2) + (py - proj_y).powi(2)).sqrt()); 189 + ClosestPoint2::new(Parameter::new(t), projected, distance) 190 + } 191 + 192 + fn as_kind(&self) -> Curve2Kind { 193 + Curve2Kind::Arc(*self) 194 + } 195 + } 196 + 197 + impl core::fmt::Display for Arc2 { 198 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 199 + write!( 200 + f, 201 + "arc2{{ c={}, r={} mm, start={} rad, sweep={} rad }}", 202 + self.center, 203 + self.radius.get::<millimeter>(), 204 + self.start_rad(), 205 + self.sweep_rad(), 206 + ) 207 + } 208 + } 209 + 210 + pub(crate) fn wrap_delta(a: f64) -> f64 { 211 + (a + PI).rem_euclid(TAU) - PI 212 + } 213 + 214 + #[must_use] 215 + pub fn arc_bounding_box( 216 + center: Point2, 217 + radius: Length, 218 + start_angle: Angle, 219 + sweep_angle: Angle, 220 + ) -> Aabb2 { 221 + let (cx, cy) = center.coords_mm(); 222 + let r = radius.get::<millimeter>(); 223 + let start = start_angle.get::<radian>(); 224 + let sweep = sweep_angle.get::<radian>(); 225 + let end = start + sweep; 226 + let start_pt = Point2::from_mm(cx + r * start.cos(), cy + r * start.sin()); 227 + let end_pt = Point2::from_mm(cx + r * end.cos(), cy + r * end.sin()); 228 + let base = Aabb2::from_corners(start_pt, end_pt); 229 + let cardinals: [(f64, Point2); 4] = [ 230 + (0.0, Point2::from_mm(cx + r, cy)), 231 + (PI * 0.5, Point2::from_mm(cx, cy + r)), 232 + (PI, Point2::from_mm(cx - r, cy)), 233 + (-PI * 0.5, Point2::from_mm(cx, cy - r)), 234 + ]; 235 + cardinals.into_iter().fold(base, |bbox, (theta, extreme)| { 236 + if arc_contains_angle_rad(theta, start, sweep) { 237 + bbox.extend_point(extreme) 238 + } else { 239 + bbox 240 + } 241 + }) 242 + } 243 + 244 + fn arc_contains_angle_rad(theta: f64, start: f64, sweep: f64) -> bool { 245 + let delta = if sweep >= 0.0 { 246 + (theta - start).rem_euclid(TAU) 247 + } else { 248 + (start - theta).rem_euclid(TAU) 249 + }; 250 + delta <= sweep.abs() 251 + } 252 + 253 + #[cfg(test)] 254 + mod tests { 255 + use super::arc_bounding_box; 256 + use bone_types::{Angle, Length, Point2}; 257 + use core::f64::consts::{FRAC_PI_2, PI, TAU}; 258 + use uom::si::angle::radian; 259 + use uom::si::length::millimeter; 260 + 261 + fn approx(a: f64, b: f64) -> bool { 262 + (a - b).abs() < 1e-9 263 + } 264 + 265 + fn arc(cx: f64, cy: f64, r_mm: f64, start_rad: f64, sweep_rad: f64) -> (Point2, Point2) { 266 + let bbox = arc_bounding_box( 267 + Point2::from_mm(cx, cy), 268 + Length::new::<millimeter>(r_mm), 269 + Angle::new::<radian>(start_rad), 270 + Angle::new::<radian>(sweep_rad), 271 + ); 272 + (bbox.min(), bbox.max()) 273 + } 274 + 275 + #[test] 276 + fn full_circle_sweep_spans_all_cardinals() { 277 + let (mn, mx) = arc(0.0, 0.0, 2.0, 0.0, TAU); 278 + let (mnx, mny) = mn.coords_mm(); 279 + let (mxx, mxy) = mx.coords_mm(); 280 + assert!(approx(mnx, -2.0) && approx(mny, -2.0)); 281 + assert!(approx(mxx, 2.0) && approx(mxy, 2.0)); 282 + } 283 + 284 + #[test] 285 + fn quarter_arc_hugs_its_quadrant() { 286 + let (mn, mx) = arc(0.0, 0.0, 1.0, 0.0, FRAC_PI_2); 287 + let (mnx, mny) = mn.coords_mm(); 288 + let (mxx, mxy) = mx.coords_mm(); 289 + assert!(approx(mnx, 0.0) && approx(mny, 0.0)); 290 + assert!(approx(mxx, 1.0) && approx(mxy, 1.0)); 291 + } 292 + 293 + #[test] 294 + fn negative_sweep_mirrors_positive() { 295 + let (fwd_min, fwd_max) = arc(0.0, 0.0, 1.0, 0.0, FRAC_PI_2); 296 + let (rev_min, rev_max) = arc(0.0, 0.0, 1.0, FRAC_PI_2, -FRAC_PI_2); 297 + assert_eq!(fwd_min, rev_min); 298 + assert_eq!(fwd_max, rev_max); 299 + } 300 + 301 + #[test] 302 + fn small_sweep_hugs_endpoints_only() { 303 + let start = FRAC_PI_2 * 0.5; 304 + let sweep = 0.1; 305 + let (mn, mx) = arc(0.0, 0.0, 1.0, start, sweep); 306 + let (mnx, mny) = mn.coords_mm(); 307 + let (mxx, mxy) = mx.coords_mm(); 308 + let end = start + sweep; 309 + assert!(approx(mnx, end.cos()) && approx(mny, start.sin())); 310 + assert!(approx(mxx, start.cos()) && approx(mxy, end.sin())); 311 + } 312 + 313 + #[test] 314 + fn wide_sweep_crosses_three_axes() { 315 + let start = FRAC_PI_2 * 0.5; 316 + let sweep = FRAC_PI_2 * 2.5; 317 + let (mn, mx) = arc(0.0, 0.0, 1.0, start, sweep); 318 + let (mnx, mny) = mn.coords_mm(); 319 + let (mxx, mxy) = mx.coords_mm(); 320 + assert!(approx(mnx, -1.0) && approx(mny, -1.0)); 321 + assert!(approx(mxx, start.cos()) && approx(mxy, 1.0)); 322 + } 323 + 324 + #[test] 325 + fn translated_center_offsets_the_box() { 326 + let (mn, mx) = arc(10.0, -5.0, 3.0, 0.0, PI); 327 + let (mnx, mny) = mn.coords_mm(); 328 + let (mxx, mxy) = mx.coords_mm(); 329 + assert!(approx(mnx, 7.0) && approx(mny, -5.0)); 330 + assert!(approx(mxx, 13.0) && approx(mxy, -2.0)); 331 + } 332 + }
+112
crates/bone-kernel/src/circle2.rs
··· 1 + use bone_types::{Length, Parameter, Point2, Tolerance, UnitVec2, Vec2}; 2 + use core::f64::consts::TAU; 3 + use uom::si::length::millimeter; 4 + 5 + use crate::KernelError; 6 + use crate::aabb::Aabb2; 7 + use crate::closest::ClosestPoint2; 8 + use crate::curvature::Curvature; 9 + use crate::curve2::{Curve2, Curve2Kind}; 10 + 11 + #[derive(Copy, Clone, Debug, PartialEq)] 12 + pub struct Circle2 { 13 + center: Point2, 14 + radius: Length, 15 + } 16 + 17 + impl Circle2 { 18 + pub fn new(center: Point2, radius: Length, tolerance: Tolerance) -> Result<Self, KernelError> { 19 + if radius.get::<millimeter>() < tolerance.value() { 20 + return Err(KernelError::DegenerateCircle); 21 + } 22 + Ok(Self { center, radius }) 23 + } 24 + 25 + #[must_use] 26 + pub(crate) const fn from_raw(center: Point2, radius: Length) -> Self { 27 + Self { center, radius } 28 + } 29 + 30 + #[must_use] 31 + pub const fn center(self) -> Point2 { 32 + self.center 33 + } 34 + 35 + #[must_use] 36 + pub const fn radius(self) -> Length { 37 + self.radius 38 + } 39 + 40 + #[must_use] 41 + pub fn radius_mm(self) -> f64 { 42 + self.radius.get::<millimeter>() 43 + } 44 + } 45 + 46 + impl Curve2 for Circle2 { 47 + fn evaluate(&self, t: Parameter) -> Point2 { 48 + let (cx, cy) = self.center.coords_mm(); 49 + let r = self.radius_mm(); 50 + let theta = TAU * t.value(); 51 + Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()) 52 + } 53 + 54 + fn derivative(&self, t: Parameter) -> Vec2 { 55 + let r = self.radius_mm(); 56 + let theta = TAU * t.value(); 57 + Vec2::from_mm(-r * TAU * theta.sin(), r * TAU * theta.cos()) 58 + } 59 + 60 + fn tangent(&self, t: Parameter) -> UnitVec2 { 61 + let theta = TAU * t.value(); 62 + UnitVec2::new_unchecked(-theta.sin(), theta.cos()) 63 + } 64 + 65 + fn curvature(&self, _t: Parameter) -> Curvature { 66 + Curvature::from_radius(self.radius) 67 + } 68 + 69 + fn bounding_box(&self) -> Aabb2 { 70 + let (cx, cy) = self.center.coords_mm(); 71 + let r = self.radius_mm(); 72 + Aabb2::from_corners( 73 + Point2::from_mm(cx - r, cy - r), 74 + Point2::from_mm(cx + r, cy + r), 75 + ) 76 + } 77 + 78 + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2 { 79 + let (cx, cy) = self.center.coords_mm(); 80 + let (px, py) = p.coords_mm(); 81 + let dx = px - cx; 82 + let dy = py - cy; 83 + let dist = (dx * dx + dy * dy).sqrt(); 84 + let r = self.radius_mm(); 85 + if dist < tolerance.value() { 86 + let projected = Point2::from_mm(cx + r, cy); 87 + let distance = Length::new::<millimeter>(r); 88 + return ClosestPoint2::new(Parameter::new(0.0), projected, distance); 89 + } 90 + let theta = dy.atan2(dx); 91 + let t_raw = theta / TAU; 92 + let t = t_raw.rem_euclid(1.0); 93 + let projected = Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()); 94 + let distance = Length::new::<millimeter>((dist - r).abs()); 95 + ClosestPoint2::new(Parameter::new(t), projected, distance) 96 + } 97 + 98 + fn as_kind(&self) -> Curve2Kind { 99 + Curve2Kind::Circle(*self) 100 + } 101 + } 102 + 103 + impl core::fmt::Display for Circle2 { 104 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 105 + write!( 106 + f, 107 + "circle2{{ c={}, r={} mm }}", 108 + self.center, 109 + self.radius.get::<millimeter>(), 110 + ) 111 + } 112 + }
+47
crates/bone-kernel/src/closest.rs
··· 1 + use bone_types::{Length, Parameter, Point2}; 2 + 3 + #[derive(Copy, Clone, Debug, PartialEq)] 4 + pub struct ClosestPoint2 { 5 + parameter: Parameter, 6 + point: Point2, 7 + distance: Length, 8 + } 9 + 10 + impl ClosestPoint2 { 11 + #[must_use] 12 + pub const fn new(parameter: Parameter, point: Point2, distance: Length) -> Self { 13 + Self { 14 + parameter, 15 + point, 16 + distance, 17 + } 18 + } 19 + 20 + #[must_use] 21 + pub const fn parameter(self) -> Parameter { 22 + self.parameter 23 + } 24 + 25 + #[must_use] 26 + pub const fn point(self) -> Point2 { 27 + self.point 28 + } 29 + 30 + #[must_use] 31 + pub const fn distance(self) -> Length { 32 + self.distance 33 + } 34 + } 35 + 36 + impl core::fmt::Display for ClosestPoint2 { 37 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 38 + use uom::si::length::millimeter; 39 + write!( 40 + f, 41 + "closest{{ t={}, p={}, d={} mm }}", 42 + self.parameter.value(), 43 + self.point, 44 + self.distance.get::<millimeter>(), 45 + ) 46 + } 47 + }
+30
crates/bone-kernel/src/curvature.rs
··· 1 + use bone_types::Length; 2 + use uom::si::length::millimeter; 3 + 4 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 5 + pub struct Curvature(f64); 6 + 7 + impl Curvature { 8 + pub const ZERO: Self = Self(0.0); 9 + 10 + #[must_use] 11 + pub fn from_radius(radius: Length) -> Self { 12 + Self(1.0 / radius.get::<millimeter>()) 13 + } 14 + 15 + #[must_use] 16 + pub const fn value_per_mm(self) -> f64 { 17 + self.0 18 + } 19 + 20 + #[must_use] 21 + pub fn with_sign(self, sign: f64) -> Self { 22 + Self(self.0 * sign.signum()) 23 + } 24 + } 25 + 26 + impl core::fmt::Display for Curvature { 27 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 28 + write!(f, "k={}/mm", self.0) 29 + } 30 + }
+73
crates/bone-kernel/src/curve2.rs
··· 1 + use bone_types::{Parameter, Point2, Tolerance, UnitVec2, Vec2}; 2 + 3 + use crate::aabb::Aabb2; 4 + use crate::arc2::Arc2; 5 + use crate::circle2::Circle2; 6 + use crate::closest::ClosestPoint2; 7 + use crate::curvature::Curvature; 8 + use crate::line2::Line2; 9 + 10 + pub trait Curve2: Copy { 11 + fn evaluate(&self, t: Parameter) -> Point2; 12 + fn derivative(&self, t: Parameter) -> Vec2; 13 + fn tangent(&self, t: Parameter) -> UnitVec2; 14 + fn curvature(&self, t: Parameter) -> Curvature; 15 + fn bounding_box(&self) -> Aabb2; 16 + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2; 17 + fn as_kind(&self) -> Curve2Kind; 18 + } 19 + 20 + #[derive(Copy, Clone, Debug, PartialEq)] 21 + pub enum Curve2Kind { 22 + Line(Line2), 23 + Arc(Arc2), 24 + Circle(Circle2), 25 + } 26 + 27 + impl Curve2 for Curve2Kind { 28 + fn evaluate(&self, t: Parameter) -> Point2 { 29 + match self { 30 + Self::Line(c) => c.evaluate(t), 31 + Self::Arc(c) => c.evaluate(t), 32 + Self::Circle(c) => c.evaluate(t), 33 + } 34 + } 35 + fn derivative(&self, t: Parameter) -> Vec2 { 36 + match self { 37 + Self::Line(c) => c.derivative(t), 38 + Self::Arc(c) => c.derivative(t), 39 + Self::Circle(c) => c.derivative(t), 40 + } 41 + } 42 + fn tangent(&self, t: Parameter) -> UnitVec2 { 43 + match self { 44 + Self::Line(c) => c.tangent(t), 45 + Self::Arc(c) => c.tangent(t), 46 + Self::Circle(c) => c.tangent(t), 47 + } 48 + } 49 + fn curvature(&self, t: Parameter) -> Curvature { 50 + match self { 51 + Self::Line(c) => c.curvature(t), 52 + Self::Arc(c) => c.curvature(t), 53 + Self::Circle(c) => c.curvature(t), 54 + } 55 + } 56 + fn bounding_box(&self) -> Aabb2 { 57 + match self { 58 + Self::Line(c) => c.bounding_box(), 59 + Self::Arc(c) => c.bounding_box(), 60 + Self::Circle(c) => c.bounding_box(), 61 + } 62 + } 63 + fn closest_point(&self, p: Point2, tolerance: Tolerance) -> ClosestPoint2 { 64 + match self { 65 + Self::Line(c) => c.closest_point(p, tolerance), 66 + Self::Arc(c) => c.closest_point(p, tolerance), 67 + Self::Circle(c) => c.closest_point(p, tolerance), 68 + } 69 + } 70 + fn as_kind(&self) -> Curve2Kind { 71 + *self 72 + } 73 + }
+305
crates/bone-kernel/src/intersect.rs
··· 1 + use bone_types::{AngleTolerance, Point2, Tolerance}; 2 + use core::f64::consts::TAU; 3 + 4 + use crate::arc2::{Arc2, wrap_delta}; 5 + use crate::circle2::Circle2; 6 + use crate::curve2::Curve2Kind; 7 + use crate::line2::Line2; 8 + 9 + #[derive(Copy, Clone, Debug, PartialEq)] 10 + pub enum IntersectionSet { 11 + Empty, 12 + One(Point2), 13 + Two(Point2, Point2), 14 + Coincident, 15 + } 16 + 17 + impl core::fmt::Display for IntersectionSet { 18 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 19 + match self { 20 + Self::Empty => write!(f, "empty"), 21 + Self::One(p) => write!(f, "one{{ {p} }}"), 22 + Self::Two(a, b) => write!(f, "two{{ {a}, {b} }}"), 23 + Self::Coincident => write!(f, "coincident"), 24 + } 25 + } 26 + } 27 + 28 + #[must_use] 29 + pub fn intersect_curves(a: &Curve2Kind, b: &Curve2Kind, tolerance: Tolerance) -> IntersectionSet { 30 + match (a, b) { 31 + (Curve2Kind::Line(l), Curve2Kind::Line(m)) => line_line(l, m, tolerance), 32 + (Curve2Kind::Line(l), Curve2Kind::Circle(c)) 33 + | (Curve2Kind::Circle(c), Curve2Kind::Line(l)) => line_circle(l, c, tolerance), 34 + (Curve2Kind::Line(l), Curve2Kind::Arc(a)) | (Curve2Kind::Arc(a), Curve2Kind::Line(l)) => { 35 + line_arc(l, a, tolerance) 36 + } 37 + (Curve2Kind::Circle(c), Curve2Kind::Circle(d)) => circle_circle(c, d, tolerance), 38 + (Curve2Kind::Circle(c), Curve2Kind::Arc(a)) 39 + | (Curve2Kind::Arc(a), Curve2Kind::Circle(c)) => circle_arc(c, a, tolerance), 40 + (Curve2Kind::Arc(a), Curve2Kind::Arc(b)) => arc_arc(a, b, tolerance), 41 + } 42 + } 43 + 44 + fn line_line(a: &Line2, b: &Line2, tolerance: Tolerance) -> IntersectionSet { 45 + let (ax, ay) = a.start().coords_mm(); 46 + let (ux, uy) = a.unit_direction().components(); 47 + let la = a.length_mm(); 48 + let (cx, cy) = b.start().coords_mm(); 49 + let (vx, vy) = b.unit_direction().components(); 50 + let lb = b.length_mm(); 51 + let eps = tolerance.value(); 52 + 53 + let det = ux * vy - uy * vx; 54 + let angle_eps = eps / la.max(lb); 55 + 56 + if det.abs() < angle_eps { 57 + let dx = cx - ax; 58 + let dy = cy - ay; 59 + let perp = dx * uy - dy * ux; 60 + if perp.abs() > eps { 61 + return IntersectionSet::Empty; 62 + } 63 + let (ex, ey) = b.end().coords_mm(); 64 + let t_c = dx * ux + dy * uy; 65 + let t_d = (ex - ax) * ux + (ey - ay) * uy; 66 + let (t_lo, t_hi) = if t_c <= t_d { (t_c, t_d) } else { (t_d, t_c) }; 67 + let overlap_lo = t_lo.max(0.0); 68 + let overlap_hi = t_hi.min(la); 69 + let width = overlap_hi - overlap_lo; 70 + if width < -eps { 71 + return IntersectionSet::Empty; 72 + } 73 + if width <= eps { 74 + let t = f64::midpoint(overlap_lo, overlap_hi); 75 + return IntersectionSet::One(Point2::from_mm(ax + t * ux, ay + t * uy)); 76 + } 77 + return IntersectionSet::Coincident; 78 + } 79 + 80 + let t = ((cx - ax) * vy - (cy - ay) * vx) / det; 81 + let s = ((cx - ax) * uy - (cy - ay) * ux) / det; 82 + if !in_segment(t, la, eps) || !in_segment(s, lb, eps) { 83 + return IntersectionSet::Empty; 84 + } 85 + IntersectionSet::One(Point2::from_mm(ax + t * ux, ay + t * uy)) 86 + } 87 + 88 + fn line_circle(line: &Line2, circle: &Circle2, tolerance: Tolerance) -> IntersectionSet { 89 + let (ax, ay) = line.start().coords_mm(); 90 + let (ux, uy) = line.unit_direction().components(); 91 + let la = line.length_mm(); 92 + let (cx, cy) = circle.center().coords_mm(); 93 + let r = circle.radius_mm(); 94 + let eps = tolerance.value(); 95 + 96 + let fx = ax - cx; 97 + let fy = ay - cy; 98 + let b_coef = 2.0 * (fx * ux + fy * uy); 99 + let c_coef = fx * fx + fy * fy - r * r; 100 + let disc = b_coef * b_coef - 4.0 * c_coef; 101 + if disc < -eps * eps { 102 + return IntersectionSet::Empty; 103 + } 104 + let sqrt_disc = disc.max(0.0).sqrt(); 105 + let t1 = f64::midpoint(-b_coef, -sqrt_disc); 106 + let t2 = f64::midpoint(-b_coef, sqrt_disc); 107 + 108 + let p = |t: f64| Point2::from_mm(ax + t * ux, ay + t * uy); 109 + let in1 = in_segment(t1, la, eps); 110 + let in2 = in_segment(t2, la, eps); 111 + if sqrt_disc < eps { 112 + if in1 { 113 + IntersectionSet::One(p(t1)) 114 + } else { 115 + IntersectionSet::Empty 116 + } 117 + } else { 118 + match (in1, in2) { 119 + (true, true) => IntersectionSet::Two(p(t1), p(t2)), 120 + (true, false) => IntersectionSet::One(p(t1)), 121 + (false, true) => IntersectionSet::One(p(t2)), 122 + (false, false) => IntersectionSet::Empty, 123 + } 124 + } 125 + } 126 + 127 + fn line_arc(line: &Line2, arc: &Arc2, tolerance: Tolerance) -> IntersectionSet { 128 + let circle = arc.as_full_circle(); 129 + match line_circle(line, &circle, tolerance) { 130 + IntersectionSet::Empty => IntersectionSet::Empty, 131 + IntersectionSet::One(p) => { 132 + if arc.contains_point(p, tolerance) { 133 + IntersectionSet::One(p) 134 + } else { 135 + IntersectionSet::Empty 136 + } 137 + } 138 + IntersectionSet::Two(p, q) => filter_two_by_arc(p, q, arc, tolerance), 139 + IntersectionSet::Coincident => unreachable!("line_circle never returns Coincident"), 140 + } 141 + } 142 + 143 + fn circle_circle(a: &Circle2, b: &Circle2, tolerance: Tolerance) -> IntersectionSet { 144 + let (ax, ay) = a.center().coords_mm(); 145 + let (bx, by) = b.center().coords_mm(); 146 + let ra = a.radius_mm(); 147 + let rb = b.radius_mm(); 148 + let eps = tolerance.value(); 149 + 150 + let dx = bx - ax; 151 + let dy = by - ay; 152 + let d_sq = dx * dx + dy * dy; 153 + let d = d_sq.sqrt(); 154 + 155 + if d < eps && (ra - rb).abs() < eps { 156 + return IntersectionSet::Coincident; 157 + } 158 + if d > ra + rb + eps { 159 + return IntersectionSet::Empty; 160 + } 161 + if d + ra.min(rb) < ra.max(rb) - eps { 162 + return IntersectionSet::Empty; 163 + } 164 + if d < eps { 165 + return IntersectionSet::Empty; 166 + } 167 + 168 + let a_proj = (d_sq + ra * ra - rb * rb) / (2.0 * d); 169 + let h_sq = ra * ra - a_proj * a_proj; 170 + let mx = ax + a_proj * dx / d; 171 + let my = ay + a_proj * dy / d; 172 + 173 + if h_sq.abs() < eps * eps { 174 + return IntersectionSet::One(Point2::from_mm(mx, my)); 175 + } 176 + if h_sq < 0.0 { 177 + return IntersectionSet::Empty; 178 + } 179 + let h = h_sq.sqrt(); 180 + let px = -dy / d * h; 181 + let py = dx / d * h; 182 + IntersectionSet::Two( 183 + Point2::from_mm(mx + px, my + py), 184 + Point2::from_mm(mx - px, my - py), 185 + ) 186 + } 187 + 188 + fn circle_arc(circle: &Circle2, arc: &Arc2, tolerance: Tolerance) -> IntersectionSet { 189 + let arc_full = arc.as_full_circle(); 190 + match circle_circle(circle, &arc_full, tolerance) { 191 + IntersectionSet::Empty => IntersectionSet::Empty, 192 + IntersectionSet::Coincident => IntersectionSet::Coincident, 193 + IntersectionSet::One(p) => { 194 + if arc.contains_point(p, tolerance) { 195 + IntersectionSet::One(p) 196 + } else { 197 + IntersectionSet::Empty 198 + } 199 + } 200 + IntersectionSet::Two(p, q) => filter_two_by_arc(p, q, arc, tolerance), 201 + } 202 + } 203 + 204 + fn arc_arc(a: &Arc2, b: &Arc2, tolerance: Tolerance) -> IntersectionSet { 205 + let a_full = a.as_full_circle(); 206 + let b_full = b.as_full_circle(); 207 + match circle_circle(&a_full, &b_full, tolerance) { 208 + IntersectionSet::Empty => IntersectionSet::Empty, 209 + IntersectionSet::Coincident => same_circle_arc_overlap(a, b, tolerance), 210 + IntersectionSet::One(p) => { 211 + if a.contains_point(p, tolerance) && b.contains_point(p, tolerance) { 212 + IntersectionSet::One(p) 213 + } else { 214 + IntersectionSet::Empty 215 + } 216 + } 217 + IntersectionSet::Two(p, q) => { 218 + let p_ok = a.contains_point(p, tolerance) && b.contains_point(p, tolerance); 219 + let q_ok = a.contains_point(q, tolerance) && b.contains_point(q, tolerance); 220 + match (p_ok, q_ok) { 221 + (true, true) => IntersectionSet::Two(p, q), 222 + (true, false) => IntersectionSet::One(p), 223 + (false, true) => IntersectionSet::One(q), 224 + (false, false) => IntersectionSet::Empty, 225 + } 226 + } 227 + } 228 + } 229 + 230 + fn same_circle_arc_overlap(a: &Arc2, b: &Arc2, tolerance: Tolerance) -> IntersectionSet { 231 + let eps_angle = AngleTolerance::from_arc_length(tolerance, a.radius()).radians(); 232 + let (lo_a, mag_a) = canonical_range(a); 233 + let (lo_b, mag_b) = canonical_range(b); 234 + 235 + if mag_a >= TAU - eps_angle || mag_b >= TAU - eps_angle { 236 + return IntersectionSet::Coincident; 237 + } 238 + 239 + let hi_a = lo_a + mag_a; 240 + 241 + let overlaps: [Option<(f64, f64)>; 3] = [-TAU, 0.0, TAU].map(|shift| { 242 + let lo_b_s = lo_b + shift; 243 + let hi_b_s = lo_b_s + mag_b; 244 + let o_lo = lo_a.max(lo_b_s); 245 + let o_hi = hi_a.min(hi_b_s); 246 + (o_hi + eps_angle >= o_lo).then_some((o_lo, o_hi)) 247 + }); 248 + 249 + let has_1d = overlaps 250 + .iter() 251 + .any(|o| matches!(o, Some((s, e)) if e - s > eps_angle)); 252 + if has_1d { 253 + return IntersectionSet::Coincident; 254 + } 255 + 256 + let points: Vec<f64> = overlaps 257 + .iter() 258 + .filter_map(|o| o.map(|(s, e)| (0.5 * (s + e)).rem_euclid(TAU))) 259 + .fold(Vec::new(), |mut acc, t| { 260 + if !acc.iter().any(|&p: &f64| angle_close(p, t, eps_angle)) { 261 + acc.push(t); 262 + } 263 + acc 264 + }); 265 + 266 + match points.as_slice() { 267 + [] => IntersectionSet::Empty, 268 + [t] => IntersectionSet::One(arc_point_at_angle(a, *t)), 269 + [t1, t2, ..] => { 270 + IntersectionSet::Two(arc_point_at_angle(a, *t1), arc_point_at_angle(a, *t2)) 271 + } 272 + } 273 + } 274 + 275 + fn canonical_range(arc: &Arc2) -> (f64, f64) { 276 + let start = arc.start_rad(); 277 + let sweep = arc.sweep_rad(); 278 + let lo = if sweep >= 0.0 { start } else { start + sweep }; 279 + (lo.rem_euclid(TAU), sweep.abs()) 280 + } 281 + 282 + fn arc_point_at_angle(arc: &Arc2, theta: f64) -> Point2 { 283 + let (cx, cy) = arc.center().coords_mm(); 284 + let r = arc.radius_mm(); 285 + Point2::from_mm(cx + r * theta.cos(), cy + r * theta.sin()) 286 + } 287 + 288 + fn angle_close(a: f64, b: f64, eps: f64) -> bool { 289 + wrap_delta(a - b).abs() < eps 290 + } 291 + 292 + fn filter_two_by_arc(p: Point2, q: Point2, arc: &Arc2, tolerance: Tolerance) -> IntersectionSet { 293 + let p_in = arc.contains_point(p, tolerance); 294 + let q_in = arc.contains_point(q, tolerance); 295 + match (p_in, q_in) { 296 + (true, true) => IntersectionSet::Two(p, q), 297 + (true, false) => IntersectionSet::One(p), 298 + (false, true) => IntersectionSet::One(q), 299 + (false, false) => IntersectionSet::Empty, 300 + } 301 + } 302 + 303 + fn in_segment(t: f64, length: f64, eps: f64) -> bool { 304 + t >= -eps && t <= length + eps 305 + }
+29
crates/bone-kernel/src/lib.rs
··· 1 + pub mod aabb; 2 + pub mod arc2; 3 + pub mod circle2; 4 + pub mod closest; 5 + pub mod curvature; 6 + pub mod curve2; 7 + pub mod intersect; 8 + pub mod line2; 9 + 10 + pub use aabb::Aabb2; 11 + pub use arc2::{Arc2, arc_bounding_box}; 12 + pub use circle2::Circle2; 13 + pub use closest::ClosestPoint2; 14 + pub use curvature::Curvature; 15 + pub use curve2::{Curve2, Curve2Kind}; 16 + pub use intersect::{IntersectionSet, intersect_curves}; 17 + pub use line2::Line2; 18 + 19 + #[derive(Debug, thiserror::Error)] 20 + pub enum KernelError { 21 + #[error("line endpoints coincide within tolerance")] 22 + DegenerateLine, 23 + #[error("arc sweep is within tolerance of zero or exceeds 2π")] 24 + DegenerateArc, 25 + #[error("circle radius is within tolerance of zero")] 26 + DegenerateCircle, 27 + } 28 + 29 + pub type Result<T, E = KernelError> = core::result::Result<T, E>;
+95
crates/bone-kernel/src/line2.rs
··· 1 + use bone_types::{Length, Parameter, Point2, Tolerance, UnitVec2, Vec2}; 2 + use uom::si::length::millimeter; 3 + 4 + use crate::KernelError; 5 + use crate::aabb::Aabb2; 6 + use crate::closest::ClosestPoint2; 7 + use crate::curvature::Curvature; 8 + use crate::curve2::{Curve2, Curve2Kind}; 9 + 10 + #[derive(Copy, Clone, Debug, PartialEq)] 11 + pub struct Line2 { 12 + start: Point2, 13 + end: Point2, 14 + } 15 + 16 + impl Line2 { 17 + pub fn new(start: Point2, end: Point2, tolerance: Tolerance) -> Result<Self, KernelError> { 18 + if (end - start).norm_mm() < tolerance.value() { 19 + return Err(KernelError::DegenerateLine); 20 + } 21 + Ok(Self { start, end }) 22 + } 23 + 24 + #[must_use] 25 + pub const fn start(self) -> Point2 { 26 + self.start 27 + } 28 + 29 + #[must_use] 30 + pub const fn end(self) -> Point2 { 31 + self.end 32 + } 33 + 34 + #[must_use] 35 + pub fn length(self) -> Length { 36 + (self.end - self.start).norm() 37 + } 38 + 39 + #[must_use] 40 + pub fn length_mm(self) -> f64 { 41 + (self.end - self.start).norm_mm() 42 + } 43 + 44 + #[must_use] 45 + pub fn unit_direction(self) -> UnitVec2 { 46 + let (dx, dy) = (self.end - self.start).coords_mm(); 47 + let len = (dx * dx + dy * dy).sqrt(); 48 + UnitVec2::new_unchecked(dx / len, dy / len) 49 + } 50 + } 51 + 52 + impl Curve2 for Line2 { 53 + fn evaluate(&self, t: Parameter) -> Point2 { 54 + let (sx, sy) = self.start.coords_mm(); 55 + let (ex, ey) = self.end.coords_mm(); 56 + let u = t.value(); 57 + Point2::from_mm(sx + u * (ex - sx), sy + u * (ey - sy)) 58 + } 59 + 60 + fn derivative(&self, _t: Parameter) -> Vec2 { 61 + self.end - self.start 62 + } 63 + 64 + fn tangent(&self, _t: Parameter) -> UnitVec2 { 65 + self.unit_direction() 66 + } 67 + 68 + fn curvature(&self, _t: Parameter) -> Curvature { 69 + Curvature::ZERO 70 + } 71 + 72 + fn bounding_box(&self) -> Aabb2 { 73 + Aabb2::from_corners(self.start, self.end) 74 + } 75 + 76 + fn closest_point(&self, p: Point2, _tolerance: Tolerance) -> ClosestPoint2 { 77 + let d = self.end - self.start; 78 + let v = p - self.start; 79 + let len_sq = d.norm_squared_mm2(); 80 + let t = (v.dot_mm2(d) / len_sq).clamp(0.0, 1.0); 81 + let point = self.evaluate(Parameter::new(t)); 82 + let dist_mm = (p - point).norm_mm(); 83 + ClosestPoint2::new(Parameter::new(t), point, Length::new::<millimeter>(dist_mm)) 84 + } 85 + 86 + fn as_kind(&self) -> Curve2Kind { 87 + Curve2Kind::Line(*self) 88 + } 89 + } 90 + 91 + impl core::fmt::Display for Line2 { 92 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 93 + write!(f, "line2{{ {} -> {} }}", self.start, self.end) 94 + } 95 + }
+155
crates/bone-kernel/tests/curves.rs
··· 1 + use bone_kernel::{Arc2, Circle2, Curve2, Line2}; 2 + use bone_types::{Angle, Length, Parameter, Point2, Tolerance}; 3 + use uom::si::angle::radian; 4 + use uom::si::length::millimeter; 5 + 6 + const TOL: Tolerance = Tolerance::new(1e-9); 7 + 8 + fn samples() -> [Parameter; 5] { 9 + [ 10 + Parameter::new(0.0), 11 + Parameter::new(0.25), 12 + Parameter::new(0.5), 13 + Parameter::new(0.75), 14 + Parameter::new(1.0), 15 + ] 16 + } 17 + 18 + fn fmt_point(p: Point2) -> String { 19 + let (x, y) = p.coords_mm(); 20 + format!("({x:.6}, {y:.6})") 21 + } 22 + 23 + fn fmt_curve<C: Curve2 + core::fmt::Display + core::fmt::Debug>(name: &str, c: &C) -> String { 24 + let samples = samples(); 25 + let points = samples 26 + .iter() 27 + .map(|t| { 28 + format!( 29 + "t={:.2} -> p={} v={}", 30 + t.value(), 31 + fmt_point(c.evaluate(*t)), 32 + c.derivative(*t) 33 + ) 34 + }) 35 + .collect::<Vec<_>>() 36 + .join("\n "); 37 + let k_start = c.curvature(Parameter::new(0.0)); 38 + let k_mid = c.curvature(Parameter::new(0.5)); 39 + let tangent_mid = c.tangent(Parameter::new(0.5)); 40 + let bbox = c.bounding_box(); 41 + let closest = c.closest_point(Point2::from_mm(0.0, 0.0), TOL); 42 + format!( 43 + "{name}_display = {c}\n\ 44 + {name}_debug = {c:?}\n\ 45 + {name}_samples:\n {points}\n\ 46 + {name}_tangent_mid = {tangent_mid}\n\ 47 + {name}_curvature_start = {k_start}\n\ 48 + {name}_curvature_mid = {k_mid}\n\ 49 + {name}_bbox = {bbox}\n\ 50 + {name}_closest_origin = {closest}", 51 + ) 52 + } 53 + 54 + #[test] 55 + fn line2_surface() { 56 + let Ok(line) = Line2::new(Point2::from_mm(1.0, 2.0), Point2::from_mm(7.0, 6.0), TOL) else { 57 + panic!("endpoints distinct"); 58 + }; 59 + insta::assert_snapshot!(fmt_curve("line2", &line)); 60 + } 61 + 62 + #[test] 63 + fn circle2_surface() { 64 + let Ok(circle) = Circle2::new( 65 + Point2::from_mm(2.0, -1.0), 66 + Length::new::<millimeter>(3.0), 67 + TOL, 68 + ) else { 69 + panic!("radius positive"); 70 + }; 71 + insta::assert_snapshot!(fmt_curve("circle2", &circle)); 72 + } 73 + 74 + #[test] 75 + fn arc2_quarter_ccw_surface() { 76 + let Ok(arc) = Arc2::new( 77 + Point2::from_mm(0.0, 0.0), 78 + Length::new::<millimeter>(5.0), 79 + Angle::new::<radian>(0.0), 80 + Angle::new::<radian>(core::f64::consts::FRAC_PI_2), 81 + TOL, 82 + ) else { 83 + panic!("sweep nonzero"); 84 + }; 85 + insta::assert_snapshot!(fmt_curve("arc2_q", &arc)); 86 + } 87 + 88 + #[test] 89 + fn arc2_half_cw_surface() { 90 + let Ok(arc) = Arc2::new( 91 + Point2::from_mm(1.0, 1.0), 92 + Length::new::<millimeter>(2.0), 93 + Angle::new::<radian>(core::f64::consts::PI), 94 + Angle::new::<radian>(-core::f64::consts::PI), 95 + TOL, 96 + ) else { 97 + panic!("sweep nonzero"); 98 + }; 99 + insta::assert_snapshot!(fmt_curve("arc2_h", &arc)); 100 + } 101 + 102 + #[test] 103 + fn line2_rejects_degenerate_endpoints() { 104 + let err = Line2::new( 105 + Point2::from_mm(1.0, 1.0), 106 + Point2::from_mm(1.0, 1.0 + 1e-12), 107 + TOL, 108 + ); 109 + assert!(err.is_err()); 110 + } 111 + 112 + #[test] 113 + fn circle2_rejects_zero_radius() { 114 + let err = Circle2::new( 115 + Point2::from_mm(0.0, 0.0), 116 + Length::new::<millimeter>(0.0), 117 + TOL, 118 + ); 119 + assert!(err.is_err()); 120 + } 121 + 122 + #[test] 123 + fn arc2_rejects_zero_sweep() { 124 + let err = Arc2::new( 125 + Point2::from_mm(0.0, 0.0), 126 + Length::new::<millimeter>(1.0), 127 + Angle::new::<radian>(0.0), 128 + Angle::new::<radian>(0.0), 129 + TOL, 130 + ); 131 + assert!(err.is_err()); 132 + } 133 + 134 + #[test] 135 + fn arc2_closest_point_wide_sweep_returns_midarc_t() { 136 + let Ok(arc) = Arc2::new( 137 + Point2::from_mm(0.0, 0.0), 138 + Length::new::<millimeter>(1.0), 139 + Angle::new::<radian>(0.0), 140 + Angle::new::<radian>(3.0 * core::f64::consts::FRAC_PI_2), 141 + TOL, 142 + ) else { 143 + panic!("3π/2 sweep within bounds"); 144 + }; 145 + let px = -0.7_f64; 146 + let py = -0.8_f64; 147 + let closest = arc.closest_point(Point2::from_mm(px, py), TOL); 148 + let theta = py.atan2(px).rem_euclid(core::f64::consts::TAU); 149 + let expected_t = theta / (3.0 * core::f64::consts::FRAC_PI_2); 150 + let got_t = closest.parameter().value(); 151 + assert!( 152 + (got_t - expected_t).abs() < 1e-12, 153 + "wide-sweep closest-point: expected t≈{expected_t}, got {got_t}" 154 + ); 155 + }
+143
crates/bone-kernel/tests/intersect.rs
··· 1 + use bone_kernel::{Arc2, Circle2, Curve2, Curve2Kind, Line2, intersect_curves}; 2 + use bone_types::{Angle, Length, Point2, Tolerance}; 3 + use uom::si::angle::radian; 4 + use uom::si::length::millimeter; 5 + 6 + const TOL: Tolerance = Tolerance::new(1e-9); 7 + 8 + fn line(ax: f64, ay: f64, bx: f64, by: f64) -> Curve2Kind { 9 + let Ok(l) = Line2::new(Point2::from_mm(ax, ay), Point2::from_mm(bx, by), TOL) else { 10 + panic!("line endpoints distinct"); 11 + }; 12 + l.as_kind() 13 + } 14 + 15 + fn circle(cx: f64, cy: f64, r: f64) -> Curve2Kind { 16 + let Ok(c) = Circle2::new(Point2::from_mm(cx, cy), Length::new::<millimeter>(r), TOL) else { 17 + panic!("radius positive"); 18 + }; 19 + c.as_kind() 20 + } 21 + 22 + fn arc(cx: f64, cy: f64, r: f64, start: f64, sweep: f64) -> Curve2Kind { 23 + let Ok(a) = Arc2::new( 24 + Point2::from_mm(cx, cy), 25 + Length::new::<millimeter>(r), 26 + Angle::new::<radian>(start), 27 + Angle::new::<radian>(sweep), 28 + TOL, 29 + ) else { 30 + panic!("sweep nonzero"); 31 + }; 32 + a.as_kind() 33 + } 34 + 35 + fn row(label: &str, a: &Curve2Kind, b: &Curve2Kind) -> String { 36 + format!("{label} = {}", intersect_curves(a, b, TOL)) 37 + } 38 + 39 + #[test] 40 + fn intersection_matrix_surface() { 41 + let line_diag = line(-5.0, -5.0, 5.0, 5.0); 42 + let line_cross = line(-5.0, 5.0, 5.0, -5.0); 43 + let line_parallel = line(-5.0, -4.0, 5.0, 6.0); 44 + let line_coincident = line(-2.0, -2.0, 2.0, 2.0); 45 + let line_collinear_touch = line(5.0, 5.0, 10.0, 10.0); 46 + let line_miss = line(10.0, 10.0, 11.0, 11.0); 47 + let line_tangent = line(-5.0, 3.0, 5.0, 3.0); 48 + let line_through_circle = line(-5.0, 0.0, 5.0, 0.0); 49 + 50 + let circle_unit = circle(0.0, 0.0, 3.0); 51 + let circle_shift = circle(4.0, 0.0, 3.0); 52 + let circle_contain = circle(0.0, 0.0, 1.0); 53 + let circle_dup = circle(0.0, 0.0, 3.0); 54 + let circle_far = circle(20.0, 0.0, 1.0); 55 + 56 + let arc_right = arc( 57 + 0.0, 58 + 0.0, 59 + 3.0, 60 + -core::f64::consts::FRAC_PI_2, 61 + core::f64::consts::PI, 62 + ); 63 + let arc_upper = arc(0.0, 0.0, 3.0, 0.0, core::f64::consts::PI); 64 + let arc_other = arc( 65 + 4.0, 66 + 0.0, 67 + 3.0, 68 + core::f64::consts::FRAC_PI_2, 69 + core::f64::consts::PI, 70 + ); 71 + let line_above_arc = line(-5.0, 5.0, 5.0, 5.0); 72 + 73 + let arc_same_disjoint_q1 = arc(0.0, 0.0, 3.0, 0.0, core::f64::consts::FRAC_PI_2); 74 + let arc_same_disjoint_q3 = arc( 75 + 0.0, 76 + 0.0, 77 + 3.0, 78 + core::f64::consts::PI, 79 + core::f64::consts::FRAC_PI_2, 80 + ); 81 + let arc_same_touch_left = arc(0.0, 0.0, 3.0, 0.0, core::f64::consts::PI); 82 + let arc_same_touch_right = arc(0.0, 0.0, 3.0, core::f64::consts::PI, core::f64::consts::PI); 83 + let arc_same_partial_b = arc( 84 + 0.0, 85 + 0.0, 86 + 3.0, 87 + core::f64::consts::FRAC_PI_2, 88 + core::f64::consts::PI, 89 + ); 90 + let arc_same_single_touch = arc( 91 + 0.0, 92 + 0.0, 93 + 3.0, 94 + core::f64::consts::FRAC_PI_2, 95 + core::f64::consts::FRAC_PI_2, 96 + ); 97 + 98 + let rows = [ 99 + row("line_line_cross", &line_diag, &line_cross), 100 + row("line_line_parallel_miss", &line_diag, &line_parallel), 101 + row("line_line_coincident_overlap", &line_diag, &line_coincident), 102 + row( 103 + "line_line_collinear_touch", 104 + &line_diag, 105 + &line_collinear_touch, 106 + ), 107 + row("line_line_segment_miss", &line_diag, &line_miss), 108 + row("line_circle_tangent", &line_tangent, &circle_unit), 109 + row("line_circle_secant", &line_through_circle, &circle_unit), 110 + row("line_arc_right_hit", &line_through_circle, &arc_right), 111 + row("line_arc_upper_endpoints", &line_through_circle, &arc_upper), 112 + row("line_arc_above_miss", &line_above_arc, &arc_upper), 113 + row("circle_circle_two_points", &circle_unit, &circle_shift), 114 + row("circle_circle_contained", &circle_unit, &circle_contain), 115 + row("circle_circle_coincident", &circle_unit, &circle_dup), 116 + row("circle_circle_disjoint", &circle_unit, &circle_far), 117 + row("circle_arc_partial", &circle_shift, &arc_upper), 118 + row("arc_arc_upper_vs_other", &arc_upper, &arc_other), 119 + row( 120 + "arc_arc_same_circle_disjoint", 121 + &arc_same_disjoint_q1, 122 + &arc_same_disjoint_q3, 123 + ), 124 + row( 125 + "arc_arc_same_circle_two_touches", 126 + &arc_same_touch_left, 127 + &arc_same_touch_right, 128 + ), 129 + row( 130 + "arc_arc_same_circle_partial_overlap", 131 + &arc_upper, 132 + &arc_same_partial_b, 133 + ), 134 + row( 135 + "arc_arc_same_circle_single_touch", 136 + &arc_same_disjoint_q1, 137 + &arc_same_single_touch, 138 + ), 139 + ]; 140 + 141 + let surface = rows.join("\n"); 142 + insta::assert_snapshot!(surface); 143 + }
+17
crates/bone-kernel/tests/snapshots/curves__arc2_half_cw_surface.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/curves.rs 3 + expression: "fmt_curve(\"arc2_h\", &arc)" 4 + --- 5 + arc2_h_display = arc2{ c=(1 mm, 1 mm), r=2 mm, start=3.141592653589793 rad, sweep=-3.141592653589793 rad } 6 + arc2_h_debug = Arc2 { center: Point2(1 mm, 1 mm), radius: 0.002 m^1, start_angle: 3.141592653589793, sweep_angle: -3.141592653589793 } 7 + arc2_h_samples: 8 + t=0.00 -> p=(-1.000000, 1.000000) v=<0.0000000000000007694682774887159 mm, 6.283185307179586 mm> 9 + t=0.25 -> p=(-0.414214, 2.414214) v=<4.442882938158366 mm, 4.442882938158366 mm> 10 + t=0.50 -> p=(1.000000, 3.000000) v=<6.283185307179586 mm, -0.00000000000000038473413874435795 mm> 11 + t=0.75 -> p=(2.414214, 2.414214) v=<4.442882938158366 mm, -4.442882938158366 mm> 12 + t=1.00 -> p=(3.000000, 1.000000) v=<0 mm, -6.283185307179586 mm> 13 + arc2_h_tangent_mid = [1, -0.00000000000000006123233995736766] 14 + arc2_h_curvature_start = k=-0.5/mm 15 + arc2_h_curvature_mid = k=-0.5/mm 16 + arc2_h_bbox = aabb2{ min=(-1 mm, 1 mm), max=(3 mm, 3 mm) } 17 + arc2_h_closest_origin = closest{ t=0, p=(-1 mm, 1.0000000000000002 mm), d=1.4142135623730951 mm }
+17
crates/bone-kernel/tests/snapshots/curves__arc2_quarter_ccw_surface.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/curves.rs 3 + expression: "fmt_curve(\"arc2_q\", &arc)" 4 + --- 5 + arc2_q_display = arc2{ c=(0 mm, 0 mm), r=5 mm, start=0 rad, sweep=1.5707963267948966 rad } 6 + arc2_q_debug = Arc2 { center: Point2(0 mm, 0 mm), radius: 0.005 m^1, start_angle: 0.0, sweep_angle: 1.5707963267948966 } 7 + arc2_q_samples: 8 + t=0.00 -> p=(5.000000, 0.000000) v=<-0 mm, 7.853981633974483 mm> 9 + t=0.25 -> p=(4.619398, 1.913417) v=<-3.0055886494217314 mm, 7.256132880348577 mm> 10 + t=0.50 -> p=(3.535534, 3.535534) v=<-5.553603672697957 mm, 5.553603672697958 mm> 11 + t=0.75 -> p=(1.913417, 4.619398) v=<-7.256132880348577 mm, 3.005588649421732 mm> 12 + t=1.00 -> p=(0.000000, 5.000000) v=<-7.853981633974483 mm, 0.0000000000000004809176734304475 mm> 13 + arc2_q_tangent_mid = [-0.7071067811865475, 0.7071067811865476] 14 + arc2_q_curvature_start = k=0.2/mm 15 + arc2_q_curvature_mid = k=0.2/mm 16 + arc2_q_bbox = aabb2{ min=(0 mm, 0 mm), max=(5 mm, 5 mm) } 17 + arc2_q_closest_origin = closest{ t=0, p=(5 mm, 0 mm), d=5 mm }
+17
crates/bone-kernel/tests/snapshots/curves__circle2_surface.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/curves.rs 3 + expression: "fmt_curve(\"circle2\", &circle)" 4 + --- 5 + circle2_display = circle2{ c=(2 mm, -1 mm), r=3 mm } 6 + circle2_debug = Circle2 { center: Point2(2 mm, -1 mm), radius: 0.003 m^1 } 7 + circle2_samples: 8 + t=0.00 -> p=(5.000000, -1.000000) v=<-0 mm, 18.84955592153876 mm> 9 + t=0.25 -> p=(2.000000, 2.000000) v=<-18.84955592153876 mm, 0.0000000000000011542024162330739 mm> 10 + t=0.50 -> p=(-1.000000, -1.000000) v=<-0.0000000000000023084048324661477 mm, -18.84955592153876 mm> 11 + t=0.75 -> p=(2.000000, -4.000000) v=<18.84955592153876 mm, -0.0000000000000034626072486992214 mm> 12 + t=1.00 -> p=(5.000000, -1.000000) v=<0.0000000000000046168096649322955 mm, 18.84955592153876 mm> 13 + circle2_tangent_mid = [-0.00000000000000012246467991473532, -1] 14 + circle2_curvature_start = k=0.3333333333333333/mm 15 + circle2_curvature_mid = k=0.3333333333333333/mm 16 + circle2_bbox = aabb2{ min=(-1 mm, -4 mm), max=(5 mm, 2 mm) } 17 + circle2_closest_origin = closest{ t=0.42620819117478337, p=(-0.6832815729997477 mm, 0.3416407864998743 mm), d=0.7639320225002102 mm }
+17
crates/bone-kernel/tests/snapshots/curves__line2_surface.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/curves.rs 3 + expression: "fmt_curve(\"line2\", &line)" 4 + --- 5 + line2_display = line2{ (1 mm, 2 mm) -> (7 mm, 6 mm) } 6 + line2_debug = Line2 { start: Point2(1 mm, 2 mm), end: Point2(7 mm, 6 mm) } 7 + line2_samples: 8 + t=0.00 -> p=(1.000000, 2.000000) v=<6 mm, 4 mm> 9 + t=0.25 -> p=(2.500000, 3.000000) v=<6 mm, 4 mm> 10 + t=0.50 -> p=(4.000000, 4.000000) v=<6 mm, 4 mm> 11 + t=0.75 -> p=(5.500000, 5.000000) v=<6 mm, 4 mm> 12 + t=1.00 -> p=(7.000000, 6.000000) v=<6 mm, 4 mm> 13 + line2_tangent_mid = [0.8320502943378437, 0.5547001962252291] 14 + line2_curvature_start = k=0/mm 15 + line2_curvature_mid = k=0/mm 16 + line2_bbox = aabb2{ min=(1 mm, 2 mm), max=(7 mm, 6 mm) } 17 + line2_closest_origin = closest{ t=0, p=(1 mm, 2 mm), d=2.23606797749979 mm }
+24
crates/bone-kernel/tests/snapshots/intersect__intersection_matrix_surface.snap
··· 1 + --- 2 + source: crates/bone-kernel/tests/intersect.rs 3 + expression: surface 4 + --- 5 + line_line_cross = one{ (0 mm, 0 mm) } 6 + line_line_parallel_miss = empty 7 + line_line_coincident_overlap = coincident 8 + line_line_collinear_touch = one{ (5 mm, 5 mm) } 9 + line_line_segment_miss = empty 10 + line_circle_tangent = one{ (0 mm, 3 mm) } 11 + line_circle_secant = two{ (-3 mm, 0 mm), (3 mm, 0 mm) } 12 + line_arc_right_hit = one{ (3 mm, 0 mm) } 13 + line_arc_upper_endpoints = two{ (-3 mm, 0 mm), (3 mm, 0 mm) } 14 + line_arc_above_miss = empty 15 + circle_circle_two_points = two{ (2 mm, 2.23606797749979 mm), (2 mm, -2.23606797749979 mm) } 16 + circle_circle_contained = empty 17 + circle_circle_coincident = coincident 18 + circle_circle_disjoint = empty 19 + circle_arc_partial = one{ (2 mm, 2.23606797749979 mm) } 20 + arc_arc_upper_vs_other = one{ (2 mm, 2.23606797749979 mm) } 21 + arc_arc_same_circle_disjoint = empty 22 + arc_arc_same_circle_two_touches = two{ (3 mm, 0 mm), (-3 mm, 0.00000000000000036739403974420594 mm) } 23 + arc_arc_same_circle_partial_overlap = coincident 24 + arc_arc_same_circle_single_touch = one{ (0.00000000000000018369701987210297 mm, 3 mm) }
+25
crates/bone-render/Cargo.toml
··· 1 + [package] 2 + name = "bone-render" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + bone-document = { workspace = true } 10 + bone-kernel = { workspace = true } 11 + bone-types = { workspace = true } 12 + bytemuck = { workspace = true } 13 + png = { workspace = true } 14 + slotmap = { workspace = true } 15 + thiserror = { workspace = true } 16 + tracing = { workspace = true } 17 + uom = { workspace = true } 18 + wgpu = { workspace = true } 19 + 20 + [dev-dependencies] 21 + pollster = { workspace = true } 22 + proptest = { workspace = true } 23 + 24 + [lints] 25 + workspace = true
+330
crates/bone-render/src/camera.rs
··· 1 + use bone_types::{Length, Vec2}; 2 + use uom::si::length::millimeter; 3 + 4 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 5 + pub struct ViewportPx(u32); 6 + 7 + impl ViewportPx { 8 + #[must_use] 9 + pub const fn new(value: u32) -> Self { 10 + Self(value) 11 + } 12 + 13 + #[must_use] 14 + pub const fn value(self) -> u32 { 15 + self.0 16 + } 17 + } 18 + 19 + impl core::fmt::Display for ViewportPx { 20 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 21 + write!(f, "{}px", self.0) 22 + } 23 + } 24 + 25 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 26 + pub struct ViewportExtent { 27 + width: ViewportPx, 28 + height: ViewportPx, 29 + } 30 + 31 + impl ViewportExtent { 32 + #[must_use] 33 + pub const fn new(width: ViewportPx, height: ViewportPx) -> Self { 34 + Self { width, height } 35 + } 36 + 37 + #[must_use] 38 + pub const fn square(side: ViewportPx) -> Self { 39 + Self { 40 + width: side, 41 + height: side, 42 + } 43 + } 44 + 45 + #[must_use] 46 + pub const fn width(self) -> ViewportPx { 47 + self.width 48 + } 49 + 50 + #[must_use] 51 + pub const fn height(self) -> ViewportPx { 52 + self.height 53 + } 54 + 55 + #[must_use] 56 + pub fn pixel_count(self) -> u64 { 57 + u64::from(self.width.value()) * u64::from(self.height.value()) 58 + } 59 + } 60 + 61 + impl core::fmt::Display for ViewportExtent { 62 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 63 + write!(f, "{}x{}", self.width, self.height) 64 + } 65 + } 66 + 67 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 68 + pub struct PixelsPerMm(f64); 69 + 70 + impl PixelsPerMm { 71 + pub const DEFAULT: Self = Self(10.0); 72 + 73 + #[must_use] 74 + pub fn new(value: f64) -> Self { 75 + assert!( 76 + value.is_finite() && value > 0.0, 77 + "PixelsPerMm requires a positive, finite value: got {value}", 78 + ); 79 + Self(value) 80 + } 81 + 82 + #[must_use] 83 + pub const fn value(self) -> f64 { 84 + self.0 85 + } 86 + } 87 + 88 + impl core::fmt::Display for PixelsPerMm { 89 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 90 + write!(f, "{} px/mm", self.0) 91 + } 92 + } 93 + 94 + #[derive(Copy, Clone, Debug, PartialEq)] 95 + pub struct Camera2 { 96 + extent: ViewportExtent, 97 + pan_mm: Vec2, 98 + zoom: PixelsPerMm, 99 + } 100 + 101 + impl Camera2 { 102 + #[must_use] 103 + pub fn new(extent: ViewportExtent) -> Self { 104 + Self { 105 + extent, 106 + pan_mm: Vec2::zero(), 107 + zoom: PixelsPerMm::DEFAULT, 108 + } 109 + } 110 + 111 + #[must_use] 112 + pub const fn extent(self) -> ViewportExtent { 113 + self.extent 114 + } 115 + 116 + #[must_use] 117 + pub const fn pan_mm(self) -> Vec2 { 118 + self.pan_mm 119 + } 120 + 121 + #[must_use] 122 + pub const fn zoom(self) -> PixelsPerMm { 123 + self.zoom 124 + } 125 + 126 + #[must_use] 127 + pub fn with_extent(self, extent: ViewportExtent) -> Self { 128 + Self { extent, ..self } 129 + } 130 + 131 + #[must_use] 132 + pub fn with_pan(self, pan_mm: Vec2) -> Self { 133 + Self { pan_mm, ..self } 134 + } 135 + 136 + #[must_use] 137 + pub fn with_zoom(self, zoom: PixelsPerMm) -> Self { 138 + Self { zoom, ..self } 139 + } 140 + 141 + #[must_use] 142 + pub fn world_mm_per_pixel(self) -> f64 { 143 + 1.0 / self.zoom.value() 144 + } 145 + 146 + #[must_use] 147 + #[allow(clippy::cast_possible_truncation)] 148 + pub fn clip_from_world_mm(self) -> [f32; 16] { 149 + let (sx, sy) = self.scale(); 150 + let (px, py) = self.pan_mm.coords_mm(); 151 + let tx = -px * sx; 152 + let ty = -py * sy; 153 + column_major(sx as f32, sy as f32, tx as f32, ty as f32) 154 + } 155 + 156 + #[must_use] 157 + #[allow(clippy::cast_possible_truncation)] 158 + pub fn world_mm_from_clip(self) -> [f32; 16] { 159 + let (sx, sy) = self.scale(); 160 + let inv_x = 1.0 / sx; 161 + let inv_y = 1.0 / sy; 162 + let (px, py) = self.pan_mm.coords_mm(); 163 + column_major(inv_x as f32, inv_y as f32, px as f32, py as f32) 164 + } 165 + 166 + fn scale(self) -> (f64, f64) { 167 + let w = f64::from(self.extent.width.value()); 168 + let h = f64::from(self.extent.height.value()); 169 + let z = self.zoom.value(); 170 + (2.0 * z / w, 2.0 * z / h) 171 + } 172 + } 173 + 174 + fn column_major(sx: f32, sy: f32, tx: f32, ty: f32) -> [f32; 16] { 175 + [ 176 + 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, 177 + ] 178 + } 179 + 180 + #[derive(Copy, Clone, Debug, PartialEq)] 181 + pub struct GridSpacing { 182 + minor: Length, 183 + major_every: u32, 184 + } 185 + 186 + impl GridSpacing { 187 + #[must_use] 188 + pub fn minor(self) -> Length { 189 + self.minor 190 + } 191 + 192 + #[must_use] 193 + pub const fn major_every(self) -> u32 { 194 + self.major_every 195 + } 196 + 197 + #[must_use] 198 + pub fn major(self) -> Length { 199 + self.minor * f64::from(self.major_every) 200 + } 201 + 202 + #[must_use] 203 + pub fn from_zoom(zoom: PixelsPerMm, target_minor_px: f32) -> Self { 204 + assert!( 205 + target_minor_px.is_finite() && target_minor_px > 0.0, 206 + "GridSpacing::from_zoom requires a positive, finite target: got {target_minor_px}", 207 + ); 208 + let desired_mm = f64::from(target_minor_px) / zoom.value(); 209 + let k = desired_mm.log10().floor(); 210 + let frac = desired_mm / 10f64.powf(k); 211 + let (multiplier, major_every, k_bump) = if frac < f64::sqrt(2.0) { 212 + (1.0, 10, 0.0) 213 + } else if frac < f64::sqrt(10.0) { 214 + (2.0, 5, 0.0) 215 + } else if frac < f64::sqrt(50.0) { 216 + (5.0, 2, 0.0) 217 + } else { 218 + (1.0, 10, 1.0) 219 + }; 220 + let minor_mm = multiplier * 10f64.powf(k + k_bump); 221 + Self { 222 + minor: Length::new::<millimeter>(minor_mm), 223 + major_every, 224 + } 225 + } 226 + } 227 + 228 + #[cfg(test)] 229 + mod tests { 230 + use super::*; 231 + 232 + fn extent(w: u32, h: u32) -> ViewportExtent { 233 + ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h)) 234 + } 235 + 236 + fn approx_eq(a: f32, b: f32) -> bool { 237 + (a - b).abs() < 1e-6 238 + } 239 + 240 + #[test] 241 + fn default_camera_maps_origin_to_clip_center() { 242 + let cam = Camera2::new(extent(200, 100)); 243 + let m = cam.clip_from_world_mm(); 244 + assert!(approx_eq(m[12], 0.0)); 245 + assert!(approx_eq(m[13], 0.0)); 246 + } 247 + 248 + #[test] 249 + fn clip_from_world_is_column_major() { 250 + let cam = Camera2::new(extent(100, 100)).with_pan(Vec2::from_mm(2.0, 3.0)); 251 + let m = cam.clip_from_world_mm(); 252 + assert!(approx_eq(m[3], 0.0)); 253 + assert!(approx_eq(m[7], 0.0)); 254 + assert!(approx_eq(m[11], 0.0)); 255 + assert!(approx_eq(m[15], 1.0)); 256 + } 257 + 258 + #[test] 259 + fn inverse_round_trips_origin() { 260 + let cam = Camera2::new(extent(256, 128)) 261 + .with_pan(Vec2::from_mm(-5.0, 4.0)) 262 + .with_zoom(PixelsPerMm::new(8.0)); 263 + let fwd = cam.clip_from_world_mm(); 264 + let inv = cam.world_mm_from_clip(); 265 + let rt = mul4(&inv, &fwd); 266 + (0..16).for_each(|idx| { 267 + let (col, row) = (idx / 4, idx % 4); 268 + let want = if col == row { 1.0 } else { 0.0 }; 269 + assert!( 270 + approx_eq(rt[idx], want), 271 + "round trip broke at col={col},row={row}: {}", 272 + rt[idx] 273 + ); 274 + }); 275 + } 276 + 277 + fn mul4(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { 278 + core::array::from_fn(|idx| { 279 + let (col, row) = (idx / 4, idx % 4); 280 + (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum() 281 + }) 282 + } 283 + 284 + #[test] 285 + fn grid_spacing_selects_one_two_five_in_decade() { 286 + [1.0_f64, 10.0, 100.0] 287 + .into_iter() 288 + .map(PixelsPerMm::new) 289 + .map(|z| GridSpacing::from_zoom(z, 16.0)) 290 + .for_each(|s| { 291 + let minor_mm = s.minor().get::<millimeter>(); 292 + let k = minor_mm.log10().floor(); 293 + let decade = 10f64.powf(k); 294 + let mult = minor_mm / decade; 295 + let snapped = [1.0, 2.0, 5.0] 296 + .into_iter() 297 + .any(|m: f64| (mult - m).abs() < 1e-9); 298 + assert!(snapped, "minor {minor_mm} mm not in {{1, 2, 5}} x decade"); 299 + assert!([2, 5, 10].contains(&s.major_every())); 300 + }); 301 + } 302 + 303 + #[test] 304 + fn grid_spacing_rolls_over_decade_past_sqrt_fifty() { 305 + let zoom = PixelsPerMm::new(2.0); 306 + let s = GridSpacing::from_zoom(zoom, 16.0); 307 + let minor_mm = s.minor().get::<millimeter>(); 308 + assert!( 309 + (minor_mm - 10.0).abs() < 1e-9, 310 + "expected rollover to 10 mm, got {minor_mm}", 311 + ); 312 + assert_eq!(s.major_every(), 10); 313 + } 314 + 315 + #[test] 316 + fn clip_x_of_pan_point_is_zero() { 317 + let cam = Camera2::new(extent(320, 240)) 318 + .with_pan(Vec2::from_mm(7.0, -3.0)) 319 + .with_zoom(PixelsPerMm::new(5.0)); 320 + let m = cam.clip_from_world_mm(); 321 + let world = [7.0_f32, -3.0_f32, 0.0_f32, 1.0_f32]; 322 + let clip = apply(&m, world); 323 + assert!(approx_eq(clip[0], 0.0)); 324 + assert!(approx_eq(clip[1], 0.0)); 325 + } 326 + 327 + fn apply(m: &[f32; 16], v: [f32; 4]) -> [f32; 4] { 328 + core::array::from_fn(|row| (0..4).map(|col| m[col * 4 + row] * v[col]).sum()) 329 + } 330 + }
+280
crates/bone-render/src/diff.rs
··· 1 + use bone_types::Tolerance; 2 + 3 + use crate::camera::ViewportExtent; 4 + use crate::snapshot::SnapshotFrame; 5 + 6 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 7 + pub struct PixelDiffThreshold(f64); 8 + 9 + impl PixelDiffThreshold { 10 + pub const EXACT: Self = Self(0.0); 11 + 12 + #[must_use] 13 + pub fn new(channel_fraction: f64) -> Self { 14 + Self(channel_fraction.clamp(0.0, 1.0)) 15 + } 16 + 17 + #[must_use] 18 + pub const fn value(self) -> f64 { 19 + self.0 20 + } 21 + 22 + #[must_use] 23 + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 24 + pub fn as_u8(self) -> u8 { 25 + (self.0.clamp(0.0, 1.0) * 255.0).round() as u8 26 + } 27 + } 28 + 29 + impl From<Tolerance> for PixelDiffThreshold { 30 + fn from(t: Tolerance) -> Self { 31 + Self::new(t.value()) 32 + } 33 + } 34 + 35 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 36 + pub struct PixelMismatch { 37 + x: u32, 38 + y: u32, 39 + channel_delta: [u8; 4], 40 + } 41 + 42 + impl PixelMismatch { 43 + #[must_use] 44 + pub const fn x(self) -> u32 { 45 + self.x 46 + } 47 + 48 + #[must_use] 49 + pub const fn y(self) -> u32 { 50 + self.y 51 + } 52 + 53 + #[must_use] 54 + pub const fn channel_delta(self) -> [u8; 4] { 55 + self.channel_delta 56 + } 57 + 58 + #[must_use] 59 + pub fn max_delta(self) -> u8 { 60 + self.channel_delta.into_iter().max().unwrap_or(0) 61 + } 62 + } 63 + 64 + #[derive(Clone, Debug, Default)] 65 + pub struct PixelDiffReport { 66 + worst: Option<PixelMismatch>, 67 + over_threshold: u32, 68 + } 69 + 70 + impl PixelDiffReport { 71 + #[must_use] 72 + pub const fn worst(&self) -> Option<PixelMismatch> { 73 + self.worst 74 + } 75 + 76 + #[must_use] 77 + pub const fn over_threshold(&self) -> u32 { 78 + self.over_threshold 79 + } 80 + 81 + #[must_use] 82 + pub const fn is_clean(&self) -> bool { 83 + self.over_threshold == 0 84 + } 85 + } 86 + 87 + #[derive(Debug, thiserror::Error)] 88 + pub enum PixelDiffError { 89 + #[error( 90 + "pixel buffer length mismatch: actual={actual}, expected={expected}, required={required}" 91 + )] 92 + ShapeMismatch { 93 + actual: usize, 94 + expected: usize, 95 + required: usize, 96 + }, 97 + #[error("viewport dimension is zero")] 98 + ZeroExtent, 99 + #[error("pixel count {pixels} exceeds u32::MAX")] 100 + ExtentOverflow { pixels: u64 }, 101 + } 102 + 103 + pub struct PixelDiff; 104 + 105 + impl PixelDiff { 106 + pub fn compare_bytes( 107 + extent: ViewportExtent, 108 + actual: &[u8], 109 + expected: &[u8], 110 + threshold: PixelDiffThreshold, 111 + ) -> core::result::Result<PixelDiffReport, PixelDiffError> { 112 + let width = extent.width().value(); 113 + let height = extent.height().value(); 114 + if width == 0 || height == 0 { 115 + return Err(PixelDiffError::ZeroExtent); 116 + } 117 + let pixel_count = extent.pixel_count(); 118 + if pixel_count > u64::from(u32::MAX) { 119 + return Err(PixelDiffError::ExtentOverflow { 120 + pixels: pixel_count, 121 + }); 122 + } 123 + let Ok(required) = usize::try_from(pixel_count * 4) else { 124 + return Err(PixelDiffError::ExtentOverflow { 125 + pixels: pixel_count, 126 + }); 127 + }; 128 + if actual.len() != required || expected.len() != required { 129 + return Err(PixelDiffError::ShapeMismatch { 130 + actual: actual.len(), 131 + expected: expected.len(), 132 + required, 133 + }); 134 + } 135 + let limit = threshold.as_u8(); 136 + let report = actual 137 + .chunks_exact(4) 138 + .zip(expected.chunks_exact(4)) 139 + .zip(0_u32..) 140 + .filter_map(|((a, e), idx)| { 141 + let delta: [u8; 4] = [ 142 + a[0].abs_diff(e[0]), 143 + a[1].abs_diff(e[1]), 144 + a[2].abs_diff(e[2]), 145 + a[3].abs_diff(e[3]), 146 + ]; 147 + let max = delta[0].max(delta[1]).max(delta[2]).max(delta[3]); 148 + (max > limit).then_some((idx, delta)) 149 + }) 150 + .fold(PixelDiffReport::default(), |acc, (idx, delta)| { 151 + let m = PixelMismatch { 152 + x: idx % width, 153 + y: idx / width, 154 + channel_delta: delta, 155 + }; 156 + let worst = match acc.worst { 157 + Some(prev) if prev.max_delta() >= m.max_delta() => Some(prev), 158 + _ => Some(m), 159 + }; 160 + PixelDiffReport { 161 + worst, 162 + over_threshold: acc.over_threshold + 1, 163 + } 164 + }); 165 + Ok(report) 166 + } 167 + 168 + pub fn compare( 169 + actual: &SnapshotFrame, 170 + expected: &[u8], 171 + threshold: PixelDiffThreshold, 172 + ) -> core::result::Result<PixelDiffReport, PixelDiffError> { 173 + Self::compare_bytes(actual.extent(), actual.rgba(), expected, threshold) 174 + } 175 + } 176 + 177 + #[cfg(test)] 178 + mod tests { 179 + use super::*; 180 + use crate::camera::{ViewportExtent, ViewportPx}; 181 + 182 + fn extent(w: u32, h: u32) -> ViewportExtent { 183 + ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h)) 184 + } 185 + 186 + fn run( 187 + extent: ViewportExtent, 188 + actual: &[u8], 189 + expected: &[u8], 190 + threshold: PixelDiffThreshold, 191 + ) -> PixelDiffReport { 192 + let Ok(report) = PixelDiff::compare_bytes(extent, actual, expected, threshold) else { 193 + panic!("compare_bytes rejected inputs"); 194 + }; 195 + report 196 + } 197 + 198 + #[test] 199 + fn exact_match_is_clean() { 200 + let buf = vec![10_u8, 20, 30, 255, 10, 20, 30, 255]; 201 + let report = run(extent(2, 1), &buf, &buf, PixelDiffThreshold::EXACT); 202 + assert!(report.is_clean()); 203 + assert!(report.worst().is_none()); 204 + } 205 + 206 + #[test] 207 + fn single_channel_delta_under_threshold_is_clean() { 208 + let a = vec![10_u8, 20, 30, 255]; 209 + let b = vec![12_u8, 20, 30, 255]; 210 + let report = run(extent(1, 1), &a, &b, PixelDiffThreshold::new(4.0 / 255.0)); 211 + assert!(report.is_clean()); 212 + } 213 + 214 + #[test] 215 + fn over_threshold_reports_worst_pixel_coords() { 216 + let a = vec![0_u8, 0, 0, 255, 200, 0, 0, 255]; 217 + let b = vec![0_u8, 0, 0, 255, 50, 0, 0, 255]; 218 + let report = run(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT); 219 + let Some(worst) = report.worst() else { 220 + panic!("worst pixel present"); 221 + }; 222 + assert_eq!(worst.x(), 1); 223 + assert_eq!(worst.y(), 0); 224 + assert_eq!(worst.max_delta(), 150); 225 + assert_eq!(report.over_threshold(), 1); 226 + } 227 + 228 + #[test] 229 + fn tolerance_maps_to_threshold() { 230 + let t = Tolerance::new(8.0 / 255.0); 231 + let th: PixelDiffThreshold = t.into(); 232 + assert_eq!(th.as_u8(), 8); 233 + } 234 + 235 + #[test] 236 + fn coords_index_by_row_major() { 237 + let a = vec![0_u8; 32]; 238 + let mut b = a.clone(); 239 + let pixel = 5_usize; 240 + b[pixel * 4] = 200; 241 + let report = run(extent(4, 2), &a, &b, PixelDiffThreshold::EXACT); 242 + let Some(worst) = report.worst() else { 243 + panic!("worst present"); 244 + }; 245 + assert_eq!(worst.x(), 1); 246 + assert_eq!(worst.y(), 1); 247 + } 248 + 249 + #[test] 250 + fn shape_mismatch_is_rejected() { 251 + let a = vec![0_u8; 12]; 252 + let b = vec![0_u8; 8]; 253 + let Err(err) = PixelDiff::compare_bytes(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT) 254 + else { 255 + panic!("expected ShapeMismatch"); 256 + }; 257 + let PixelDiffError::ShapeMismatch { 258 + actual, 259 + expected, 260 + required, 261 + } = err 262 + else { 263 + panic!("wrong error variant: {err:?}"); 264 + }; 265 + assert_eq!(actual, 12); 266 + assert_eq!(expected, 8); 267 + assert_eq!(required, 8); 268 + } 269 + 270 + #[test] 271 + fn zero_extent_is_rejected() { 272 + let buf: Vec<u8> = Vec::new(); 273 + let Err(err) = 274 + PixelDiff::compare_bytes(extent(0, 4), &buf, &buf, PixelDiffThreshold::EXACT) 275 + else { 276 + panic!("expected ZeroExtent"); 277 + }; 278 + assert!(matches!(err, PixelDiffError::ZeroExtent)); 279 + } 280 + }
+372
crates/bone-render/src/gpu.rs
··· 1 + use crate::camera::{ViewportExtent, ViewportPx}; 2 + use crate::pick::{PickId, PickIndex, Picker}; 3 + use crate::snapshot::{SnapshotFrame, Style}; 4 + use crate::{RenderError, Result}; 5 + 6 + pub(crate) const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; 7 + pub(crate) const PICK_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint; 8 + pub(crate) const BYTES_PER_PIXEL: u32 = 4; 9 + pub(crate) const PICK_BYTES_PER_PIXEL: u32 = 4; 10 + 11 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 12 + pub struct BackendTag(wgpu::Backend); 13 + 14 + impl BackendTag { 15 + #[cfg(test)] 16 + pub(crate) const fn from_backend(backend: wgpu::Backend) -> Self { 17 + Self(backend) 18 + } 19 + 20 + #[must_use] 21 + pub fn backend(self) -> wgpu::Backend { 22 + self.0 23 + } 24 + } 25 + 26 + impl core::fmt::Display for BackendTag { 27 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 28 + write!(f, "{:?}", self.0) 29 + } 30 + } 31 + 32 + #[derive(Clone, Debug)] 33 + pub struct Capabilities { 34 + adapter_limits: wgpu::Limits, 35 + backend: BackendTag, 36 + adapter_name: String, 37 + } 38 + 39 + impl Capabilities { 40 + pub(crate) fn probe(adapter: &wgpu::Adapter) -> Self { 41 + let info = adapter.get_info(); 42 + Self { 43 + adapter_limits: adapter.limits(), 44 + backend: BackendTag(info.backend), 45 + adapter_name: info.name, 46 + } 47 + } 48 + 49 + #[must_use] 50 + pub fn adapter_limits(&self) -> &wgpu::Limits { 51 + &self.adapter_limits 52 + } 53 + 54 + #[must_use] 55 + pub fn backend(&self) -> BackendTag { 56 + self.backend 57 + } 58 + 59 + #[must_use] 60 + pub fn adapter_name(&self) -> &str { 61 + &self.adapter_name 62 + } 63 + } 64 + 65 + pub struct Gpu { 66 + device: wgpu::Device, 67 + queue: wgpu::Queue, 68 + capabilities: Capabilities, 69 + } 70 + 71 + impl Gpu { 72 + pub(crate) fn from_parts( 73 + device: wgpu::Device, 74 + queue: wgpu::Queue, 75 + capabilities: Capabilities, 76 + ) -> Self { 77 + Self { 78 + device, 79 + queue, 80 + capabilities, 81 + } 82 + } 83 + 84 + #[must_use] 85 + pub fn device(&self) -> &wgpu::Device { 86 + &self.device 87 + } 88 + 89 + #[must_use] 90 + pub fn queue(&self) -> &wgpu::Queue { 91 + &self.queue 92 + } 93 + 94 + #[must_use] 95 + pub fn capabilities(&self) -> &Capabilities { 96 + &self.capabilities 97 + } 98 + } 99 + 100 + pub struct OffscreenContext { 101 + gpu: Gpu, 102 + color: wgpu::Texture, 103 + pick: wgpu::Texture, 104 + pick_staging: wgpu::Buffer, 105 + extent: ViewportExtent, 106 + } 107 + 108 + impl OffscreenContext { 109 + pub async fn new(extent: ViewportExtent) -> Result<Self> { 110 + if extent.width().value() == 0 || extent.height().value() == 0 { 111 + return Err(RenderError::ZeroExtent); 112 + } 113 + let instance = 114 + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); 115 + let adapter = instance 116 + .request_adapter(&wgpu::RequestAdapterOptions { 117 + power_preference: wgpu::PowerPreference::LowPower, 118 + force_fallback_adapter: false, 119 + compatible_surface: None, 120 + }) 121 + .await?; 122 + let (device, queue) = adapter 123 + .request_device(&wgpu::DeviceDescriptor { 124 + label: Some("bone-render:offscreen"), 125 + required_features: wgpu::Features::empty(), 126 + required_limits: wgpu::Limits::downlevel_defaults(), 127 + experimental_features: wgpu::ExperimentalFeatures::default(), 128 + memory_hints: wgpu::MemoryHints::default(), 129 + trace: wgpu::Trace::Off, 130 + }) 131 + .await?; 132 + let capabilities = Capabilities::probe(&adapter); 133 + let color = device.create_texture(&wgpu::TextureDescriptor { 134 + label: Some("bone-render:offscreen-color"), 135 + size: texture_size(extent), 136 + mip_level_count: 1, 137 + sample_count: 1, 138 + dimension: wgpu::TextureDimension::D2, 139 + format: COLOR_FORMAT, 140 + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, 141 + view_formats: &[], 142 + }); 143 + let pick = device.create_texture(&wgpu::TextureDescriptor { 144 + label: Some("bone-render:offscreen-pick"), 145 + size: texture_size(extent), 146 + mip_level_count: 1, 147 + sample_count: 1, 148 + dimension: wgpu::TextureDimension::D2, 149 + format: PICK_FORMAT, 150 + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, 151 + view_formats: &[], 152 + }); 153 + let pick_staging = device.create_buffer(&wgpu::BufferDescriptor { 154 + label: Some("bone-render:pick-readback"), 155 + size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT), 156 + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, 157 + mapped_at_creation: false, 158 + }); 159 + Ok(Self { 160 + gpu: Gpu { 161 + device, 162 + queue, 163 + capabilities, 164 + }, 165 + color, 166 + pick, 167 + pick_staging, 168 + extent, 169 + }) 170 + } 171 + 172 + #[must_use] 173 + pub fn gpu(&self) -> &Gpu { 174 + &self.gpu 175 + } 176 + 177 + #[must_use] 178 + pub fn extent(&self) -> ViewportExtent { 179 + self.extent 180 + } 181 + 182 + #[must_use] 183 + pub const fn color_format(&self) -> wgpu::TextureFormat { 184 + COLOR_FORMAT 185 + } 186 + 187 + #[must_use] 188 + pub fn color_view(&self) -> wgpu::TextureView { 189 + self.color 190 + .create_view(&wgpu::TextureViewDescriptor::default()) 191 + } 192 + 193 + #[must_use] 194 + pub fn pick_view(&self) -> wgpu::TextureView { 195 + self.pick 196 + .create_view(&wgpu::TextureViewDescriptor::default()) 197 + } 198 + 199 + #[must_use] 200 + pub fn picker(&self, index: PickIndex) -> Picker<'_> { 201 + Picker::new( 202 + &self.gpu, 203 + &self.pick, 204 + &self.pick_staging, 205 + self.extent, 206 + index, 207 + ) 208 + } 209 + 210 + pub fn render_clear(&self, style: &Style) -> Result<SnapshotFrame> { 211 + self.render(|encoder, color_view, pick_view| { 212 + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 213 + label: Some("bone-render:clear"), 214 + color_attachments: &[ 215 + Some(wgpu::RenderPassColorAttachment { 216 + view: color_view, 217 + resolve_target: None, 218 + depth_slice: None, 219 + ops: wgpu::Operations { 220 + load: wgpu::LoadOp::Clear(style.background().into()), 221 + store: wgpu::StoreOp::Store, 222 + }, 223 + }), 224 + Some(wgpu::RenderPassColorAttachment { 225 + view: pick_view, 226 + resolve_target: None, 227 + depth_slice: None, 228 + ops: wgpu::Operations { 229 + load: wgpu::LoadOp::Clear(pick_clear_color()), 230 + store: wgpu::StoreOp::Store, 231 + }, 232 + }), 233 + ], 234 + depth_stencil_attachment: None, 235 + timestamp_writes: None, 236 + occlusion_query_set: None, 237 + multiview_mask: None, 238 + }); 239 + }) 240 + } 241 + 242 + pub(crate) fn render<F>(&self, mut build_passes: F) -> Result<SnapshotFrame> 243 + where 244 + F: FnMut(&mut wgpu::CommandEncoder, &wgpu::TextureView, &wgpu::TextureView), 245 + { 246 + let mut encoder = self 247 + .gpu 248 + .device 249 + .create_command_encoder(&wgpu::CommandEncoderDescriptor { 250 + label: Some("bone-render:encoder"), 251 + }); 252 + let color_view = self.color_view(); 253 + let pick_view = self.pick_view(); 254 + build_passes(&mut encoder, &color_view, &pick_view); 255 + let padded_bpr = padded_bytes_per_row(self.extent.width()); 256 + let staging = self.gpu.device.create_buffer(&wgpu::BufferDescriptor { 257 + label: Some("bone-render:readback"), 258 + size: u64::from(padded_bpr) * u64::from(self.extent.height().value()), 259 + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, 260 + mapped_at_creation: false, 261 + }); 262 + encoder.copy_texture_to_buffer( 263 + wgpu::TexelCopyTextureInfo { 264 + texture: &self.color, 265 + mip_level: 0, 266 + origin: wgpu::Origin3d::ZERO, 267 + aspect: wgpu::TextureAspect::All, 268 + }, 269 + wgpu::TexelCopyBufferInfo { 270 + buffer: &staging, 271 + layout: wgpu::TexelCopyBufferLayout { 272 + offset: 0, 273 + bytes_per_row: Some(padded_bpr), 274 + rows_per_image: Some(self.extent.height().value()), 275 + }, 276 + }, 277 + texture_size(self.extent), 278 + ); 279 + self.gpu.queue.submit(Some(encoder.finish())); 280 + let rgba = read_staging(&self.gpu.device, &staging, self.extent, padded_bpr)?; 281 + Ok(SnapshotFrame::new( 282 + self.extent, 283 + rgba, 284 + self.gpu.capabilities.backend(), 285 + )) 286 + } 287 + } 288 + 289 + pub(crate) fn clear_pick_attachment( 290 + encoder: &mut wgpu::CommandEncoder, 291 + pick_view: &wgpu::TextureView, 292 + ) { 293 + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 294 + label: Some("bone-render:pick-clear"), 295 + color_attachments: &[Some(wgpu::RenderPassColorAttachment { 296 + view: pick_view, 297 + resolve_target: None, 298 + depth_slice: None, 299 + ops: wgpu::Operations { 300 + load: wgpu::LoadOp::Clear(pick_clear_color()), 301 + store: wgpu::StoreOp::Store, 302 + }, 303 + })], 304 + depth_stencil_attachment: None, 305 + timestamp_writes: None, 306 + occlusion_query_set: None, 307 + multiview_mask: None, 308 + }); 309 + } 310 + 311 + fn pick_clear_color() -> wgpu::Color { 312 + wgpu::Color { 313 + r: f64::from(PickId::NONE.raw()), 314 + g: 0.0, 315 + b: 0.0, 316 + a: 0.0, 317 + } 318 + } 319 + 320 + fn texture_size(extent: ViewportExtent) -> wgpu::Extent3d { 321 + wgpu::Extent3d { 322 + width: extent.width().value(), 323 + height: extent.height().value(), 324 + depth_or_array_layers: 1, 325 + } 326 + } 327 + 328 + pub(crate) fn padded_bytes_per_row(width: ViewportPx) -> u32 { 329 + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; 330 + let w = width.value(); 331 + debug_assert!( 332 + w <= u32::MAX / BYTES_PER_PIXEL, 333 + "viewport width too large for padded row calc" 334 + ); 335 + let raw = w * BYTES_PER_PIXEL; 336 + raw.div_ceil(align) * align 337 + } 338 + 339 + fn read_staging( 340 + device: &wgpu::Device, 341 + buffer: &wgpu::Buffer, 342 + extent: ViewportExtent, 343 + padded_bpr: u32, 344 + ) -> Result<Vec<u8>> { 345 + let slice = buffer.slice(..); 346 + let (tx, rx) = 347 + std::sync::mpsc::sync_channel::<core::result::Result<(), wgpu::BufferAsyncError>>(1); 348 + slice.map_async(wgpu::MapMode::Read, move |res| { 349 + let _ = tx.send(res); 350 + }); 351 + device 352 + .poll(wgpu::PollType::wait_indefinitely()) 353 + .map_err(RenderError::Poll)?; 354 + match rx.try_recv() { 355 + Ok(Ok(())) => {} 356 + Ok(Err(e)) => return Err(RenderError::Map(e)), 357 + Err(_) => return Err(RenderError::MapMissing), 358 + } 359 + let row_bytes = extent.width().value() * BYTES_PER_PIXEL; 360 + let rgba: Vec<u8> = { 361 + let view = slice.get_mapped_range(); 362 + (0..extent.height().value()) 363 + .flat_map(|y| { 364 + let start = (y * padded_bpr) as usize; 365 + let end = start + row_bytes as usize; 366 + view[start..end].iter().copied() 367 + }) 368 + .collect() 369 + }; 370 + buffer.unmap(); 371 + Ok(rgba) 372 + }
+105
crates/bone-render/src/lib.rs
··· 1 + pub mod camera; 2 + pub mod diff; 3 + pub mod gpu; 4 + pub mod pick; 5 + pub mod pipelines; 6 + pub mod scene; 7 + pub mod snapshot; 8 + pub mod surface; 9 + 10 + pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx}; 11 + pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 12 + pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; 13 + pub use pick::{EntityKindTag, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker}; 14 + pub use pipelines::{ArcPipeline, GridPipeline, LinesPipeline}; 15 + pub use scene::{SceneArc, SceneCircle, SceneLine, ScenePoint, SketchScene}; 16 + pub use snapshot::{ 17 + ClearColor, GridStyle, SnapshotFrame, StrokeStyle, Style, decode_png, encode_png, 18 + }; 19 + pub use surface::{SurfaceContext, SurfaceError}; 20 + 21 + #[derive(Debug, thiserror::Error)] 22 + pub enum RenderError { 23 + #[error("no wgpu adapter matched the offscreen request: {0}")] 24 + NoAdapter(#[from] wgpu::RequestAdapterError), 25 + #[error("wgpu device request failed: {0}")] 26 + Device(#[from] wgpu::RequestDeviceError), 27 + #[error("wgpu poll failed: {0}")] 28 + Poll(wgpu::PollError), 29 + #[error("wgpu buffer map failed: {0}")] 30 + Map(wgpu::BufferAsyncError), 31 + #[error("wgpu buffer map callback did not fire after poll")] 32 + MapMissing, 33 + #[error("png encode failed: {0}")] 34 + PngEncode(#[from] png::EncodingError), 35 + #[error("png decode failed: {0}")] 36 + PngDecode(#[from] png::DecodingError), 37 + #[error("png format unsupported: color={color_type:?}, depth={bit_depth:?}, require rgba8")] 38 + PngFormat { 39 + color_type: png::ColorType, 40 + bit_depth: png::BitDepth, 41 + }, 42 + #[error("viewport dimension is zero")] 43 + ZeroExtent, 44 + #[error("pick id construction failed: {0}")] 45 + PickId(#[from] PickIdError), 46 + #[error("pick query {query} outside viewport {extent}")] 47 + PickOutOfBounds { 48 + query: PickQuery, 49 + extent: ViewportExtent, 50 + }, 51 + } 52 + 53 + pub type Result<T, E = RenderError> = core::result::Result<T, E>; 54 + 55 + #[derive(Debug)] 56 + pub struct SketchRenderer { 57 + grid: GridPipeline, 58 + arcs: ArcPipeline, 59 + lines: LinesPipeline, 60 + } 61 + 62 + impl SketchRenderer { 63 + #[must_use] 64 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 65 + Self { 66 + grid: GridPipeline::new(gpu, color_format), 67 + arcs: ArcPipeline::new(gpu, color_format), 68 + lines: LinesPipeline::new(gpu, color_format), 69 + } 70 + } 71 + 72 + pub fn encode_passes( 73 + &self, 74 + encoder: &mut wgpu::CommandEncoder, 75 + color_view: &wgpu::TextureView, 76 + pick_view: &wgpu::TextureView, 77 + scene: &SketchScene, 78 + camera: Camera2, 79 + style: &Style, 80 + ) { 81 + self.grid.draw(encoder, color_view, camera, style); 82 + gpu::clear_pick_attachment(encoder, pick_view); 83 + self.arcs 84 + .draw(encoder, color_view, pick_view, camera, style, scene); 85 + self.lines 86 + .draw(encoder, color_view, pick_view, camera, style, scene); 87 + } 88 + 89 + pub fn render( 90 + &self, 91 + ctx: &OffscreenContext, 92 + scene: &SketchScene, 93 + camera: Camera2, 94 + style: &Style, 95 + ) -> Result<SnapshotFrame> { 96 + debug_assert_eq!( 97 + camera.extent(), 98 + ctx.extent(), 99 + "camera extent must match offscreen context extent", 100 + ); 101 + ctx.render(|encoder, color_view, pick_view| { 102 + self.encode_passes(encoder, color_view, pick_view, scene, camera, style); 103 + }) 104 + } 105 + }
+670
crates/bone-render/src/pick.rs
··· 1 + use bone_types::{SketchDimensionId, SketchEntityId, SketchRelationId}; 2 + use slotmap::Key; 3 + use std::collections::HashMap; 4 + use std::collections::hash_map::Entry; 5 + 6 + use crate::camera::{ViewportExtent, ViewportPx}; 7 + use crate::gpu::{Gpu, PICK_BYTES_PER_PIXEL}; 8 + use crate::{RenderError, Result}; 9 + 10 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 11 + #[repr(u8)] 12 + pub enum EntityKindTag { 13 + Point = 0, 14 + Line = 1, 15 + Arc = 2, 16 + Circle = 3, 17 + Relation = 4, 18 + Dimension = 5, 19 + } 20 + 21 + impl EntityKindTag { 22 + #[must_use] 23 + pub const fn bits(self) -> u8 { 24 + self as u8 25 + } 26 + 27 + #[must_use] 28 + pub const fn from_bits(bits: u8) -> Option<Self> { 29 + match bits { 30 + 0 => Some(Self::Point), 31 + 1 => Some(Self::Line), 32 + 2 => Some(Self::Arc), 33 + 3 => Some(Self::Circle), 34 + 4 => Some(Self::Relation), 35 + 5 => Some(Self::Dimension), 36 + _ => None, 37 + } 38 + } 39 + } 40 + 41 + impl core::fmt::Display for EntityKindTag { 42 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 43 + let name = match self { 44 + Self::Point => "point", 45 + Self::Line => "line", 46 + Self::Arc => "arc", 47 + Self::Circle => "circle", 48 + Self::Relation => "relation", 49 + Self::Dimension => "dimension", 50 + }; 51 + f.write_str(name) 52 + } 53 + } 54 + 55 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 56 + pub enum PickedItem { 57 + Point(SketchEntityId), 58 + Line(SketchEntityId), 59 + Arc(SketchEntityId), 60 + Circle(SketchEntityId), 61 + Relation(SketchRelationId), 62 + Dimension(SketchDimensionId), 63 + } 64 + 65 + impl PickedItem { 66 + #[must_use] 67 + pub const fn tag(self) -> EntityKindTag { 68 + match self { 69 + Self::Point(_) => EntityKindTag::Point, 70 + Self::Line(_) => EntityKindTag::Line, 71 + Self::Arc(_) => EntityKindTag::Arc, 72 + Self::Circle(_) => EntityKindTag::Circle, 73 + Self::Relation(_) => EntityKindTag::Relation, 74 + Self::Dimension(_) => EntityKindTag::Dimension, 75 + } 76 + } 77 + } 78 + 79 + #[derive(Debug, thiserror::Error)] 80 + pub enum PickIdError { 81 + #[error("slotmap slot {slot:#x} exceeds {limit:#x}, the 28-bit PickId index field")] 82 + SlotIndexOverflow { slot: u32, limit: u32 }, 83 + #[error("two entries collapse to packed slot index {0:#x} in the PickIndex")] 84 + SlotIndexCollision(u32), 85 + } 86 + 87 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 88 + pub struct PickId(u32); 89 + 90 + impl PickId { 91 + pub const TAG_SHIFT: u32 = 28; 92 + pub const INDEX_MASK: u32 = 0x0FFF_FFFF; 93 + pub const TAG_MASK: u32 = 0xF000_0000; 94 + pub const NONE: Self = Self(u32::MAX); 95 + 96 + #[must_use] 97 + pub const fn from_raw(bits: u32) -> Self { 98 + Self(bits) 99 + } 100 + 101 + #[must_use] 102 + pub const fn raw(self) -> u32 { 103 + self.0 104 + } 105 + 106 + #[must_use] 107 + #[allow(clippy::cast_possible_truncation)] 108 + pub const fn tag(self) -> Option<EntityKindTag> { 109 + EntityKindTag::from_bits((self.0 >> Self::TAG_SHIFT) as u8) 110 + } 111 + 112 + #[must_use] 113 + pub const fn index(self) -> u32 { 114 + self.0 & Self::INDEX_MASK 115 + } 116 + 117 + pub fn point(id: SketchEntityId) -> Result<Self, PickIdError> { 118 + Self::from_keyed(EntityKindTag::Point, id) 119 + } 120 + 121 + pub fn line(id: SketchEntityId) -> Result<Self, PickIdError> { 122 + Self::from_keyed(EntityKindTag::Line, id) 123 + } 124 + 125 + pub fn arc(id: SketchEntityId) -> Result<Self, PickIdError> { 126 + Self::from_keyed(EntityKindTag::Arc, id) 127 + } 128 + 129 + pub fn circle(id: SketchEntityId) -> Result<Self, PickIdError> { 130 + Self::from_keyed(EntityKindTag::Circle, id) 131 + } 132 + 133 + pub fn relation(id: SketchRelationId) -> Result<Self, PickIdError> { 134 + Self::from_keyed(EntityKindTag::Relation, id) 135 + } 136 + 137 + pub fn dimension(id: SketchDimensionId) -> Result<Self, PickIdError> { 138 + Self::from_keyed(EntityKindTag::Dimension, id) 139 + } 140 + 141 + #[must_use] 142 + pub fn unpack(self, index: &PickIndex) -> Option<PickedItem> { 143 + let tag = self.tag()?; 144 + let slot = self.index(); 145 + match tag { 146 + EntityKindTag::Point => index.lookup_entity(slot, tag).map(PickedItem::Point), 147 + EntityKindTag::Line => index.lookup_entity(slot, tag).map(PickedItem::Line), 148 + EntityKindTag::Arc => index.lookup_entity(slot, tag).map(PickedItem::Arc), 149 + EntityKindTag::Circle => index.lookup_entity(slot, tag).map(PickedItem::Circle), 150 + EntityKindTag::Relation => index 151 + .relations 152 + .get(&slot) 153 + .copied() 154 + .map(PickedItem::Relation), 155 + EntityKindTag::Dimension => index 156 + .dimensions 157 + .get(&slot) 158 + .copied() 159 + .map(PickedItem::Dimension), 160 + } 161 + } 162 + 163 + fn from_keyed<K: Key>(tag: EntityKindTag, id: K) -> Result<Self, PickIdError> { 164 + slot_index(id).map(|idx| Self::pack(tag, idx)) 165 + } 166 + 167 + const fn pack(tag: EntityKindTag, idx: u32) -> Self { 168 + debug_assert!(idx <= Self::INDEX_MASK); 169 + Self(((tag.bits() as u32) << Self::TAG_SHIFT) | idx) 170 + } 171 + } 172 + 173 + const _: () = assert!(PickId::TAG_MASK | PickId::INDEX_MASK == u32::MAX); 174 + const _: () = assert!(PickId::TAG_MASK & PickId::INDEX_MASK == 0); 175 + const _: () = assert!(PickId::TAG_MASK == 0xFu32 << PickId::TAG_SHIFT); 176 + const _: () = assert!(PickId::NONE.tag().is_none()); 177 + 178 + impl core::fmt::Display for PickId { 179 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 180 + match self.tag() { 181 + Some(tag) => write!(f, "{tag}#{}", self.index()), 182 + None => write!(f, "invalid#{:08x}", self.0), 183 + } 184 + } 185 + } 186 + 187 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 188 + pub struct PickQuery { 189 + x: ViewportPx, 190 + y: ViewportPx, 191 + } 192 + 193 + impl PickQuery { 194 + #[must_use] 195 + pub const fn new(x: ViewportPx, y: ViewportPx) -> Self { 196 + Self { x, y } 197 + } 198 + 199 + #[must_use] 200 + pub const fn x(self) -> ViewportPx { 201 + self.x 202 + } 203 + 204 + #[must_use] 205 + pub const fn y(self) -> ViewportPx { 206 + self.y 207 + } 208 + } 209 + 210 + impl core::fmt::Display for PickQuery { 211 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 212 + write!(f, "({}, {})", self.x, self.y) 213 + } 214 + } 215 + 216 + pub struct Picker<'a> { 217 + gpu: &'a Gpu, 218 + pick: &'a wgpu::Texture, 219 + staging: &'a wgpu::Buffer, 220 + extent: ViewportExtent, 221 + index: PickIndex, 222 + } 223 + 224 + impl<'a> Picker<'a> { 225 + pub(crate) fn new( 226 + gpu: &'a Gpu, 227 + pick: &'a wgpu::Texture, 228 + staging: &'a wgpu::Buffer, 229 + extent: ViewportExtent, 230 + index: PickIndex, 231 + ) -> Self { 232 + Self { 233 + gpu, 234 + pick, 235 + staging, 236 + extent, 237 + index, 238 + } 239 + } 240 + 241 + pub fn raw_at(&self, query: PickQuery) -> Result<PickId> { 242 + let width = self.extent.width().value(); 243 + let height = self.extent.height().value(); 244 + if query.x.value() >= width || query.y.value() >= height { 245 + return Err(RenderError::PickOutOfBounds { 246 + query, 247 + extent: self.extent, 248 + }); 249 + } 250 + let device = self.gpu.device(); 251 + let queue = self.gpu.queue(); 252 + let padded_bpr = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; 253 + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { 254 + label: Some("bone-render:pick-encoder"), 255 + }); 256 + encoder.copy_texture_to_buffer( 257 + wgpu::TexelCopyTextureInfo { 258 + texture: self.pick, 259 + mip_level: 0, 260 + origin: wgpu::Origin3d { 261 + x: query.x.value(), 262 + y: query.y.value(), 263 + z: 0, 264 + }, 265 + aspect: wgpu::TextureAspect::All, 266 + }, 267 + wgpu::TexelCopyBufferInfo { 268 + buffer: self.staging, 269 + layout: wgpu::TexelCopyBufferLayout { 270 + offset: 0, 271 + bytes_per_row: Some(padded_bpr), 272 + rows_per_image: Some(1), 273 + }, 274 + }, 275 + wgpu::Extent3d { 276 + width: 1, 277 + height: 1, 278 + depth_or_array_layers: 1, 279 + }, 280 + ); 281 + queue.submit(Some(encoder.finish())); 282 + 283 + let slice = self.staging.slice(..); 284 + let (tx, rx) = 285 + std::sync::mpsc::sync_channel::<core::result::Result<(), wgpu::BufferAsyncError>>(1); 286 + slice.map_async(wgpu::MapMode::Read, move |res| { 287 + let _ = tx.send(res); 288 + }); 289 + device 290 + .poll(wgpu::PollType::wait_indefinitely()) 291 + .map_err(RenderError::Poll)?; 292 + match rx.try_recv() { 293 + Ok(Ok(())) => {} 294 + Ok(Err(e)) => return Err(RenderError::Map(e)), 295 + Err(_) => return Err(RenderError::MapMissing), 296 + } 297 + let raw = { 298 + let view = slice.get_mapped_range(); 299 + debug_assert!(view.len() >= PICK_BYTES_PER_PIXEL as usize); 300 + u32::from_le_bytes([view[0], view[1], view[2], view[3]]) 301 + }; 302 + self.staging.unmap(); 303 + Ok(PickId::from_raw(raw)) 304 + } 305 + 306 + pub fn at(&self, query: PickQuery) -> Result<Option<PickedItem>> { 307 + let pid = self.raw_at(query)?; 308 + Ok(pid.unpack(&self.index)) 309 + } 310 + } 311 + 312 + impl core::fmt::Debug for Picker<'_> { 313 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 314 + f.debug_struct("Picker") 315 + .field("extent", &self.extent) 316 + .finish_non_exhaustive() 317 + } 318 + } 319 + 320 + #[derive(Debug, Default, Clone)] 321 + pub struct PickIndex { 322 + entities: HashMap<u32, (SketchEntityId, EntityKindTag)>, 323 + relations: HashMap<u32, SketchRelationId>, 324 + dimensions: HashMap<u32, SketchDimensionId>, 325 + } 326 + 327 + impl PickIndex { 328 + pub fn build<EI, RI, DI>( 329 + entities: EI, 330 + relations: RI, 331 + dimensions: DI, 332 + ) -> Result<Self, PickIdError> 333 + where 334 + EI: IntoIterator<Item = (SketchEntityId, EntityKindTag)>, 335 + RI: IntoIterator<Item = SketchRelationId>, 336 + DI: IntoIterator<Item = SketchDimensionId>, 337 + { 338 + Ok(Self { 339 + entities: try_collect_keyed(entities, |(id, _)| slot_index(*id))?, 340 + relations: try_collect_keyed(relations, |id| slot_index(*id))?, 341 + dimensions: try_collect_keyed(dimensions, |id| slot_index(*id))?, 342 + }) 343 + } 344 + 345 + fn lookup_entity(&self, slot: u32, expected: EntityKindTag) -> Option<SketchEntityId> { 346 + self.entities 347 + .get(&slot) 348 + .filter(|(_, kind)| *kind == expected) 349 + .map(|(id, _)| *id) 350 + } 351 + } 352 + 353 + #[allow(clippy::cast_possible_truncation)] 354 + fn slot_index<K: Key>(key: K) -> Result<u32, PickIdError> { 355 + let slot = (key.data().as_ffi() & 0xFFFF_FFFF_u64) as u32; 356 + if slot > PickId::INDEX_MASK { 357 + return Err(PickIdError::SlotIndexOverflow { 358 + slot, 359 + limit: PickId::INDEX_MASK, 360 + }); 361 + } 362 + Ok(slot) 363 + } 364 + 365 + fn try_collect_keyed<I, V, F>(iter: I, slot_of: F) -> Result<HashMap<u32, V>, PickIdError> 366 + where 367 + I: IntoIterator<Item = V>, 368 + F: Fn(&V) -> Result<u32, PickIdError>, 369 + { 370 + iter.into_iter().try_fold(HashMap::new(), |mut acc, item| { 371 + let slot = slot_of(&item)?; 372 + match acc.entry(slot) { 373 + Entry::Vacant(v) => { 374 + v.insert(item); 375 + Ok(acc) 376 + } 377 + Entry::Occupied(_) => Err(PickIdError::SlotIndexCollision(slot)), 378 + } 379 + }) 380 + } 381 + 382 + #[cfg(test)] 383 + mod tests { 384 + use super::*; 385 + use proptest::prelude::*; 386 + use slotmap::{KeyData, SlotMap}; 387 + 388 + #[derive(Copy, Clone, Debug)] 389 + enum TagKind { 390 + Point, 391 + Line, 392 + Arc, 393 + Circle, 394 + Relation, 395 + Dimension, 396 + } 397 + 398 + impl TagKind { 399 + fn entity_kind(self) -> EntityKindTag { 400 + match self { 401 + Self::Point | Self::Relation | Self::Dimension => EntityKindTag::Point, 402 + Self::Line => EntityKindTag::Line, 403 + Self::Arc => EntityKindTag::Arc, 404 + Self::Circle => EntityKindTag::Circle, 405 + } 406 + } 407 + } 408 + 409 + fn arb_tag() -> impl Strategy<Value = TagKind> { 410 + prop_oneof![ 411 + Just(TagKind::Point), 412 + Just(TagKind::Line), 413 + Just(TagKind::Arc), 414 + Just(TagKind::Circle), 415 + Just(TagKind::Relation), 416 + Just(TagKind::Dimension), 417 + ] 418 + } 419 + 420 + type TestSlots = ( 421 + SlotMap<SketchEntityId, EntityKindTag>, 422 + SlotMap<SketchRelationId, ()>, 423 + SlotMap<SketchDimensionId, ()>, 424 + ); 425 + 426 + fn populate_uniform( 427 + n: usize, 428 + entity_kind: EntityKindTag, 429 + ) -> ( 430 + Vec<SketchEntityId>, 431 + Vec<SketchRelationId>, 432 + Vec<SketchDimensionId>, 433 + TestSlots, 434 + ) { 435 + let mut entities = SlotMap::with_key(); 436 + let mut relations = SlotMap::with_key(); 437 + let mut dimensions = SlotMap::with_key(); 438 + let e: Vec<_> = (0..n).map(|_| entities.insert(entity_kind)).collect(); 439 + let r: Vec<_> = (0..n).map(|_| relations.insert(())).collect(); 440 + let d: Vec<_> = (0..n).map(|_| dimensions.insert(())).collect(); 441 + (e, r, d, (entities, relations, dimensions)) 442 + } 443 + 444 + fn ok<T>(result: Result<T, PickIdError>) -> T { 445 + let Ok(value) = result else { 446 + panic!("test fixture should fit in PickId field"); 447 + }; 448 + value 449 + } 450 + 451 + fn build_index(slots: &TestSlots) -> PickIndex { 452 + ok(PickIndex::build( 453 + slots.0.iter().map(|(k, kind)| (k, *kind)), 454 + slots.1.keys(), 455 + slots.2.keys(), 456 + )) 457 + } 458 + 459 + fn build_case( 460 + tag: TagKind, 461 + e: &[SketchEntityId], 462 + r: &[SketchRelationId], 463 + d: &[SketchDimensionId], 464 + target: usize, 465 + ) -> (PickId, PickedItem) { 466 + match tag { 467 + TagKind::Point => (ok(PickId::point(e[target])), PickedItem::Point(e[target])), 468 + TagKind::Line => (ok(PickId::line(e[target])), PickedItem::Line(e[target])), 469 + TagKind::Arc => (ok(PickId::arc(e[target])), PickedItem::Arc(e[target])), 470 + TagKind::Circle => (ok(PickId::circle(e[target])), PickedItem::Circle(e[target])), 471 + TagKind::Relation => ( 472 + ok(PickId::relation(r[target])), 473 + PickedItem::Relation(r[target]), 474 + ), 475 + TagKind::Dimension => ( 476 + ok(PickId::dimension(d[target])), 477 + PickedItem::Dimension(d[target]), 478 + ), 479 + } 480 + } 481 + 482 + fn forget(slots: &mut TestSlots, item: PickedItem) { 483 + match item { 484 + PickedItem::Point(k) 485 + | PickedItem::Line(k) 486 + | PickedItem::Arc(k) 487 + | PickedItem::Circle(k) => { 488 + slots.0.remove(k); 489 + } 490 + PickedItem::Relation(k) => { 491 + slots.1.remove(k); 492 + } 493 + PickedItem::Dimension(k) => { 494 + slots.2.remove(k); 495 + } 496 + } 497 + } 498 + 499 + #[test] 500 + fn tag_bits_roundtrip() { 501 + (0u8..6).for_each(|bits| { 502 + let Some(tag) = EntityKindTag::from_bits(bits) else { 503 + panic!("tag {bits} should decode"); 504 + }; 505 + assert_eq!(tag.bits(), bits); 506 + }); 507 + } 508 + 509 + #[test] 510 + fn invalid_tag_bits_return_none() { 511 + (6u8..16).for_each(|bits| { 512 + assert!(EntityKindTag::from_bits(bits).is_none()); 513 + }); 514 + } 515 + 516 + #[test] 517 + fn raw_roundtrip() { 518 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 519 + let k = entities.insert(()); 520 + let pid = ok(PickId::point(k)); 521 + assert_eq!(PickId::from_raw(pid.raw()), pid); 522 + } 523 + 524 + #[test] 525 + fn tag_and_index_split_cleanly() { 526 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 527 + let k = entities.insert(()); 528 + let pid = ok(PickId::line(k)); 529 + assert_eq!(pid.tag(), Some(EntityKindTag::Line)); 530 + assert_eq!(pid.raw() & PickId::TAG_MASK, 1u32 << PickId::TAG_SHIFT); 531 + assert_eq!(pid.index(), pid.raw() & PickId::INDEX_MASK); 532 + } 533 + 534 + #[test] 535 + fn display_renders_tag_and_index() { 536 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 537 + let k = entities.insert(()); 538 + let pid = ok(PickId::arc(k)); 539 + assert!(format!("{pid}").starts_with("arc#")); 540 + } 541 + 542 + #[test] 543 + fn display_renders_invalid_tag() { 544 + let raw = 0xF000_0000u32; 545 + let pid = PickId::from_raw(raw); 546 + assert_eq!(pid.tag(), None); 547 + assert_eq!(format!("{pid}"), "invalid#f0000000"); 548 + } 549 + 550 + #[test] 551 + fn max_index_roundtrip() { 552 + let raw = 553 + (u32::from(EntityKindTag::Point.bits()) << PickId::TAG_SHIFT) | PickId::INDEX_MASK; 554 + let pid = PickId::from_raw(raw); 555 + assert_eq!(pid.tag(), Some(EntityKindTag::Point)); 556 + assert_eq!(pid.index(), PickId::INDEX_MASK); 557 + assert_eq!(pid.raw(), raw); 558 + } 559 + 560 + #[test] 561 + fn slot_overflow_is_rejected_by_constructor() { 562 + let bad_slot: u32 = PickId::INDEX_MASK + 1; 563 + let key = SketchEntityId::from(KeyData::from_ffi((1u64 << 32) | u64::from(bad_slot))); 564 + let result = PickId::point(key); 565 + assert!(matches!( 566 + result, 567 + Err(PickIdError::SlotIndexOverflow { slot, limit }) 568 + if slot == bad_slot && limit == PickId::INDEX_MASK 569 + )); 570 + } 571 + 572 + #[test] 573 + fn slot_overflow_is_rejected_by_pick_index_build() { 574 + let bad_slot: u32 = PickId::INDEX_MASK + 1; 575 + let key = SketchEntityId::from(KeyData::from_ffi((1u64 << 32) | u64::from(bad_slot))); 576 + let result = PickIndex::build( 577 + [(key, EntityKindTag::Point)], 578 + std::iter::empty::<SketchRelationId>(), 579 + std::iter::empty::<SketchDimensionId>(), 580 + ); 581 + assert!(matches!(result, Err(PickIdError::SlotIndexOverflow { .. }))); 582 + } 583 + 584 + #[test] 585 + fn none_sentinel_is_invalid() { 586 + assert_eq!(PickId::NONE.raw(), u32::MAX); 587 + assert_eq!(PickId::NONE.tag(), None); 588 + assert_eq!(PickId::NONE.unpack(&PickIndex::default()), None); 589 + } 590 + 591 + #[test] 592 + fn collision_in_pick_index_build_is_rejected() { 593 + let mut entities: SlotMap<SketchEntityId, EntityKindTag> = SlotMap::with_key(); 594 + let k = entities.insert(EntityKindTag::Point); 595 + let result = PickIndex::build( 596 + [(k, EntityKindTag::Point), (k, EntityKindTag::Line)], 597 + std::iter::empty::<SketchRelationId>(), 598 + std::iter::empty::<SketchDimensionId>(), 599 + ); 600 + assert!(matches!(result, Err(PickIdError::SlotIndexCollision(_)))); 601 + } 602 + 603 + fn build_entity_index(entities: &SlotMap<SketchEntityId, EntityKindTag>) -> PickIndex { 604 + ok(PickIndex::build( 605 + entities.iter().map(|(k, kind)| (k, *kind)), 606 + std::iter::empty::<SketchRelationId>(), 607 + std::iter::empty::<SketchDimensionId>(), 608 + )) 609 + } 610 + 611 + #[test] 612 + fn slot_reuse_with_same_kind_returns_new_key() { 613 + let mut entities: SlotMap<SketchEntityId, EntityKindTag> = SlotMap::with_key(); 614 + let k1 = entities.insert(EntityKindTag::Point); 615 + let pid = ok(PickId::point(k1)); 616 + entities.remove(k1); 617 + let k2 = entities.insert(EntityKindTag::Point); 618 + assert_ne!(k1, k2); 619 + let index = build_entity_index(&entities); 620 + assert_eq!(pid.unpack(&index), Some(PickedItem::Point(k2))); 621 + } 622 + 623 + #[test] 624 + fn slot_reuse_with_different_kind_returns_none() { 625 + let mut entities: SlotMap<SketchEntityId, EntityKindTag> = SlotMap::with_key(); 626 + let k1 = entities.insert(EntityKindTag::Point); 627 + let pid = ok(PickId::point(k1)); 628 + entities.remove(k1); 629 + let _k2 = entities.insert(EntityKindTag::Line); 630 + let index = build_entity_index(&entities); 631 + assert_eq!(pid.unpack(&index), None); 632 + } 633 + 634 + proptest! { 635 + #[test] 636 + fn unpack_roundtrip_across_all_populated( 637 + tag in arb_tag(), 638 + n in 1usize..32, 639 + target in 0usize..32, 640 + ) { 641 + let target = target % n; 642 + let (e, r, d, slots) = populate_uniform(n, tag.entity_kind()); 643 + let (pid, expected) = build_case(tag, &e, &r, &d, target); 644 + let index = build_index(&slots); 645 + prop_assert_eq!(pid.unpack(&index), Some(expected)); 646 + prop_assert_eq!(pid.tag(), Some(expected.tag())); 647 + } 648 + 649 + #[test] 650 + fn removed_slot_unpacks_to_none( 651 + tag in arb_tag(), 652 + n in 1usize..32, 653 + target in 0usize..32, 654 + ) { 655 + let target = target % n; 656 + let (e, r, d, mut slots) = populate_uniform(n, tag.entity_kind()); 657 + let (pid, item) = build_case(tag, &e, &r, &d, target); 658 + forget(&mut slots, item); 659 + let index = build_index(&slots); 660 + prop_assert!(pid.unpack(&index).is_none()); 661 + } 662 + 663 + #[test] 664 + fn invalid_tag_bits_unpack_to_none(bits in 6u8..16, idx in 0u32..=PickId::INDEX_MASK) { 665 + let raw = (u32::from(bits) << PickId::TAG_SHIFT) | (idx & PickId::INDEX_MASK); 666 + let pid = PickId::from_raw(raw); 667 + prop_assert!(pid.unpack(&PickIndex::default()).is_none()); 668 + } 669 + } 670 + }
+317
crates/bone-render/src/pipelines/arc.rs
··· 1 + use bone_types::{Angle, Length, Point2}; 2 + use uom::si::angle::radian; 3 + use uom::si::length::millimeter; 4 + use wgpu::util::DeviceExt; 5 + 6 + use crate::camera::Camera2; 7 + use crate::gpu::{Gpu, PICK_FORMAT}; 8 + use crate::pipelines::{CONSTRUCTION_BIT, FRAME_UNIFORM_SIZE, build_frame_uniform}; 9 + use crate::scene::{SceneArc, SceneCircle, SketchScene}; 10 + use crate::snapshot::Style; 11 + 12 + #[repr(C)] 13 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 14 + struct ArcInstance { 15 + center: [f32; 2], 16 + radius_mm: f32, 17 + half_width_px: f32, 18 + start_rad: f32, 19 + sweep_rad: f32, 20 + aabb_min_mm: [f32; 2], 21 + aabb_max_mm: [f32; 2], 22 + pick_id: u32, 23 + style_bits: u32, 24 + } 25 + 26 + const INSTANCE_STRIDE: u64 = core::mem::size_of::<ArcInstance>() as u64; 27 + 28 + pub struct ArcPipeline { 29 + device: wgpu::Device, 30 + queue: wgpu::Queue, 31 + pipeline: wgpu::RenderPipeline, 32 + uniform_buffer: wgpu::Buffer, 33 + bind_group: wgpu::BindGroup, 34 + } 35 + 36 + impl ArcPipeline { 37 + #[must_use] 38 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 39 + let device = gpu.device().clone(); 40 + let queue = gpu.queue().clone(); 41 + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 42 + label: Some("bone-render:arc-shader"), 43 + source: wgpu::ShaderSource::Wgsl(include_str!("arc.wgsl").into()), 44 + }); 45 + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 46 + label: Some("bone-render:arc-bgl"), 47 + entries: &[wgpu::BindGroupLayoutEntry { 48 + binding: 0, 49 + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 50 + ty: wgpu::BindingType::Buffer { 51 + ty: wgpu::BufferBindingType::Uniform, 52 + has_dynamic_offset: false, 53 + min_binding_size: wgpu::BufferSize::new(FRAME_UNIFORM_SIZE), 54 + }, 55 + count: None, 56 + }], 57 + }); 58 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 59 + label: Some("bone-render:arc-layout"), 60 + bind_group_layouts: &[Some(&bind_group_layout)], 61 + immediate_size: 0, 62 + }); 63 + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 64 + label: Some("bone-render:arc-pipeline"), 65 + layout: Some(&pipeline_layout), 66 + vertex: wgpu::VertexState { 67 + module: &shader, 68 + entry_point: Some("vs"), 69 + compilation_options: wgpu::PipelineCompilationOptions::default(), 70 + buffers: &[wgpu::VertexBufferLayout { 71 + array_stride: INSTANCE_STRIDE, 72 + step_mode: wgpu::VertexStepMode::Instance, 73 + attributes: &INSTANCE_ATTRS, 74 + }], 75 + }, 76 + fragment: Some(wgpu::FragmentState { 77 + module: &shader, 78 + entry_point: Some("fs"), 79 + compilation_options: wgpu::PipelineCompilationOptions::default(), 80 + targets: &[ 81 + Some(wgpu::ColorTargetState { 82 + format: color_format, 83 + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 84 + write_mask: wgpu::ColorWrites::ALL, 85 + }), 86 + Some(wgpu::ColorTargetState { 87 + format: PICK_FORMAT, 88 + blend: None, 89 + write_mask: wgpu::ColorWrites::ALL, 90 + }), 91 + ], 92 + }), 93 + primitive: wgpu::PrimitiveState { 94 + topology: wgpu::PrimitiveTopology::TriangleList, 95 + strip_index_format: None, 96 + front_face: wgpu::FrontFace::Ccw, 97 + cull_mode: None, 98 + polygon_mode: wgpu::PolygonMode::Fill, 99 + conservative: false, 100 + unclipped_depth: false, 101 + }, 102 + depth_stencil: None, 103 + multisample: wgpu::MultisampleState::default(), 104 + multiview_mask: None, 105 + cache: None, 106 + }); 107 + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 108 + label: Some("bone-render:arc-uniform"), 109 + size: FRAME_UNIFORM_SIZE, 110 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 111 + mapped_at_creation: false, 112 + }); 113 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 114 + label: Some("bone-render:arc-bg"), 115 + layout: &bind_group_layout, 116 + entries: &[wgpu::BindGroupEntry { 117 + binding: 0, 118 + resource: uniform_buffer.as_entire_binding(), 119 + }], 120 + }); 121 + Self { 122 + device, 123 + queue, 124 + pipeline, 125 + uniform_buffer, 126 + bind_group, 127 + } 128 + } 129 + 130 + pub fn draw( 131 + &self, 132 + encoder: &mut wgpu::CommandEncoder, 133 + color_view: &wgpu::TextureView, 134 + pick_view: &wgpu::TextureView, 135 + camera: Camera2, 136 + style: &Style, 137 + scene: &SketchScene, 138 + ) { 139 + let instances = build_instances(scene, style); 140 + if instances.is_empty() { 141 + return; 142 + } 143 + let uniform = build_frame_uniform(camera, style); 144 + self.queue 145 + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); 146 + let instance_buffer = self 147 + .device 148 + .create_buffer_init(&wgpu::util::BufferInitDescriptor { 149 + label: Some("bone-render:arc-instances"), 150 + contents: bytemuck::cast_slice(&instances), 151 + usage: wgpu::BufferUsages::VERTEX, 152 + }); 153 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 154 + label: Some("bone-render:arc-pass"), 155 + color_attachments: &[ 156 + Some(wgpu::RenderPassColorAttachment { 157 + view: color_view, 158 + resolve_target: None, 159 + depth_slice: None, 160 + ops: wgpu::Operations { 161 + load: wgpu::LoadOp::Load, 162 + store: wgpu::StoreOp::Store, 163 + }, 164 + }), 165 + Some(wgpu::RenderPassColorAttachment { 166 + view: pick_view, 167 + resolve_target: None, 168 + depth_slice: None, 169 + ops: wgpu::Operations { 170 + load: wgpu::LoadOp::Load, 171 + store: wgpu::StoreOp::Store, 172 + }, 173 + }), 174 + ], 175 + depth_stencil_attachment: None, 176 + timestamp_writes: None, 177 + occlusion_query_set: None, 178 + multiview_mask: None, 179 + }); 180 + pass.set_pipeline(&self.pipeline); 181 + pass.set_bind_group(0, &self.bind_group, &[]); 182 + pass.set_vertex_buffer(0, instance_buffer.slice(..)); 183 + let len = instances.len(); 184 + let Ok(count) = u32::try_from(len) else { 185 + panic!("arc instance count {len} exceeds u32::MAX"); 186 + }; 187 + pass.draw(0..6, 0..count); 188 + } 189 + } 190 + 191 + impl core::fmt::Debug for ArcPipeline { 192 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 193 + f.debug_struct("ArcPipeline").finish_non_exhaustive() 194 + } 195 + } 196 + 197 + const INSTANCE_ATTRS: [wgpu::VertexAttribute; 9] = wgpu::vertex_attr_array![ 198 + 0 => Float32x2, 199 + 1 => Float32, 200 + 2 => Float32, 201 + 3 => Float32, 202 + 4 => Float32, 203 + 5 => Float32x2, 204 + 6 => Float32x2, 205 + 7 => Uint32, 206 + 8 => Uint32, 207 + ]; 208 + 209 + const FULL_CIRCLE_SWEEP: f32 = core::f32::consts::TAU; 210 + 211 + fn build_instances(scene: &SketchScene, style: &Style) -> Vec<ArcInstance> { 212 + let arcs = scene.arcs().iter().map(|a| arc_instance(*a, style)); 213 + let circles = scene.circles().iter().map(|c| circle_instance(*c, style)); 214 + arcs.chain(circles).collect() 215 + } 216 + 217 + #[allow(clippy::cast_possible_truncation)] 218 + fn arc_instance(arc: SceneArc, style: &Style) -> ArcInstance { 219 + let (cx, cy) = arc.center().coords_mm(); 220 + let radius_mm = arc.radius().get::<millimeter>() as f32; 221 + let start_rad = arc.start_angle().get::<radian>() as f32; 222 + let sweep_rad = arc.sweep_angle().get::<radian>() as f32; 223 + let (aabb_min_mm, aabb_max_mm) = arc_aabb_offsets_mm( 224 + arc.center(), 225 + arc.radius(), 226 + arc.start_angle(), 227 + arc.sweep_angle(), 228 + ); 229 + let bits = if arc.for_construction() { 230 + CONSTRUCTION_BIT 231 + } else { 232 + 0 233 + }; 234 + ArcInstance { 235 + center: [cx as f32, cy as f32], 236 + radius_mm, 237 + half_width_px: style.strokes().stroke_width_px() * 0.5, 238 + start_rad, 239 + sweep_rad, 240 + aabb_min_mm, 241 + aabb_max_mm, 242 + pick_id: arc.pick().raw(), 243 + style_bits: bits, 244 + } 245 + } 246 + 247 + #[allow(clippy::cast_possible_truncation)] 248 + fn circle_instance(circle: SceneCircle, style: &Style) -> ArcInstance { 249 + let (cx, cy) = circle.center().coords_mm(); 250 + let radius_mm = circle.radius().get::<millimeter>() as f32; 251 + let bits = if circle.for_construction() { 252 + CONSTRUCTION_BIT 253 + } else { 254 + 0 255 + }; 256 + ArcInstance { 257 + center: [cx as f32, cy as f32], 258 + radius_mm, 259 + half_width_px: style.strokes().stroke_width_px() * 0.5, 260 + start_rad: 0.0, 261 + sweep_rad: FULL_CIRCLE_SWEEP, 262 + aabb_min_mm: [-radius_mm, -radius_mm], 263 + aabb_max_mm: [radius_mm, radius_mm], 264 + pick_id: circle.pick().raw(), 265 + style_bits: bits, 266 + } 267 + } 268 + 269 + #[allow(clippy::cast_possible_truncation)] 270 + fn arc_aabb_offsets_mm( 271 + center: Point2, 272 + radius: Length, 273 + start: Angle, 274 + sweep: Angle, 275 + ) -> ([f32; 2], [f32; 2]) { 276 + let aabb = bone_kernel::arc_bounding_box(center, radius, start, sweep); 277 + let (cx, cy) = center.coords_mm(); 278 + let (mnx, mny) = aabb.min().coords_mm(); 279 + let (mxx, mxy) = aabb.max().coords_mm(); 280 + ( 281 + [(mnx - cx) as f32, (mny - cy) as f32], 282 + [(mxx - cx) as f32, (mxy - cy) as f32], 283 + ) 284 + } 285 + 286 + #[cfg(test)] 287 + mod tests { 288 + use super::arc_aabb_offsets_mm; 289 + use bone_types::{Angle, Length, Point2}; 290 + use core::f64::consts::FRAC_PI_2; 291 + use uom::si::angle::radian; 292 + use uom::si::length::millimeter; 293 + 294 + #[test] 295 + fn quarter_arc_at_origin_offsets_to_unit_quadrant() { 296 + let (min, max) = arc_aabb_offsets_mm( 297 + Point2::from_mm(0.0, 0.0), 298 + Length::new::<millimeter>(1.0), 299 + Angle::new::<radian>(0.0), 300 + Angle::new::<radian>(FRAC_PI_2), 301 + ); 302 + assert!(min[0].abs() < 1e-5 && min[1].abs() < 1e-5); 303 + assert!((max[0] - 1.0).abs() < 1e-5 && (max[1] - 1.0).abs() < 1e-5); 304 + } 305 + 306 + #[test] 307 + fn translated_center_yields_the_same_relative_offsets() { 308 + let (min, max) = arc_aabb_offsets_mm( 309 + Point2::from_mm(10.0, -5.0), 310 + Length::new::<millimeter>(1.0), 311 + Angle::new::<radian>(0.0), 312 + Angle::new::<radian>(FRAC_PI_2), 313 + ); 314 + assert!(min[0].abs() < 1e-5 && min[1].abs() < 1e-5); 315 + assert!((max[0] - 1.0).abs() < 1e-5 && (max[1] - 1.0).abs() < 1e-5); 316 + } 317 + }
+141
crates/bone-render/src/pipelines/arc.wgsl
··· 1 + struct Frame { 2 + clip_from_world: mat4x4<f32>, 3 + stroke_color: vec4<f32>, 4 + construction: vec4<f32>, 5 + pixels_per_mm: f32, 6 + dash_period_px: f32, 7 + dash_on_ratio: f32, 8 + _pad: f32, 9 + }; 10 + 11 + struct Instance { 12 + @location(0) center: vec2<f32>, 13 + @location(1) radius_mm: f32, 14 + @location(2) half_width_px: f32, 15 + @location(3) start_rad: f32, 16 + @location(4) sweep_rad: f32, 17 + @location(5) aabb_min_mm: vec2<f32>, 18 + @location(6) aabb_max_mm: vec2<f32>, 19 + @location(7) pick_id: u32, 20 + @location(8) style_bits: u32, 21 + }; 22 + 23 + struct VsOut { 24 + @builtin(position) clip: vec4<f32>, 25 + @location(0) local_px: vec2<f32>, 26 + @location(1) radius_px: f32, 27 + @location(2) half_width_px: f32, 28 + @location(3) start_rad: f32, 29 + @location(4) sweep_rad: f32, 30 + @location(5) @interpolate(flat) style_bits: u32, 31 + @location(6) @interpolate(flat) pick_id: u32, 32 + }; 33 + 34 + struct FsOut { 35 + @location(0) color: vec4<f32>, 36 + @location(1) pick_id: u32, 37 + }; 38 + 39 + @group(0) @binding(0) var<uniform> u: Frame; 40 + 41 + const CORNERS: array<vec2<f32>, 6> = array<vec2<f32>, 6>( 42 + vec2<f32>(0.0, 0.0), 43 + vec2<f32>(1.0, 0.0), 44 + vec2<f32>(0.0, 1.0), 45 + vec2<f32>(0.0, 1.0), 46 + vec2<f32>(1.0, 0.0), 47 + vec2<f32>(1.0, 1.0), 48 + ); 49 + 50 + const TWO_PI: f32 = 6.283185307179586; 51 + const CONSTRUCTION_BIT: u32 = 1u; 52 + 53 + @vertex 54 + fn vs(@builtin(vertex_index) vid: u32, inst: Instance) -> VsOut { 55 + var corners = CORNERS; 56 + let corner = corners[vid]; 57 + let expand_mm = (inst.half_width_px + 1.0) / u.pixels_per_mm; 58 + let bbox_min = inst.aabb_min_mm - vec2<f32>(expand_mm, expand_mm); 59 + let bbox_max = inst.aabb_max_mm + vec2<f32>(expand_mm, expand_mm); 60 + let offset_mm = mix(bbox_min, bbox_max, corner); 61 + let world_mm = inst.center + offset_mm; 62 + let clip = u.clip_from_world * vec4<f32>(world_mm, 0.0, 1.0); 63 + 64 + var out: VsOut; 65 + out.clip = clip; 66 + out.local_px = offset_mm * u.pixels_per_mm; 67 + out.radius_px = inst.radius_mm * u.pixels_per_mm; 68 + out.half_width_px = inst.half_width_px; 69 + out.start_rad = inst.start_rad; 70 + out.sweep_rad = inst.sweep_rad; 71 + out.style_bits = inst.style_bits; 72 + out.pick_id = inst.pick_id; 73 + return out; 74 + } 75 + 76 + struct ArcPoint { 77 + distance_px: f32, 78 + along_px: f32, 79 + }; 80 + 81 + fn arc_sample(p: vec2<f32>, radius_px: f32, start_rad: f32, sweep_rad: f32) -> ArcPoint { 82 + let r_px = length(p); 83 + let full_circle = sweep_rad >= TWO_PI - 1.0e-4; 84 + if (full_circle) { 85 + let theta = atan2(p.y, p.x); 86 + var delta = theta - start_rad; 87 + delta = delta - floor(delta / TWO_PI) * TWO_PI; 88 + var out: ArcPoint; 89 + out.distance_px = abs(r_px - radius_px); 90 + out.along_px = delta * radius_px; 91 + return out; 92 + } 93 + let theta = atan2(p.y, p.x); 94 + var delta = theta - start_rad; 95 + delta = delta - floor(delta / TWO_PI) * TWO_PI; 96 + var theta_nearest: f32; 97 + var along_rad: f32; 98 + if (delta <= sweep_rad) { 99 + theta_nearest = theta; 100 + along_rad = delta; 101 + } else { 102 + let to_end = delta - sweep_rad; 103 + let to_start = TWO_PI - delta; 104 + if (to_end < to_start) { 105 + theta_nearest = start_rad + sweep_rad; 106 + along_rad = sweep_rad; 107 + } else { 108 + theta_nearest = start_rad; 109 + along_rad = 0.0; 110 + } 111 + } 112 + let nearest = vec2<f32>(cos(theta_nearest), sin(theta_nearest)) * radius_px; 113 + var out: ArcPoint; 114 + out.distance_px = length(p - nearest); 115 + out.along_px = along_rad * radius_px; 116 + return out; 117 + } 118 + 119 + @fragment 120 + fn fs(in: VsOut) -> FsOut { 121 + let sample = arc_sample(in.local_px, in.radius_px, in.start_rad, in.sweep_rad); 122 + let aa = 0.5; 123 + let coverage = 1.0 - smoothstep(in.half_width_px - aa, in.half_width_px + aa, sample.distance_px); 124 + if (coverage <= 0.0) { 125 + discard; 126 + } 127 + 128 + let is_construction = (in.style_bits & CONSTRUCTION_BIT) != 0u; 129 + let dash_visible = !is_construction 130 + || u.dash_period_px <= 0.0 131 + || fract(sample.along_px / u.dash_period_px) <= u.dash_on_ratio; 132 + let color_coverage = select(0.0, coverage, dash_visible); 133 + 134 + let base = select(u.stroke_color, u.construction, is_construction); 135 + let a = base.a * color_coverage; 136 + 137 + var out: FsOut; 138 + out.color = vec4<f32>(base.rgb * a, a); 139 + out.pick_id = in.pick_id; 140 + return out; 141 + }
+176
crates/bone-render/src/pipelines/grid.rs
··· 1 + use uom::si::length::millimeter; 2 + 3 + use crate::camera::{Camera2, GridSpacing}; 4 + use crate::gpu::Gpu; 5 + use crate::snapshot::Style; 6 + 7 + #[repr(C, align(16))] 8 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 9 + struct GridUniform { 10 + world_from_clip: [f32; 16], 11 + minor: [f32; 4], 12 + major: [f32; 4], 13 + axis_x: [f32; 4], 14 + axis_y: [f32; 4], 15 + origin: [f32; 4], 16 + viewport: [f32; 2], 17 + minor_spacing: f32, 18 + major_every: f32, 19 + line_width_px: f32, 20 + axis_width_px: f32, 21 + origin_radius_px: f32, 22 + pixels_per_mm: f32, 23 + } 24 + 25 + const UNIFORM_SIZE: u64 = core::mem::size_of::<GridUniform>() as u64; 26 + 27 + pub struct GridPipeline { 28 + pipeline: wgpu::RenderPipeline, 29 + uniform_buffer: wgpu::Buffer, 30 + bind_group: wgpu::BindGroup, 31 + queue: wgpu::Queue, 32 + } 33 + 34 + impl GridPipeline { 35 + #[must_use] 36 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 37 + let device = gpu.device(); 38 + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 39 + label: Some("bone-render:grid-shader"), 40 + source: wgpu::ShaderSource::Wgsl(include_str!("grid.wgsl").into()), 41 + }); 42 + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 43 + label: Some("bone-render:grid-bgl"), 44 + entries: &[wgpu::BindGroupLayoutEntry { 45 + binding: 0, 46 + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 47 + ty: wgpu::BindingType::Buffer { 48 + ty: wgpu::BufferBindingType::Uniform, 49 + has_dynamic_offset: false, 50 + min_binding_size: wgpu::BufferSize::new(UNIFORM_SIZE), 51 + }, 52 + count: None, 53 + }], 54 + }); 55 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 56 + label: Some("bone-render:grid-layout"), 57 + bind_group_layouts: &[Some(&bind_group_layout)], 58 + immediate_size: 0, 59 + }); 60 + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 61 + label: Some("bone-render:grid-pipeline"), 62 + layout: Some(&pipeline_layout), 63 + vertex: wgpu::VertexState { 64 + module: &shader, 65 + entry_point: Some("vs"), 66 + compilation_options: wgpu::PipelineCompilationOptions::default(), 67 + buffers: &[], 68 + }, 69 + fragment: Some(wgpu::FragmentState { 70 + module: &shader, 71 + entry_point: Some("fs"), 72 + compilation_options: wgpu::PipelineCompilationOptions::default(), 73 + targets: &[Some(wgpu::ColorTargetState { 74 + format: color_format, 75 + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 76 + write_mask: wgpu::ColorWrites::ALL, 77 + })], 78 + }), 79 + primitive: wgpu::PrimitiveState { 80 + topology: wgpu::PrimitiveTopology::TriangleList, 81 + strip_index_format: None, 82 + front_face: wgpu::FrontFace::Ccw, 83 + cull_mode: None, 84 + polygon_mode: wgpu::PolygonMode::Fill, 85 + conservative: false, 86 + unclipped_depth: false, 87 + }, 88 + depth_stencil: None, 89 + multisample: wgpu::MultisampleState::default(), 90 + multiview_mask: None, 91 + cache: None, 92 + }); 93 + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 94 + label: Some("bone-render:grid-uniform"), 95 + size: UNIFORM_SIZE, 96 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 97 + mapped_at_creation: false, 98 + }); 99 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 100 + label: Some("bone-render:grid-bg"), 101 + layout: &bind_group_layout, 102 + entries: &[wgpu::BindGroupEntry { 103 + binding: 0, 104 + resource: uniform_buffer.as_entire_binding(), 105 + }], 106 + }); 107 + Self { 108 + pipeline, 109 + uniform_buffer, 110 + bind_group, 111 + queue: gpu.queue().clone(), 112 + } 113 + } 114 + 115 + pub fn draw( 116 + &self, 117 + encoder: &mut wgpu::CommandEncoder, 118 + view: &wgpu::TextureView, 119 + camera: Camera2, 120 + style: &Style, 121 + ) { 122 + let spacing = GridSpacing::from_zoom(camera.zoom(), style.grid().minor_spacing_target_px()); 123 + let uniform = build_uniform(camera, spacing, style); 124 + self.queue 125 + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); 126 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 127 + label: Some("bone-render:grid-pass"), 128 + color_attachments: &[Some(wgpu::RenderPassColorAttachment { 129 + view, 130 + resolve_target: None, 131 + depth_slice: None, 132 + ops: wgpu::Operations { 133 + load: wgpu::LoadOp::Clear(style.background().into()), 134 + store: wgpu::StoreOp::Store, 135 + }, 136 + })], 137 + depth_stencil_attachment: None, 138 + timestamp_writes: None, 139 + occlusion_query_set: None, 140 + multiview_mask: None, 141 + }); 142 + pass.set_pipeline(&self.pipeline); 143 + pass.set_bind_group(0, &self.bind_group, &[]); 144 + pass.draw(0..3, 0..1); 145 + } 146 + } 147 + 148 + impl core::fmt::Debug for GridPipeline { 149 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 150 + f.debug_struct("GridPipeline").finish_non_exhaustive() 151 + } 152 + } 153 + 154 + #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] 155 + fn build_uniform(camera: Camera2, spacing: GridSpacing, style: &Style) -> GridUniform { 156 + let grid = style.grid(); 157 + let extent = camera.extent(); 158 + GridUniform { 159 + world_from_clip: camera.world_mm_from_clip(), 160 + minor: grid.minor().to_rgba_array(), 161 + major: grid.major().to_rgba_array(), 162 + axis_x: grid.axis_x().to_rgba_array(), 163 + axis_y: grid.axis_y().to_rgba_array(), 164 + origin: grid.origin().to_rgba_array(), 165 + viewport: [ 166 + extent.width().value() as f32, 167 + extent.height().value() as f32, 168 + ], 169 + minor_spacing: spacing.minor().get::<millimeter>() as f32, 170 + major_every: spacing.major_every() as f32, 171 + line_width_px: grid.line_width_px(), 172 + axis_width_px: grid.axis_width_px(), 173 + origin_radius_px: grid.origin_radius_px(), 174 + pixels_per_mm: camera.zoom().value() as f32, 175 + } 176 + }
+76
crates/bone-render/src/pipelines/grid.wgsl
··· 1 + struct Grid { 2 + world_from_clip: mat4x4<f32>, 3 + minor: vec4<f32>, 4 + major: vec4<f32>, 5 + axis_x: vec4<f32>, 6 + axis_y: vec4<f32>, 7 + origin: vec4<f32>, 8 + viewport: vec2<f32>, 9 + minor_spacing: f32, 10 + major_every: f32, 11 + line_width_px: f32, 12 + axis_width_px: f32, 13 + origin_radius_px: f32, 14 + pixels_per_mm: f32, 15 + }; 16 + 17 + @group(0) @binding(0) var<uniform> u: Grid; 18 + 19 + @vertex 20 + fn vs(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> { 21 + var positions = array<vec2<f32>, 3>( 22 + vec2<f32>(-1.0, -1.0), 23 + vec2<f32>( 3.0, -1.0), 24 + vec2<f32>(-1.0, 3.0), 25 + ); 26 + return vec4<f32>(positions[vid], 0.0, 1.0); 27 + } 28 + 29 + fn line_distance_world(coord: f32, spacing: f32) -> f32 { 30 + let nearest = round(coord / spacing) * spacing; 31 + return abs(coord - nearest); 32 + } 33 + 34 + fn line_alpha(dist_px: f32, half_width_px: f32) -> f32 { 35 + let aa = min(0.5, half_width_px); 36 + return 1.0 - smoothstep(half_width_px - aa, half_width_px + aa, dist_px); 37 + } 38 + 39 + fn over(dst_pma: vec4<f32>, src: vec4<f32>, coverage: f32) -> vec4<f32> { 40 + let a = src.a * coverage; 41 + let src_pma = vec4<f32>(src.rgb * a, a); 42 + return src_pma + dst_pma * (1.0 - a); 43 + } 44 + 45 + @fragment 46 + fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> { 47 + let ndc = (pos.xy / u.viewport) * vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0); 48 + let world4 = u.world_from_clip * vec4<f32>(ndc, 0.0, 1.0); 49 + let world = world4.xy / world4.w; 50 + 51 + var acc = vec4<f32>(0.0, 0.0, 0.0, 0.0); 52 + 53 + let half_line = u.line_width_px * 0.5; 54 + let dx_minor = line_distance_world(world.x, u.minor_spacing) * u.pixels_per_mm; 55 + let dy_minor = line_distance_world(world.y, u.minor_spacing) * u.pixels_per_mm; 56 + let minor_cov = max(line_alpha(dx_minor, half_line), line_alpha(dy_minor, half_line)); 57 + acc = over(acc, u.minor, minor_cov); 58 + 59 + let major_spacing = u.minor_spacing * u.major_every; 60 + let dx_major = line_distance_world(world.x, major_spacing) * u.pixels_per_mm; 61 + let dy_major = line_distance_world(world.y, major_spacing) * u.pixels_per_mm; 62 + let major_cov = max(line_alpha(dx_major, half_line), line_alpha(dy_major, half_line)); 63 + acc = over(acc, u.major, major_cov); 64 + 65 + let axis_half = u.axis_width_px * 0.5; 66 + let ax_y_cov = line_alpha(abs(world.x) * u.pixels_per_mm, axis_half); 67 + acc = over(acc, u.axis_y, ax_y_cov); 68 + let ax_x_cov = line_alpha(abs(world.y) * u.pixels_per_mm, axis_half); 69 + acc = over(acc, u.axis_x, ax_x_cov); 70 + 71 + let d_origin_px = length(world) * u.pixels_per_mm; 72 + let origin_cov = 1.0 - smoothstep(u.origin_radius_px - 1.0, u.origin_radius_px + 1.0, d_origin_px); 73 + acc = over(acc, u.origin, origin_cov); 74 + 75 + return acc; 76 + }
+233
crates/bone-render/src/pipelines/lines.rs
··· 1 + use wgpu::util::DeviceExt; 2 + 3 + use crate::camera::Camera2; 4 + use crate::gpu::{Gpu, PICK_FORMAT}; 5 + use crate::pipelines::{CONSTRUCTION_BIT, FRAME_UNIFORM_SIZE, build_frame_uniform}; 6 + use crate::scene::{SceneLine, ScenePoint, SketchScene}; 7 + use crate::snapshot::Style; 8 + 9 + #[repr(C)] 10 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 11 + struct LineInstance { 12 + a: [f32; 2], 13 + b: [f32; 2], 14 + half_width_px: f32, 15 + pick_id: u32, 16 + style_bits: u32, 17 + } 18 + 19 + const INSTANCE_STRIDE: u64 = core::mem::size_of::<LineInstance>() as u64; 20 + 21 + pub struct LinesPipeline { 22 + device: wgpu::Device, 23 + queue: wgpu::Queue, 24 + pipeline: wgpu::RenderPipeline, 25 + uniform_buffer: wgpu::Buffer, 26 + bind_group: wgpu::BindGroup, 27 + } 28 + 29 + impl LinesPipeline { 30 + #[must_use] 31 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 32 + let device = gpu.device().clone(); 33 + let queue = gpu.queue().clone(); 34 + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 35 + label: Some("bone-render:lines-shader"), 36 + source: wgpu::ShaderSource::Wgsl(include_str!("lines.wgsl").into()), 37 + }); 38 + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 39 + label: Some("bone-render:lines-bgl"), 40 + entries: &[wgpu::BindGroupLayoutEntry { 41 + binding: 0, 42 + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 43 + ty: wgpu::BindingType::Buffer { 44 + ty: wgpu::BufferBindingType::Uniform, 45 + has_dynamic_offset: false, 46 + min_binding_size: wgpu::BufferSize::new(FRAME_UNIFORM_SIZE), 47 + }, 48 + count: None, 49 + }], 50 + }); 51 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 52 + label: Some("bone-render:lines-layout"), 53 + bind_group_layouts: &[Some(&bind_group_layout)], 54 + immediate_size: 0, 55 + }); 56 + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 57 + label: Some("bone-render:lines-pipeline"), 58 + layout: Some(&pipeline_layout), 59 + vertex: wgpu::VertexState { 60 + module: &shader, 61 + entry_point: Some("vs"), 62 + compilation_options: wgpu::PipelineCompilationOptions::default(), 63 + buffers: &[wgpu::VertexBufferLayout { 64 + array_stride: INSTANCE_STRIDE, 65 + step_mode: wgpu::VertexStepMode::Instance, 66 + attributes: &INSTANCE_ATTRS, 67 + }], 68 + }, 69 + fragment: Some(wgpu::FragmentState { 70 + module: &shader, 71 + entry_point: Some("fs"), 72 + compilation_options: wgpu::PipelineCompilationOptions::default(), 73 + targets: &[ 74 + Some(wgpu::ColorTargetState { 75 + format: color_format, 76 + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 77 + write_mask: wgpu::ColorWrites::ALL, 78 + }), 79 + Some(wgpu::ColorTargetState { 80 + format: PICK_FORMAT, 81 + blend: None, 82 + write_mask: wgpu::ColorWrites::ALL, 83 + }), 84 + ], 85 + }), 86 + primitive: wgpu::PrimitiveState { 87 + topology: wgpu::PrimitiveTopology::TriangleList, 88 + strip_index_format: None, 89 + front_face: wgpu::FrontFace::Ccw, 90 + cull_mode: None, 91 + polygon_mode: wgpu::PolygonMode::Fill, 92 + conservative: false, 93 + unclipped_depth: false, 94 + }, 95 + depth_stencil: None, 96 + multisample: wgpu::MultisampleState::default(), 97 + multiview_mask: None, 98 + cache: None, 99 + }); 100 + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 101 + label: Some("bone-render:lines-uniform"), 102 + size: FRAME_UNIFORM_SIZE, 103 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 104 + mapped_at_creation: false, 105 + }); 106 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 107 + label: Some("bone-render:lines-bg"), 108 + layout: &bind_group_layout, 109 + entries: &[wgpu::BindGroupEntry { 110 + binding: 0, 111 + resource: uniform_buffer.as_entire_binding(), 112 + }], 113 + }); 114 + Self { 115 + device, 116 + queue, 117 + pipeline, 118 + uniform_buffer, 119 + bind_group, 120 + } 121 + } 122 + 123 + pub fn draw( 124 + &self, 125 + encoder: &mut wgpu::CommandEncoder, 126 + color_view: &wgpu::TextureView, 127 + pick_view: &wgpu::TextureView, 128 + camera: Camera2, 129 + style: &Style, 130 + scene: &SketchScene, 131 + ) { 132 + let instances = build_instances(scene, style); 133 + if instances.is_empty() { 134 + return; 135 + } 136 + let uniform = build_frame_uniform(camera, style); 137 + self.queue 138 + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); 139 + let instance_buffer = self 140 + .device 141 + .create_buffer_init(&wgpu::util::BufferInitDescriptor { 142 + label: Some("bone-render:lines-instances"), 143 + contents: bytemuck::cast_slice(&instances), 144 + usage: wgpu::BufferUsages::VERTEX, 145 + }); 146 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 147 + label: Some("bone-render:lines-pass"), 148 + color_attachments: &[ 149 + Some(wgpu::RenderPassColorAttachment { 150 + view: color_view, 151 + resolve_target: None, 152 + depth_slice: None, 153 + ops: wgpu::Operations { 154 + load: wgpu::LoadOp::Load, 155 + store: wgpu::StoreOp::Store, 156 + }, 157 + }), 158 + Some(wgpu::RenderPassColorAttachment { 159 + view: pick_view, 160 + resolve_target: None, 161 + depth_slice: None, 162 + ops: wgpu::Operations { 163 + load: wgpu::LoadOp::Load, 164 + store: wgpu::StoreOp::Store, 165 + }, 166 + }), 167 + ], 168 + depth_stencil_attachment: None, 169 + timestamp_writes: None, 170 + occlusion_query_set: None, 171 + multiview_mask: None, 172 + }); 173 + pass.set_pipeline(&self.pipeline); 174 + pass.set_bind_group(0, &self.bind_group, &[]); 175 + pass.set_vertex_buffer(0, instance_buffer.slice(..)); 176 + let len = instances.len(); 177 + let Ok(count) = u32::try_from(len) else { 178 + panic!("line instance count {len} exceeds u32::MAX"); 179 + }; 180 + pass.draw(0..6, 0..count); 181 + } 182 + } 183 + 184 + impl core::fmt::Debug for LinesPipeline { 185 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 186 + f.debug_struct("LinesPipeline").finish_non_exhaustive() 187 + } 188 + } 189 + 190 + const INSTANCE_ATTRS: [wgpu::VertexAttribute; 5] = wgpu::vertex_attr_array![ 191 + 0 => Float32x2, 192 + 1 => Float32x2, 193 + 2 => Float32, 194 + 3 => Uint32, 195 + 4 => Uint32, 196 + ]; 197 + 198 + fn build_instances(scene: &SketchScene, style: &Style) -> Vec<LineInstance> { 199 + let lines = scene.lines().iter().map(|l| line_instance(*l, style)); 200 + let points = scene.points().iter().map(|p| point_instance(*p, style)); 201 + lines.chain(points).collect() 202 + } 203 + 204 + #[allow(clippy::cast_possible_truncation)] 205 + fn line_instance(line: SceneLine, style: &Style) -> LineInstance { 206 + let (ax, ay) = line.a().coords_mm(); 207 + let (bx, by) = line.b().coords_mm(); 208 + let bits = if line.for_construction() { 209 + CONSTRUCTION_BIT 210 + } else { 211 + 0 212 + }; 213 + LineInstance { 214 + a: [ax as f32, ay as f32], 215 + b: [bx as f32, by as f32], 216 + half_width_px: style.strokes().stroke_width_px() * 0.5, 217 + pick_id: line.pick().raw(), 218 + style_bits: bits, 219 + } 220 + } 221 + 222 + #[allow(clippy::cast_possible_truncation)] 223 + fn point_instance(point: ScenePoint, style: &Style) -> LineInstance { 224 + let (x, y) = point.at().coords_mm(); 225 + let xy = [x as f32, y as f32]; 226 + LineInstance { 227 + a: xy, 228 + b: xy, 229 + half_width_px: style.strokes().point_radius_px(), 230 + pick_id: point.pick().raw(), 231 + style_bits: 0, 232 + } 233 + }
+98
crates/bone-render/src/pipelines/lines.wgsl
··· 1 + struct Frame { 2 + clip_from_world: mat4x4<f32>, 3 + stroke_color: vec4<f32>, 4 + construction: vec4<f32>, 5 + pixels_per_mm: f32, 6 + dash_period_px: f32, 7 + dash_on_ratio: f32, 8 + _pad: f32, 9 + }; 10 + 11 + struct Instance { 12 + @location(0) a: vec2<f32>, 13 + @location(1) b: vec2<f32>, 14 + @location(2) half_width_px: f32, 15 + @location(3) pick_id: u32, 16 + @location(4) style_bits: u32, 17 + }; 18 + 19 + struct VsOut { 20 + @builtin(position) clip: vec4<f32>, 21 + @location(0) local_px: vec2<f32>, 22 + @location(1) length_px: f32, 23 + @location(2) half_width_px: f32, 24 + @location(3) @interpolate(flat) style_bits: u32, 25 + @location(4) @interpolate(flat) pick_id: u32, 26 + }; 27 + 28 + struct FsOut { 29 + @location(0) color: vec4<f32>, 30 + @location(1) pick_id: u32, 31 + }; 32 + 33 + @group(0) @binding(0) var<uniform> u: Frame; 34 + 35 + const CORNERS: array<vec2<f32>, 6> = array<vec2<f32>, 6>( 36 + vec2<f32>(0.0, 0.0), 37 + vec2<f32>(1.0, 0.0), 38 + vec2<f32>(0.0, 1.0), 39 + vec2<f32>(0.0, 1.0), 40 + vec2<f32>(1.0, 0.0), 41 + vec2<f32>(1.0, 1.0), 42 + ); 43 + 44 + const CONSTRUCTION_BIT: u32 = 1u; 45 + 46 + @vertex 47 + fn vs(@builtin(vertex_index) vid: u32, inst: Instance) -> VsOut { 48 + var corners = CORNERS; 49 + let corner = corners[vid]; 50 + let d_mm = inst.b - inst.a; 51 + let len_mm = length(d_mm); 52 + let safe = max(len_mm, 1.0e-9); 53 + let t_hat = select(vec2<f32>(1.0, 0.0), d_mm / safe, len_mm > 1.0e-9); 54 + let n_hat = vec2<f32>(-t_hat.y, t_hat.x); 55 + 56 + let expand_mm = (inst.half_width_px + 1.0) / u.pixels_per_mm; 57 + let along_mm = mix(-expand_mm, len_mm + expand_mm, corner.x); 58 + let perp_mm = mix(-expand_mm, expand_mm, corner.y); 59 + 60 + let world_mm = inst.a + along_mm * t_hat + perp_mm * n_hat; 61 + let clip = u.clip_from_world * vec4<f32>(world_mm, 0.0, 1.0); 62 + 63 + var out: VsOut; 64 + out.clip = clip; 65 + out.local_px = vec2<f32>(along_mm, perp_mm) * u.pixels_per_mm; 66 + out.length_px = len_mm * u.pixels_per_mm; 67 + out.half_width_px = inst.half_width_px; 68 + out.style_bits = inst.style_bits; 69 + out.pick_id = inst.pick_id; 70 + return out; 71 + } 72 + 73 + @fragment 74 + fn fs(in: VsOut) -> FsOut { 75 + let along = clamp(in.local_px.x, 0.0, in.length_px); 76 + let dx = in.local_px.x - along; 77 + let dy = in.local_px.y; 78 + let d_px = sqrt(dx * dx + dy * dy); 79 + let aa = 0.5; 80 + let coverage = 1.0 - smoothstep(in.half_width_px - aa, in.half_width_px + aa, d_px); 81 + if (coverage <= 0.0) { 82 + discard; 83 + } 84 + 85 + let is_construction = (in.style_bits & CONSTRUCTION_BIT) != 0u; 86 + let dash_visible = !is_construction 87 + || u.dash_period_px <= 0.0 88 + || fract(along / u.dash_period_px) <= u.dash_on_ratio; 89 + let color_coverage = select(0.0, coverage, dash_visible); 90 + 91 + let base = select(u.stroke_color, u.construction, is_construction); 92 + let a = base.a * color_coverage; 93 + 94 + var out: FsOut; 95 + out.color = vec4<f32>(base.rgb * a, a); 96 + out.pick_id = in.pick_id; 97 + return out; 98 + }
+40
crates/bone-render/src/pipelines/mod.rs
··· 1 + pub mod arc; 2 + pub mod grid; 3 + pub mod lines; 4 + 5 + pub use arc::ArcPipeline; 6 + pub use grid::GridPipeline; 7 + pub use lines::LinesPipeline; 8 + 9 + use crate::camera::Camera2; 10 + use crate::snapshot::Style; 11 + 12 + #[repr(C, align(16))] 13 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 14 + pub(crate) struct FrameUniform { 15 + pub clip_from_world: [f32; 16], 16 + pub stroke_color: [f32; 4], 17 + pub construction_color: [f32; 4], 18 + pub pixels_per_mm: f32, 19 + pub dash_period_px: f32, 20 + pub dash_on_ratio: f32, 21 + pub _pad: f32, 22 + } 23 + 24 + pub(crate) const FRAME_UNIFORM_SIZE: u64 = core::mem::size_of::<FrameUniform>() as u64; 25 + 26 + pub(crate) const CONSTRUCTION_BIT: u32 = 1; 27 + 28 + #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] 29 + pub(crate) fn build_frame_uniform(camera: Camera2, style: &Style) -> FrameUniform { 30 + let strokes = style.strokes(); 31 + FrameUniform { 32 + clip_from_world: camera.clip_from_world_mm(), 33 + stroke_color: strokes.stroke().to_rgba_array(), 34 + construction_color: strokes.construction().to_rgba_array(), 35 + pixels_per_mm: camera.zoom().value() as f32, 36 + dash_period_px: strokes.construction_dash_period_px(), 37 + dash_on_ratio: strokes.construction_dash_on_ratio(), 38 + _pad: 0.0, 39 + } 40 + }
+556
crates/bone-render/src/scene.rs
··· 1 + use bone_document::{Sketch, SketchEntity}; 2 + use bone_kernel::{Aabb2, arc_bounding_box}; 3 + use bone_types::{Angle, Length, Point2, SketchEntityId}; 4 + use core::f64::consts::TAU; 5 + use uom::si::angle::radian; 6 + use uom::si::length::millimeter; 7 + 8 + use crate::pick::{EntityKindTag, PickId, PickIdError, PickIndex}; 9 + 10 + #[derive(Copy, Clone, Debug, PartialEq)] 11 + pub struct ScenePoint { 12 + at: Point2, 13 + entity: SketchEntityId, 14 + pick: PickId, 15 + } 16 + 17 + impl ScenePoint { 18 + #[must_use] 19 + pub const fn at(self) -> Point2 { 20 + self.at 21 + } 22 + 23 + #[must_use] 24 + pub const fn pick(self) -> PickId { 25 + self.pick 26 + } 27 + } 28 + 29 + #[derive(Copy, Clone, Debug, PartialEq)] 30 + pub struct SceneLine { 31 + a: Point2, 32 + b: Point2, 33 + entity: SketchEntityId, 34 + pick: PickId, 35 + for_construction: bool, 36 + } 37 + 38 + impl SceneLine { 39 + #[must_use] 40 + pub const fn a(self) -> Point2 { 41 + self.a 42 + } 43 + 44 + #[must_use] 45 + pub const fn b(self) -> Point2 { 46 + self.b 47 + } 48 + 49 + #[must_use] 50 + pub const fn pick(self) -> PickId { 51 + self.pick 52 + } 53 + 54 + #[must_use] 55 + pub const fn for_construction(self) -> bool { 56 + self.for_construction 57 + } 58 + } 59 + 60 + #[derive(Copy, Clone, Debug, PartialEq)] 61 + pub struct SceneArc { 62 + center: Point2, 63 + radius: Length, 64 + start_angle: Angle, 65 + sweep_angle: Angle, 66 + entity: SketchEntityId, 67 + pick: PickId, 68 + for_construction: bool, 69 + } 70 + 71 + impl SceneArc { 72 + #[must_use] 73 + pub const fn center(self) -> Point2 { 74 + self.center 75 + } 76 + 77 + #[must_use] 78 + pub const fn radius(self) -> Length { 79 + self.radius 80 + } 81 + 82 + #[must_use] 83 + pub const fn start_angle(self) -> Angle { 84 + self.start_angle 85 + } 86 + 87 + #[must_use] 88 + pub const fn sweep_angle(self) -> Angle { 89 + self.sweep_angle 90 + } 91 + 92 + #[must_use] 93 + pub const fn pick(self) -> PickId { 94 + self.pick 95 + } 96 + 97 + #[must_use] 98 + pub const fn for_construction(self) -> bool { 99 + self.for_construction 100 + } 101 + } 102 + 103 + #[derive(Copy, Clone, Debug, PartialEq)] 104 + pub struct SceneCircle { 105 + center: Point2, 106 + radius: Length, 107 + entity: SketchEntityId, 108 + pick: PickId, 109 + for_construction: bool, 110 + } 111 + 112 + impl SceneCircle { 113 + #[must_use] 114 + pub const fn center(self) -> Point2 { 115 + self.center 116 + } 117 + 118 + #[must_use] 119 + pub const fn radius(self) -> Length { 120 + self.radius 121 + } 122 + 123 + #[must_use] 124 + pub const fn pick(self) -> PickId { 125 + self.pick 126 + } 127 + 128 + #[must_use] 129 + pub const fn for_construction(self) -> bool { 130 + self.for_construction 131 + } 132 + } 133 + 134 + #[derive(Clone, Debug, Default, PartialEq)] 135 + pub struct SketchScene { 136 + points: Vec<ScenePoint>, 137 + lines: Vec<SceneLine>, 138 + arcs: Vec<SceneArc>, 139 + circles: Vec<SceneCircle>, 140 + } 141 + 142 + impl SketchScene { 143 + #[must_use] 144 + pub const fn empty() -> Self { 145 + Self { 146 + points: Vec::new(), 147 + lines: Vec::new(), 148 + arcs: Vec::new(), 149 + circles: Vec::new(), 150 + } 151 + } 152 + 153 + pub fn extract(sketch: &Sketch) -> Result<Self, PickIdError> { 154 + sketch 155 + .entity_order() 156 + .iter() 157 + .copied() 158 + .try_fold(Self::empty(), |acc, id| acc.push_from_sketch(sketch, id)) 159 + } 160 + 161 + #[must_use] 162 + pub fn points(&self) -> &[ScenePoint] { 163 + &self.points 164 + } 165 + 166 + #[must_use] 167 + pub fn lines(&self) -> &[SceneLine] { 168 + &self.lines 169 + } 170 + 171 + #[must_use] 172 + pub fn arcs(&self) -> &[SceneArc] { 173 + &self.arcs 174 + } 175 + 176 + #[must_use] 177 + pub fn circles(&self) -> &[SceneCircle] { 178 + &self.circles 179 + } 180 + 181 + #[must_use] 182 + pub fn is_empty(&self) -> bool { 183 + self.points.is_empty() 184 + && self.lines.is_empty() 185 + && self.arcs.is_empty() 186 + && self.circles.is_empty() 187 + } 188 + 189 + #[must_use] 190 + pub fn aabb(&self) -> Option<Aabb2> { 191 + let points = self 192 + .points 193 + .iter() 194 + .map(|p| Aabb2::from_corners(p.at(), p.at())); 195 + let lines = self.lines.iter().map(|l| Aabb2::from_corners(l.a(), l.b())); 196 + let circles = self.circles.iter().map(|c| { 197 + let (cx, cy) = c.center().coords_mm(); 198 + let r = c.radius().get::<millimeter>(); 199 + Aabb2::from_corners( 200 + Point2::from_mm(cx - r, cy - r), 201 + Point2::from_mm(cx + r, cy + r), 202 + ) 203 + }); 204 + let arcs = self 205 + .arcs 206 + .iter() 207 + .map(|a| arc_bounding_box(a.center(), a.radius(), a.start_angle(), a.sweep_angle())); 208 + points 209 + .chain(lines) 210 + .chain(circles) 211 + .chain(arcs) 212 + .reduce(|a, b| a.extend_point(b.min()).extend_point(b.max())) 213 + } 214 + 215 + pub fn pick_index(&self) -> Result<PickIndex, PickIdError> { 216 + let entities = self 217 + .points 218 + .iter() 219 + .map(|p| (p.entity, EntityKindTag::Point)) 220 + .chain(self.lines.iter().map(|l| (l.entity, EntityKindTag::Line))) 221 + .chain(self.arcs.iter().map(|a| (a.entity, EntityKindTag::Arc))) 222 + .chain( 223 + self.circles 224 + .iter() 225 + .map(|c| (c.entity, EntityKindTag::Circle)), 226 + ); 227 + PickIndex::build( 228 + entities, 229 + core::iter::empty::<bone_types::SketchRelationId>(), 230 + core::iter::empty::<bone_types::SketchDimensionId>(), 231 + ) 232 + } 233 + 234 + fn push_from_sketch( 235 + mut self, 236 + sketch: &Sketch, 237 + id: SketchEntityId, 238 + ) -> Result<Self, PickIdError> { 239 + let Some(entity) = sketch.entities().get(id).copied() else { 240 + unreachable!( 241 + "entity_order references id {id:?} missing from entities — Sketch invariants broken" 242 + ); 243 + }; 244 + match entity { 245 + SketchEntity::Point(p) => { 246 + self.points.push(ScenePoint { 247 + at: p.at(), 248 + entity: id, 249 + pick: PickId::point(id)?, 250 + }); 251 + } 252 + SketchEntity::Line(l) => { 253 + self.lines.push(SceneLine { 254 + a: point_position(sketch, l.a()), 255 + b: point_position(sketch, l.b()), 256 + entity: id, 257 + pick: PickId::line(id)?, 258 + for_construction: l.for_construction(), 259 + }); 260 + } 261 + SketchEntity::Arc(a) => { 262 + if let Some(arc) = derive_arc(sketch, id, a, PickId::arc(id)?) { 263 + self.arcs.push(arc); 264 + } 265 + } 266 + SketchEntity::Circle(c) => { 267 + self.circles.push(SceneCircle { 268 + center: point_position(sketch, c.center()), 269 + radius: c.radius(), 270 + entity: id, 271 + pick: PickId::circle(id)?, 272 + for_construction: c.for_construction(), 273 + }); 274 + } 275 + } 276 + Ok(self) 277 + } 278 + } 279 + 280 + fn point_position(sketch: &Sketch, id: SketchEntityId) -> Point2 { 281 + let Some(entity) = sketch.entities().get(id) else { 282 + unreachable!( 283 + "SketchEntityId {id:?} missing from sketch entities — Sketch invariants broken" 284 + ); 285 + }; 286 + let SketchEntity::Point(p) = entity else { 287 + unreachable!( 288 + "expected Point at {id:?}, got {:?} — Sketch::apply guarantees references resolve to Points", 289 + entity.kind() 290 + ); 291 + }; 292 + p.at() 293 + } 294 + 295 + fn derive_arc( 296 + sketch: &Sketch, 297 + id: SketchEntityId, 298 + data: bone_document::ArcData, 299 + pick: PickId, 300 + ) -> Option<SceneArc> { 301 + let center = point_position(sketch, data.center()); 302 + let start = point_position(sketch, data.start()); 303 + let end = point_position(sketch, data.end()); 304 + let (cx, cy) = center.coords_mm(); 305 + let (start_x, start_y) = start.coords_mm(); 306 + let (end_x, end_y) = end.coords_mm(); 307 + let start_offset_x = start_x - cx; 308 + let start_offset_y = start_y - cy; 309 + let radius_mm = (start_offset_x * start_offset_x + start_offset_y * start_offset_y).sqrt(); 310 + if !(radius_mm.is_finite() && radius_mm > 0.0) { 311 + return None; 312 + } 313 + let start_angle = start_offset_y.atan2(start_offset_x); 314 + let end_angle = (end_y - cy).atan2(end_x - cx); 315 + let sweep = (end_angle - start_angle).rem_euclid(TAU); 316 + if !(sweep.is_finite() && sweep > 0.0) { 317 + return None; 318 + } 319 + Some(SceneArc { 320 + center, 321 + radius: Length::new::<millimeter>(radius_mm), 322 + start_angle: Angle::new::<radian>(start_angle), 323 + sweep_angle: Angle::new::<radian>(sweep), 324 + entity: id, 325 + pick, 326 + for_construction: data.for_construction(), 327 + }) 328 + } 329 + 330 + #[cfg(test)] 331 + mod tests { 332 + use super::*; 333 + use bone_document::{EditOutcome, SketchEdit}; 334 + use bone_types::{Point3, SketchPlaneBasis, Tolerance, UnitVec3}; 335 + use uom::si::length::millimeter; 336 + 337 + fn plane() -> SketchPlaneBasis { 338 + let Ok(basis) = SketchPlaneBasis::new( 339 + Point3::origin(), 340 + UnitVec3::x_axis(), 341 + UnitVec3::y_axis(), 342 + Tolerance::new(1e-9), 343 + ) else { 344 + panic!("xy plane basis is orthogonal"); 345 + }; 346 + basis 347 + } 348 + 349 + fn len_mm(v: f64) -> Length { 350 + Length::new::<millimeter>(v) 351 + } 352 + 353 + fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 354 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 355 + SketchEntity::point(Point2::from_mm(x, y)), 356 + )) else { 357 + panic!("add point"); 358 + }; 359 + (next, id) 360 + } 361 + 362 + fn add_line( 363 + sketch: Sketch, 364 + a: SketchEntityId, 365 + b: SketchEntityId, 366 + construction: bool, 367 + ) -> (Sketch, SketchEntityId) { 368 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 369 + SketchEntity::line(a, b, construction), 370 + )) else { 371 + panic!("add line"); 372 + }; 373 + (next, id) 374 + } 375 + 376 + fn add_circle( 377 + sketch: Sketch, 378 + center: SketchEntityId, 379 + radius_mm: f64, 380 + ) -> (Sketch, SketchEntityId) { 381 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 382 + SketchEntity::circle(center, len_mm(radius_mm), false), 383 + )) else { 384 + panic!("add circle"); 385 + }; 386 + (next, id) 387 + } 388 + 389 + fn add_arc_ids( 390 + sketch: Sketch, 391 + center: SketchEntityId, 392 + start: SketchEntityId, 393 + end: SketchEntityId, 394 + ) -> (Sketch, SketchEntityId) { 395 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 396 + SketchEntity::arc(center, start, end, false), 397 + )) else { 398 + panic!("add arc"); 399 + }; 400 + (next, id) 401 + } 402 + 403 + #[test] 404 + fn empty_scene_has_no_items() { 405 + let s = Sketch::new(plane()); 406 + let Ok(scene) = SketchScene::extract(&s) else { 407 + panic!("extract empty"); 408 + }; 409 + assert!(scene.is_empty()); 410 + } 411 + 412 + #[test] 413 + fn rectangle_extracts_four_points_and_four_lines() { 414 + let s = Sketch::new(plane()); 415 + let (s, p0) = add_point(s, 0.0, 0.0); 416 + let (s, p1) = add_point(s, 10.0, 0.0); 417 + let (s, p2) = add_point(s, 10.0, 5.0); 418 + let (s, p3) = add_point(s, 0.0, 5.0); 419 + let (s, _) = add_line(s, p0, p1, false); 420 + let (s, _) = add_line(s, p1, p2, false); 421 + let (s, _) = add_line(s, p2, p3, false); 422 + let (s, _) = add_line(s, p3, p0, false); 423 + let Ok(scene) = SketchScene::extract(&s) else { 424 + panic!("extract"); 425 + }; 426 + assert_eq!(scene.points().len(), 4); 427 + assert_eq!(scene.lines().len(), 4); 428 + assert!(scene.arcs().is_empty()); 429 + assert!(scene.circles().is_empty()); 430 + assert_eq!(scene.lines()[0].a(), Point2::from_mm(0.0, 0.0)); 431 + assert_eq!(scene.lines()[0].b(), Point2::from_mm(10.0, 0.0)); 432 + } 433 + 434 + #[test] 435 + fn construction_flag_propagates_to_line() { 436 + let s = Sketch::new(plane()); 437 + let (s, a) = add_point(s, 0.0, 0.0); 438 + let (s, b) = add_point(s, 3.0, 4.0); 439 + let (s, _) = add_line(s, a, b, true); 440 + let Ok(scene) = SketchScene::extract(&s) else { 441 + panic!("extract"); 442 + }; 443 + assert!(scene.lines()[0].for_construction()); 444 + } 445 + 446 + #[test] 447 + fn circle_extracts_with_center_and_radius() { 448 + let s = Sketch::new(plane()); 449 + let (s, c) = add_point(s, 1.0, 2.0); 450 + let (s, _) = add_circle(s, c, 4.0); 451 + let Ok(scene) = SketchScene::extract(&s) else { 452 + panic!("extract"); 453 + }; 454 + assert_eq!(scene.circles().len(), 1); 455 + assert_eq!(scene.circles()[0].center(), Point2::from_mm(1.0, 2.0)); 456 + assert!((scene.circles()[0].radius().get::<millimeter>() - 4.0).abs() < 1e-12); 457 + } 458 + 459 + #[test] 460 + fn arc_derives_radius_and_ccw_sweep() { 461 + let s = Sketch::new(plane()); 462 + let (s, c) = add_point(s, 0.0, 0.0); 463 + let (s, start) = add_point(s, 5.0, 0.0); 464 + let (s, end) = add_point(s, 0.0, 5.0); 465 + let (s, _) = add_arc_ids(s, c, start, end); 466 + let Ok(scene) = SketchScene::extract(&s) else { 467 + panic!("extract"); 468 + }; 469 + let arc = scene.arcs()[0]; 470 + assert!((arc.radius().get::<millimeter>() - 5.0).abs() < 1e-12); 471 + let start_rad = arc.start_angle().get::<radian>(); 472 + let sweep_rad = arc.sweep_angle().get::<radian>(); 473 + assert!(start_rad.abs() < 1e-12); 474 + assert!((sweep_rad - core::f64::consts::FRAC_PI_2).abs() < 1e-12); 475 + } 476 + 477 + #[test] 478 + fn pick_index_round_trips_each_scene_item() { 479 + use crate::pick::PickedItem; 480 + let s = Sketch::new(plane()); 481 + let (s, p0) = add_point(s, 0.0, 0.0); 482 + let (s, p1) = add_point(s, 1.0, 0.0); 483 + let (s, line) = add_line(s, p0, p1, false); 484 + let (s, cc) = add_point(s, 5.0, 0.0); 485 + let (s, circle) = add_circle(s, cc, 2.0); 486 + let (s, ac) = add_point(s, 10.0, 0.0); 487 + let (s, a_start) = add_point(s, 12.0, 0.0); 488 + let (s, a_end) = add_point(s, 10.0, 2.0); 489 + let (s, arc) = add_arc_ids(s, ac, a_start, a_end); 490 + let Ok(scene) = SketchScene::extract(&s) else { 491 + panic!("extract"); 492 + }; 493 + let Ok(index) = scene.pick_index() else { 494 + panic!("pick index"); 495 + }; 496 + assert_eq!( 497 + scene.points()[0].pick().unpack(&index), 498 + Some(PickedItem::Point(p0)) 499 + ); 500 + assert_eq!( 501 + scene.lines()[0].pick().unpack(&index), 502 + Some(PickedItem::Line(line)) 503 + ); 504 + assert_eq!( 505 + scene.arcs()[0].pick().unpack(&index), 506 + Some(PickedItem::Arc(arc)) 507 + ); 508 + assert_eq!( 509 + scene.circles()[0].pick().unpack(&index), 510 + Some(PickedItem::Circle(circle)) 511 + ); 512 + } 513 + 514 + #[test] 515 + fn arc_coincident_start_and_end_is_dropped() { 516 + let s = Sketch::new(plane()); 517 + let (s, c) = add_point(s, 0.0, 0.0); 518 + let (s, start) = add_point(s, 5.0, 0.0); 519 + let (s, end) = add_point(s, 5.0, 0.0); 520 + let (s, _) = add_arc_ids(s, c, start, end); 521 + let Ok(scene) = SketchScene::extract(&s) else { 522 + panic!("extract"); 523 + }; 524 + assert!(scene.arcs().is_empty()); 525 + } 526 + 527 + #[test] 528 + fn arc_with_zero_radius_is_dropped() { 529 + let s = Sketch::new(plane()); 530 + let (s, c) = add_point(s, 0.0, 0.0); 531 + let (s, start) = add_point(s, 0.0, 0.0); 532 + let (s, end) = add_point(s, 5.0, 0.0); 533 + let (s, _) = add_arc_ids(s, c, start, end); 534 + let Ok(scene) = SketchScene::extract(&s) else { 535 + panic!("extract"); 536 + }; 537 + assert!(scene.arcs().is_empty()); 538 + } 539 + 540 + #[test] 541 + fn arc_sweep_follows_ccw_convention() { 542 + let s = Sketch::new(plane()); 543 + let (s, c) = add_point(s, 0.0, 0.0); 544 + let (s, start) = add_point(s, 0.0, 5.0); 545 + let (s, end) = add_point(s, 5.0, 0.0); 546 + let (s, _) = add_arc_ids(s, c, start, end); 547 + let Ok(scene) = SketchScene::extract(&s) else { 548 + panic!("extract"); 549 + }; 550 + let arc = scene.arcs()[0]; 551 + let start_rad = arc.start_angle().get::<radian>(); 552 + let sweep_rad = arc.sweep_angle().get::<radian>(); 553 + assert!((start_rad - core::f64::consts::FRAC_PI_2).abs() < 1e-12); 554 + assert!((sweep_rad - 3.0 * core::f64::consts::FRAC_PI_2).abs() < 1e-12); 555 + } 556 + }
+371
crates/bone-render/src/snapshot.rs
··· 1 + use crate::camera::ViewportExtent; 2 + use crate::gpu::BackendTag; 3 + use crate::{RenderError, Result}; 4 + 5 + #[derive(Copy, Clone, Debug, PartialEq)] 6 + pub struct ClearColor { 7 + r: f64, 8 + g: f64, 9 + b: f64, 10 + a: f64, 11 + } 12 + 13 + impl ClearColor { 14 + #[must_use] 15 + pub const fn new(r: f64, g: f64, b: f64, a: f64) -> Self { 16 + Self { r, g, b, a } 17 + } 18 + 19 + #[must_use] 20 + pub const fn opaque(r: f64, g: f64, b: f64) -> Self { 21 + Self::new(r, g, b, 1.0) 22 + } 23 + 24 + #[must_use] 25 + pub fn to_rgba8(self) -> [u8; 4] { 26 + [ 27 + channel_to_u8(self.r), 28 + channel_to_u8(self.g), 29 + channel_to_u8(self.b), 30 + channel_to_u8(self.a), 31 + ] 32 + } 33 + 34 + #[must_use] 35 + #[allow(clippy::cast_possible_truncation)] 36 + pub fn to_rgba_array(self) -> [f32; 4] { 37 + [self.r as f32, self.g as f32, self.b as f32, self.a as f32] 38 + } 39 + } 40 + 41 + impl From<ClearColor> for wgpu::Color { 42 + fn from(c: ClearColor) -> Self { 43 + Self { 44 + r: c.r, 45 + g: c.g, 46 + b: c.b, 47 + a: c.a, 48 + } 49 + } 50 + } 51 + 52 + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 53 + fn channel_to_u8(value: f64) -> u8 { 54 + (value.clamp(0.0, 1.0) * 255.0).round() as u8 55 + } 56 + 57 + #[derive(Copy, Clone, Debug, PartialEq)] 58 + pub struct GridStyle { 59 + minor: ClearColor, 60 + major: ClearColor, 61 + axis_x: ClearColor, 62 + axis_y: ClearColor, 63 + origin: ClearColor, 64 + line_width_px: f32, 65 + axis_width_px: f32, 66 + origin_radius_px: f32, 67 + minor_spacing_target_px: f32, 68 + } 69 + 70 + impl GridStyle { 71 + pub const DEFAULT: Self = Self { 72 + minor: ClearColor::new(1.0, 1.0, 1.0, 0.08), 73 + major: ClearColor::new(1.0, 1.0, 1.0, 0.22), 74 + axis_x: ClearColor::new(1.0, 0.0, 0.0, 1.0), 75 + axis_y: ClearColor::new(0.0, 1.0, 0.0, 1.0), 76 + origin: ClearColor::new(1.0, 1.0, 1.0, 0.95), 77 + line_width_px: 1.0, 78 + axis_width_px: 1.6, 79 + origin_radius_px: 4.0, 80 + minor_spacing_target_px: 24.0, 81 + }; 82 + 83 + #[must_use] 84 + pub const fn minor(self) -> ClearColor { 85 + self.minor 86 + } 87 + 88 + #[must_use] 89 + pub const fn major(self) -> ClearColor { 90 + self.major 91 + } 92 + 93 + #[must_use] 94 + pub const fn axis_x(self) -> ClearColor { 95 + self.axis_x 96 + } 97 + 98 + #[must_use] 99 + pub const fn axis_y(self) -> ClearColor { 100 + self.axis_y 101 + } 102 + 103 + #[must_use] 104 + pub const fn origin(self) -> ClearColor { 105 + self.origin 106 + } 107 + 108 + #[must_use] 109 + pub const fn line_width_px(self) -> f32 { 110 + self.line_width_px 111 + } 112 + 113 + #[must_use] 114 + pub const fn axis_width_px(self) -> f32 { 115 + self.axis_width_px 116 + } 117 + 118 + #[must_use] 119 + pub const fn origin_radius_px(self) -> f32 { 120 + self.origin_radius_px 121 + } 122 + 123 + #[must_use] 124 + pub const fn minor_spacing_target_px(self) -> f32 { 125 + self.minor_spacing_target_px 126 + } 127 + } 128 + 129 + impl Default for GridStyle { 130 + fn default() -> Self { 131 + Self::DEFAULT 132 + } 133 + } 134 + 135 + #[derive(Copy, Clone, Debug, PartialEq)] 136 + pub struct StrokeStyle { 137 + stroke: ClearColor, 138 + construction: ClearColor, 139 + stroke_width_px: f32, 140 + point_radius_px: f32, 141 + construction_dash_period_px: f32, 142 + construction_dash_on_ratio: f32, 143 + } 144 + 145 + impl StrokeStyle { 146 + pub const DEFAULT: Self = Self { 147 + stroke: ClearColor::new(0.95, 0.95, 0.98, 1.0), 148 + construction: ClearColor::new(0.55, 0.75, 1.0, 0.85), 149 + stroke_width_px: 1.5, 150 + point_radius_px: 3.0, 151 + construction_dash_period_px: 8.0, 152 + construction_dash_on_ratio: 0.5, 153 + }; 154 + 155 + #[must_use] 156 + pub const fn stroke(self) -> ClearColor { 157 + self.stroke 158 + } 159 + 160 + #[must_use] 161 + pub const fn construction(self) -> ClearColor { 162 + self.construction 163 + } 164 + 165 + #[must_use] 166 + pub const fn stroke_width_px(self) -> f32 { 167 + self.stroke_width_px 168 + } 169 + 170 + #[must_use] 171 + pub const fn point_radius_px(self) -> f32 { 172 + self.point_radius_px 173 + } 174 + 175 + #[must_use] 176 + pub const fn construction_dash_period_px(self) -> f32 { 177 + self.construction_dash_period_px 178 + } 179 + 180 + #[must_use] 181 + pub const fn construction_dash_on_ratio(self) -> f32 { 182 + self.construction_dash_on_ratio 183 + } 184 + } 185 + 186 + impl Default for StrokeStyle { 187 + fn default() -> Self { 188 + Self::DEFAULT 189 + } 190 + } 191 + 192 + #[derive(Copy, Clone, Debug, PartialEq)] 193 + pub struct Style { 194 + background: ClearColor, 195 + grid: GridStyle, 196 + strokes: StrokeStyle, 197 + } 198 + 199 + impl Style { 200 + #[must_use] 201 + pub const fn new(background: ClearColor) -> Self { 202 + Self { 203 + background, 204 + grid: GridStyle::DEFAULT, 205 + strokes: StrokeStyle::DEFAULT, 206 + } 207 + } 208 + 209 + #[must_use] 210 + pub const fn background(self) -> ClearColor { 211 + self.background 212 + } 213 + 214 + #[must_use] 215 + pub const fn grid(self) -> GridStyle { 216 + self.grid 217 + } 218 + 219 + #[must_use] 220 + pub const fn strokes(self) -> StrokeStyle { 221 + self.strokes 222 + } 223 + } 224 + 225 + impl Default for Style { 226 + fn default() -> Self { 227 + Self::new(ClearColor::opaque(0.05, 0.05, 0.07)) 228 + } 229 + } 230 + 231 + #[derive(Clone, Debug)] 232 + pub struct SnapshotFrame { 233 + extent: ViewportExtent, 234 + rgba: Vec<u8>, 235 + backend: BackendTag, 236 + } 237 + 238 + impl SnapshotFrame { 239 + pub(crate) fn new(extent: ViewportExtent, rgba: Vec<u8>, backend: BackendTag) -> Self { 240 + Self { 241 + extent, 242 + rgba, 243 + backend, 244 + } 245 + } 246 + 247 + #[must_use] 248 + pub fn extent(&self) -> ViewportExtent { 249 + self.extent 250 + } 251 + 252 + #[must_use] 253 + pub fn rgba(&self) -> &[u8] { 254 + &self.rgba 255 + } 256 + 257 + #[must_use] 258 + pub fn backend(&self) -> BackendTag { 259 + self.backend 260 + } 261 + } 262 + 263 + pub fn encode_png(frame: &SnapshotFrame) -> Result<Vec<u8>> { 264 + let mut out: Vec<u8> = Vec::new(); 265 + { 266 + let mut encoder = png::Encoder::new( 267 + &mut out, 268 + frame.extent.width().value(), 269 + frame.extent.height().value(), 270 + ); 271 + encoder.set_color(png::ColorType::Rgba); 272 + encoder.set_depth(png::BitDepth::Eight); 273 + let mut writer = encoder.write_header()?; 274 + writer.write_image_data(frame.rgba())?; 275 + } 276 + Ok(out) 277 + } 278 + 279 + pub fn decode_png(bytes: &[u8]) -> Result<(ViewportExtent, Vec<u8>)> { 280 + use crate::camera::ViewportPx; 281 + let decoder = png::Decoder::new(bytes); 282 + let mut reader = decoder.read_info()?; 283 + { 284 + let info = reader.info(); 285 + if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight { 286 + return Err(RenderError::PngFormat { 287 + color_type: info.color_type, 288 + bit_depth: info.bit_depth, 289 + }); 290 + } 291 + } 292 + let mut buf = vec![0_u8; reader.output_buffer_size()]; 293 + let info = reader.next_frame(&mut buf)?; 294 + let extent = ViewportExtent::new(ViewportPx::new(info.width), ViewportPx::new(info.height)); 295 + buf.truncate(info.buffer_size()); 296 + Ok((extent, buf)) 297 + } 298 + 299 + #[cfg(test)] 300 + mod tests { 301 + use super::*; 302 + use crate::camera::ViewportPx; 303 + use crate::gpu::BackendTag; 304 + 305 + fn tag() -> BackendTag { 306 + BackendTag::from_backend(wgpu::Backend::Noop) 307 + } 308 + 309 + #[test] 310 + fn clear_color_rounds_to_nearest_u8() { 311 + let c = ClearColor::opaque(0.0, 0.5, 1.0); 312 + assert_eq!(c.to_rgba8(), [0, 128, 255, 255]); 313 + } 314 + 315 + #[test] 316 + fn style_default_is_navy_opaque() { 317 + let rgba = Style::default().background().to_rgba8(); 318 + assert_eq!(rgba[3], 255); 319 + assert!(rgba[0] < 32 && rgba[1] < 32 && rgba[2] < 32); 320 + } 321 + 322 + fn encode_grayscale(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> { 323 + let mut bytes: Vec<u8> = Vec::new(); 324 + { 325 + let mut enc = png::Encoder::new(&mut bytes, width, height); 326 + enc.set_color(png::ColorType::Grayscale); 327 + enc.set_depth(png::BitDepth::Eight); 328 + let Ok(mut writer) = enc.write_header() else { 329 + panic!("grayscale png header write failed"); 330 + }; 331 + assert!( 332 + writer.write_image_data(pixels).is_ok(), 333 + "grayscale png body write failed", 334 + ); 335 + } 336 + bytes 337 + } 338 + 339 + #[test] 340 + fn decode_png_rejects_non_rgba8() { 341 + let bytes = encode_grayscale(4, 4, &[0_u8; 16]); 342 + let Err(RenderError::PngFormat { 343 + color_type, 344 + bit_depth, 345 + }) = decode_png(&bytes) 346 + else { 347 + panic!("expected PngFormat rejection"); 348 + }; 349 + assert_eq!(color_type, png::ColorType::Grayscale); 350 + assert_eq!(bit_depth, png::BitDepth::Eight); 351 + } 352 + 353 + #[test] 354 + fn png_encode_decode_round_trips_solid_frame() { 355 + let extent = ViewportExtent::new(ViewportPx::new(8), ViewportPx::new(4)); 356 + let pixel = [200_u8, 80, 40, 255]; 357 + let rgba: Vec<u8> = (0..extent.pixel_count()) 358 + .flat_map(|_| pixel.into_iter()) 359 + .collect(); 360 + let frame = SnapshotFrame::new(extent, rgba.clone(), tag()); 361 + 362 + let Ok(encoded) = encode_png(&frame) else { 363 + panic!("encode_png failed"); 364 + }; 365 + let Ok((decoded_extent, decoded)) = decode_png(&encoded) else { 366 + panic!("decode_png failed"); 367 + }; 368 + assert_eq!(decoded_extent, extent); 369 + assert_eq!(decoded, rgba); 370 + } 371 + }
+235
crates/bone-render/src/surface.rs
··· 1 + use crate::camera::ViewportExtent; 2 + use crate::gpu::{Capabilities, Gpu}; 3 + 4 + const PICK_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint; 5 + 6 + #[derive(Debug, thiserror::Error)] 7 + pub enum SurfaceError { 8 + #[error("surface creation: {0}")] 9 + Create(#[from] wgpu::CreateSurfaceError), 10 + #[error("no wgpu adapter matched the surface: {0}")] 11 + NoAdapter(#[from] wgpu::RequestAdapterError), 12 + #[error("wgpu device request failed: {0}")] 13 + Device(#[from] wgpu::RequestDeviceError), 14 + #[error( 15 + "surface exposes no srgb colour format; baseline requires one. available: {available:?}" 16 + )] 17 + NoSrgbFormat { available: Vec<wgpu::TextureFormat> }, 18 + #[error("viewport dimension is zero")] 19 + ZeroExtent, 20 + } 21 + 22 + pub struct SurfaceContext { 23 + gpu: Gpu, 24 + surface: wgpu::Surface<'static>, 25 + config: wgpu::SurfaceConfiguration, 26 + pick: wgpu::Texture, 27 + extent: ViewportExtent, 28 + reconfigure_pending: bool, 29 + } 30 + 31 + enum AcquireOutcome { 32 + Frame(wgpu::SurfaceTexture), 33 + Skip, 34 + Reconfigure, 35 + } 36 + 37 + impl SurfaceContext { 38 + pub async fn new( 39 + target: impl Into<wgpu::SurfaceTarget<'static>>, 40 + extent: ViewportExtent, 41 + ) -> Result<Self, SurfaceError> { 42 + if extent.width().value() == 0 || extent.height().value() == 0 { 43 + return Err(SurfaceError::ZeroExtent); 44 + } 45 + let instance = 46 + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); 47 + let target: wgpu::SurfaceTarget<'static> = target.into(); 48 + let surface = instance.create_surface(target)?; 49 + let adapter = instance 50 + .request_adapter(&wgpu::RequestAdapterOptions { 51 + power_preference: wgpu::PowerPreference::LowPower, 52 + force_fallback_adapter: false, 53 + compatible_surface: Some(&surface), 54 + }) 55 + .await?; 56 + let (device, queue) = adapter 57 + .request_device(&wgpu::DeviceDescriptor { 58 + label: Some("bone-render:surface"), 59 + required_features: wgpu::Features::empty(), 60 + required_limits: wgpu::Limits::downlevel_defaults(), 61 + experimental_features: wgpu::ExperimentalFeatures::default(), 62 + memory_hints: wgpu::MemoryHints::default(), 63 + trace: wgpu::Trace::Off, 64 + }) 65 + .await?; 66 + let capabilities = Capabilities::probe(&adapter); 67 + let caps = surface.get_capabilities(&adapter); 68 + let Some(format) = caps 69 + .formats 70 + .iter() 71 + .copied() 72 + .find(wgpu::TextureFormat::is_srgb) 73 + else { 74 + return Err(SurfaceError::NoSrgbFormat { 75 + available: caps.formats.clone(), 76 + }); 77 + }; 78 + let present_mode = pick_present_mode(&caps.present_modes); 79 + let alpha_mode = caps 80 + .alpha_modes 81 + .first() 82 + .copied() 83 + .unwrap_or(wgpu::CompositeAlphaMode::Auto); 84 + let config = wgpu::SurfaceConfiguration { 85 + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, 86 + format, 87 + width: extent.width().value(), 88 + height: extent.height().value(), 89 + present_mode, 90 + alpha_mode, 91 + view_formats: vec![], 92 + desired_maximum_frame_latency: 1, 93 + }; 94 + surface.configure(&device, &config); 95 + let pick = create_pick_texture(&device, extent); 96 + Ok(Self { 97 + gpu: Gpu::from_parts(device, queue, capabilities), 98 + surface, 99 + config, 100 + pick, 101 + extent, 102 + reconfigure_pending: false, 103 + }) 104 + } 105 + 106 + #[must_use] 107 + pub fn gpu(&self) -> &Gpu { 108 + &self.gpu 109 + } 110 + 111 + #[must_use] 112 + pub const fn extent(&self) -> ViewportExtent { 113 + self.extent 114 + } 115 + 116 + #[must_use] 117 + pub const fn color_format(&self) -> wgpu::TextureFormat { 118 + self.config.format 119 + } 120 + 121 + pub fn resize(&mut self, extent: ViewportExtent) { 122 + if extent.width().value() == 0 || extent.height().value() == 0 { 123 + return; 124 + } 125 + self.extent = extent; 126 + self.config.width = extent.width().value(); 127 + self.config.height = extent.height().value(); 128 + self.surface.configure(self.gpu.device(), &self.config); 129 + self.pick = create_pick_texture(self.gpu.device(), extent); 130 + self.reconfigure_pending = false; 131 + } 132 + 133 + pub fn render<F, G>(&mut self, build_passes: F, pre_present: G) 134 + where 135 + F: FnOnce(&mut wgpu::CommandEncoder, &wgpu::TextureView, &wgpu::TextureView), 136 + G: FnOnce(), 137 + { 138 + let Some(frame) = self.acquire_frame() else { 139 + return; 140 + }; 141 + let color_view = frame 142 + .texture 143 + .create_view(&wgpu::TextureViewDescriptor::default()); 144 + let pick_view = self 145 + .pick 146 + .create_view(&wgpu::TextureViewDescriptor::default()); 147 + let mut encoder = 148 + self.gpu 149 + .device() 150 + .create_command_encoder(&wgpu::CommandEncoderDescriptor { 151 + label: Some("bone-render:surface-encoder"), 152 + }); 153 + build_passes(&mut encoder, &color_view, &pick_view); 154 + self.gpu.queue().submit(Some(encoder.finish())); 155 + pre_present(); 156 + frame.present(); 157 + } 158 + 159 + fn acquire_frame(&mut self) -> Option<wgpu::SurfaceTexture> { 160 + if self.reconfigure_pending { 161 + self.surface.configure(self.gpu.device(), &self.config); 162 + self.reconfigure_pending = false; 163 + } 164 + match self.classify_acquire() { 165 + AcquireOutcome::Frame(frame) => Some(frame), 166 + AcquireOutcome::Skip => None, 167 + AcquireOutcome::Reconfigure => { 168 + self.surface.configure(self.gpu.device(), &self.config); 169 + match self.classify_acquire() { 170 + AcquireOutcome::Frame(frame) => Some(frame), 171 + AcquireOutcome::Skip => None, 172 + AcquireOutcome::Reconfigure => { 173 + tracing::warn!( 174 + "surface still outdated/lost after reconfigure; skipping frame" 175 + ); 176 + None 177 + } 178 + } 179 + } 180 + } 181 + } 182 + 183 + fn classify_acquire(&mut self) -> AcquireOutcome { 184 + match self.surface.get_current_texture() { 185 + wgpu::CurrentSurfaceTexture::Success(frame) => AcquireOutcome::Frame(frame), 186 + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { 187 + self.reconfigure_pending = true; 188 + AcquireOutcome::Frame(frame) 189 + } 190 + wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { 191 + AcquireOutcome::Reconfigure 192 + } 193 + wgpu::CurrentSurfaceTexture::Timeout => { 194 + tracing::warn!("surface frame acquire timed out; skipping frame"); 195 + AcquireOutcome::Skip 196 + } 197 + wgpu::CurrentSurfaceTexture::Occluded => { 198 + tracing::debug!("surface occluded; skipping frame"); 199 + AcquireOutcome::Skip 200 + } 201 + wgpu::CurrentSurfaceTexture::Validation => { 202 + tracing::warn!("surface validation error on acquire; skipping frame"); 203 + AcquireOutcome::Skip 204 + } 205 + } 206 + } 207 + } 208 + 209 + fn create_pick_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { 210 + device.create_texture(&wgpu::TextureDescriptor { 211 + label: Some("bone-render:surface-pick"), 212 + size: wgpu::Extent3d { 213 + width: extent.width().value(), 214 + height: extent.height().value(), 215 + depth_or_array_layers: 1, 216 + }, 217 + mip_level_count: 1, 218 + sample_count: 1, 219 + dimension: wgpu::TextureDimension::D2, 220 + format: PICK_FORMAT, 221 + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, 222 + view_formats: &[], 223 + }) 224 + } 225 + 226 + fn pick_present_mode(modes: &[wgpu::PresentMode]) -> wgpu::PresentMode { 227 + [ 228 + wgpu::PresentMode::Mailbox, 229 + wgpu::PresentMode::Immediate, 230 + wgpu::PresentMode::FifoRelaxed, 231 + ] 232 + .into_iter() 233 + .find(|m| modes.contains(m)) 234 + .unwrap_or(wgpu::PresentMode::Fifo) 235 + }
+148
crates/bone-render/tests/arcs.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 4 + use bone_render::{ 5 + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, PixelsPerMm, RenderError, 6 + SketchRenderer, SketchScene, Style, ViewportExtent, ViewportPx, decode_png, encode_png, 7 + }; 8 + use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; 9 + use uom::si::length::millimeter; 10 + 11 + const GOLDEN: &str = "tests/goldens/arc_circle_zoom100_256.png"; 12 + const UPDATE_ENV: &str = "BONE_UPDATE_ARC_GOLDEN"; 13 + const DIFF_TOLERANCE: f64 = 16.0 / 255.0; 14 + 15 + fn extent(side: u32) -> ViewportExtent { 16 + ViewportExtent::square(ViewportPx::new(side)) 17 + } 18 + 19 + fn make_context(extent: ViewportExtent) -> OffscreenContext { 20 + match pollster::block_on(OffscreenContext::new(extent)) { 21 + Ok(ctx) => ctx, 22 + Err(RenderError::NoAdapter(e)) => { 23 + panic!( 24 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 25 + ); 26 + } 27 + Err(e) => panic!("offscreen context init failed: {e}"), 28 + } 29 + } 30 + 31 + fn golden_path() -> PathBuf { 32 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) 33 + } 34 + 35 + fn plane() -> SketchPlaneBasis { 36 + let Ok(basis) = SketchPlaneBasis::new( 37 + Point3::origin(), 38 + UnitVec3::x_axis(), 39 + UnitVec3::y_axis(), 40 + Tolerance::new(1e-9), 41 + ) else { 42 + panic!("xy plane basis is orthogonal"); 43 + }; 44 + basis 45 + } 46 + 47 + fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 48 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 49 + Point2::from_mm(x, y), 50 + ))) else { 51 + panic!("add point"); 52 + }; 53 + (next, id) 54 + } 55 + 56 + fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> Sketch { 57 + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 58 + center, 59 + Length::new::<millimeter>(radius_mm), 60 + false, 61 + ))) else { 62 + panic!("add circle"); 63 + }; 64 + next 65 + } 66 + 67 + fn add_arc( 68 + s: Sketch, 69 + center: SketchEntityId, 70 + start: SketchEntityId, 71 + end: SketchEntityId, 72 + ) -> Sketch { 73 + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::arc( 74 + center, start, end, false, 75 + ))) else { 76 + panic!("add arc"); 77 + }; 78 + next 79 + } 80 + 81 + fn arc_circle_scene() -> SketchScene { 82 + let s = Sketch::new(plane()); 83 + let (s, circle_c) = add_point(s, -0.7, 0.0); 84 + let s = add_circle(s, circle_c, 0.5); 85 + 86 + let (s, arc_c) = add_point(s, 0.7, 0.0); 87 + let (s, arc_start) = add_point(s, 1.2, 0.0); 88 + let (s, arc_end) = add_point(s, 0.7, 0.5); 89 + let s = add_arc(s, arc_c, arc_start, arc_end); 90 + 91 + let Ok(scene) = SketchScene::extract(&s) else { 92 + panic!("scene extract"); 93 + }; 94 + scene 95 + } 96 + 97 + #[test] 98 + fn arc_circle_at_100x_zoom_matches_golden() { 99 + let size = extent(256); 100 + let ctx = make_context(size); 101 + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 102 + let scene = arc_circle_scene(); 103 + let camera = Camera2::new(size).with_zoom(PixelsPerMm::new(100.0)); 104 + let style = Style::default(); 105 + 106 + let Ok(frame) = renderer.render(&ctx, &scene, camera, &style) else { 107 + panic!("SketchRenderer::render failed"); 108 + }; 109 + 110 + let path = golden_path(); 111 + 112 + if std::env::var(UPDATE_ENV).is_ok() { 113 + let Ok(bytes) = encode_png(&frame) else { 114 + panic!("encode_png failed"); 115 + }; 116 + if let Some(parent) = path.parent() 117 + && let Err(e) = std::fs::create_dir_all(parent) 118 + { 119 + panic!("create goldens dir {}: {e}", parent.display()); 120 + } 121 + if let Err(e) = std::fs::write(&path, &bytes) { 122 + panic!("write golden {}: {e}", path.display()); 123 + } 124 + return; 125 + } 126 + 127 + let Ok(bytes) = std::fs::read(&path) else { 128 + panic!( 129 + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 130 + path.display() 131 + ); 132 + }; 133 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 134 + panic!("failed to decode golden PNG"); 135 + }; 136 + assert_eq!(golden_extent, size, "golden extent drift"); 137 + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); 138 + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { 139 + panic!("PixelDiff rejected inputs"); 140 + }; 141 + assert!( 142 + report.is_clean(), 143 + "arc render drifted from golden: {} mismatches, worst {:?}, backend {}", 144 + report.over_threshold(), 145 + report.worst(), 146 + frame.backend(), 147 + ); 148 + }
+91
crates/bone-render/tests/clear.rs
··· 1 + use bone_render::{ 2 + ClearColor, OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, Style, 3 + ViewportExtent, ViewportPx, 4 + }; 5 + 6 + fn extent(side: u32) -> ViewportExtent { 7 + ViewportExtent::square(ViewportPx::new(side)) 8 + } 9 + 10 + fn make_context(extent: ViewportExtent) -> OffscreenContext { 11 + match pollster::block_on(OffscreenContext::new(extent)) { 12 + Ok(ctx) => ctx, 13 + Err(RenderError::NoAdapter(e)) => { 14 + panic!( 15 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 16 + ); 17 + } 18 + Err(e) => panic!("offscreen context init failed: {e}"), 19 + } 20 + } 21 + 22 + fn expected_rgba(extent: ViewportExtent, color: ClearColor) -> Vec<u8> { 23 + let pixel = color.to_rgba8(); 24 + let count = usize::try_from(extent.pixel_count()).unwrap_or(0); 25 + (0..count).flat_map(|_| pixel.into_iter()).collect() 26 + } 27 + 28 + #[test] 29 + fn offscreen_clear_matches_style_background_exactly() { 30 + let size = extent(64); 31 + let ctx = make_context(size); 32 + let style = Style::default(); 33 + 34 + let Ok(frame) = ctx.render_clear(&style) else { 35 + panic!("render_clear failed"); 36 + }; 37 + 38 + assert_eq!(frame.extent(), size); 39 + let expected = expected_rgba(size, style.background()); 40 + assert_eq!(frame.rgba().len(), expected.len()); 41 + 42 + let Ok(report) = PixelDiff::compare(&frame, &expected, PixelDiffThreshold::EXACT) else { 43 + panic!("pixel diff rejected inputs"); 44 + }; 45 + assert!( 46 + report.is_clean(), 47 + "clear frame diverged from expected bytes: {} mismatches, worst {:?}", 48 + report.over_threshold(), 49 + report.worst(), 50 + ); 51 + } 52 + 53 + #[test] 54 + fn offscreen_clear_honours_custom_background() { 55 + let size = extent(32); 56 + let ctx = make_context(size); 57 + let background = ClearColor::opaque(0.25, 0.50, 0.75); 58 + let style = Style::new(background); 59 + 60 + let Ok(frame) = ctx.render_clear(&style) else { 61 + panic!("render_clear failed"); 62 + }; 63 + 64 + let expected = expected_rgba(size, background); 65 + let Ok(report) = PixelDiff::compare(&frame, &expected, PixelDiffThreshold::EXACT) else { 66 + panic!("pixel diff rejected inputs"); 67 + }; 68 + assert!( 69 + report.is_clean(), 70 + "custom clear diverged: {} mismatches", 71 + report.over_threshold(), 72 + ); 73 + } 74 + 75 + #[test] 76 + fn offscreen_extent_with_non_aligned_width_reads_back_packed_rgba() { 77 + let size = ViewportExtent::new(ViewportPx::new(63), ViewportPx::new(16)); 78 + let ctx = make_context(size); 79 + let style = Style::new(ClearColor::opaque(1.0, 0.0, 0.0)); 80 + 81 + let Ok(frame) = ctx.render_clear(&style) else { 82 + panic!("render_clear failed"); 83 + }; 84 + 85 + let expected = expected_rgba(size, style.background()); 86 + assert_eq!(frame.rgba().len(), expected.len()); 87 + let Ok(report) = PixelDiff::compare(&frame, &expected, PixelDiffThreshold::EXACT) else { 88 + panic!("pixel diff rejected inputs"); 89 + }; 90 + assert!(report.is_clean(), "unaligned width readback diverged"); 91 + }
+194
crates/bone-render/tests/construction_styling.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 4 + use bone_render::{ 5 + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, PixelsPerMm, RenderError, 6 + SketchRenderer, SketchScene, SnapshotFrame, Style, ViewportExtent, ViewportPx, decode_png, 7 + encode_png, 8 + }; 9 + use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; 10 + use uom::si::length::millimeter; 11 + 12 + const REAL_GOLDEN: &str = "tests/goldens/construction_real_256.png"; 13 + const DASHED_GOLDEN: &str = "tests/goldens/construction_dashed_256.png"; 14 + const UPDATE_ENV: &str = "BONE_UPDATE_CONSTRUCTION_GOLDENS"; 15 + const DIFF_TOLERANCE: f64 = 16.0 / 255.0; 16 + const DISTINCT_MIN_PIXELS: u32 = 50; 17 + 18 + fn extent() -> ViewportExtent { 19 + ViewportExtent::square(ViewportPx::new(256)) 20 + } 21 + 22 + fn make_context() -> OffscreenContext { 23 + match pollster::block_on(OffscreenContext::new(extent())) { 24 + Ok(ctx) => ctx, 25 + Err(RenderError::NoAdapter(e)) => panic!( 26 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 27 + ), 28 + Err(e) => panic!("offscreen context init failed: {e}"), 29 + } 30 + } 31 + 32 + fn plane() -> SketchPlaneBasis { 33 + let Ok(basis) = SketchPlaneBasis::new( 34 + Point3::origin(), 35 + UnitVec3::x_axis(), 36 + UnitVec3::y_axis(), 37 + Tolerance::new(1e-9), 38 + ) else { 39 + panic!("xy plane basis is orthogonal"); 40 + }; 41 + basis 42 + } 43 + 44 + fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 45 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 46 + Point2::from_mm(x, y), 47 + ))) else { 48 + panic!("add point"); 49 + }; 50 + (next, id) 51 + } 52 + 53 + fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId, construction: bool) -> Sketch { 54 + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line( 55 + a, 56 + b, 57 + construction, 58 + ))) else { 59 + panic!("add line"); 60 + }; 61 + next 62 + } 63 + 64 + fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64, construction: bool) -> Sketch { 65 + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 66 + center, 67 + Length::new::<millimeter>(radius_mm), 68 + construction, 69 + ))) else { 70 + panic!("add circle"); 71 + }; 72 + next 73 + } 74 + 75 + fn add_arc( 76 + s: Sketch, 77 + center: SketchEntityId, 78 + start: SketchEntityId, 79 + end: SketchEntityId, 80 + construction: bool, 81 + ) -> Sketch { 82 + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::arc( 83 + center, 84 + start, 85 + end, 86 + construction, 87 + ))) else { 88 + panic!("add arc"); 89 + }; 90 + next 91 + } 92 + 93 + fn build_scene(construction: bool) -> SketchScene { 94 + let s = Sketch::new(plane()); 95 + 96 + let (s, la) = add_point(s, -0.9, 0.35); 97 + let (s, lb) = add_point(s, 0.9, 0.35); 98 + let s = add_line(s, la, lb, construction); 99 + 100 + let (s, cc) = add_point(s, -0.5, -0.3); 101 + let s = add_circle(s, cc, 0.25, construction); 102 + 103 + let (s, ac) = add_point(s, 0.5, -0.3); 104 + let (s, astart) = add_point(s, 0.85, -0.3); 105 + let (s, aend) = add_point(s, 0.5, 0.05); 106 + let s = add_arc(s, ac, astart, aend, construction); 107 + 108 + let Ok(scene) = SketchScene::extract(&s) else { 109 + panic!("scene extract"); 110 + }; 111 + scene 112 + } 113 + 114 + fn render_scene(ctx: &OffscreenContext, scene: &SketchScene) -> SnapshotFrame { 115 + let camera = Camera2::new(ctx.extent()).with_zoom(PixelsPerMm::new(100.0)); 116 + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 117 + let style = Style::default(); 118 + let Ok(frame) = renderer.render(ctx, scene, camera, &style) else { 119 + panic!("SketchRenderer::render failed"); 120 + }; 121 + frame 122 + } 123 + 124 + fn golden_path(name: &str) -> PathBuf { 125 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name) 126 + } 127 + 128 + fn match_or_update_golden(frame: &SnapshotFrame, name: &str) { 129 + let path = golden_path(name); 130 + if std::env::var(UPDATE_ENV).is_ok() { 131 + let Ok(bytes) = encode_png(frame) else { 132 + panic!("encode_png failed"); 133 + }; 134 + if let Some(parent) = path.parent() 135 + && let Err(e) = std::fs::create_dir_all(parent) 136 + { 137 + panic!("create goldens dir {}: {e}", parent.display()); 138 + } 139 + if let Err(e) = std::fs::write(&path, &bytes) { 140 + panic!("write golden {}: {e}", path.display()); 141 + } 142 + return; 143 + } 144 + let Ok(bytes) = std::fs::read(&path) else { 145 + panic!( 146 + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 147 + path.display() 148 + ); 149 + }; 150 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 151 + panic!("failed to decode golden PNG at {}", path.display()); 152 + }; 153 + assert_eq!( 154 + golden_extent, 155 + frame.extent(), 156 + "golden extent drift for {name}" 157 + ); 158 + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); 159 + let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else { 160 + panic!("PixelDiff rejected inputs for {name}"); 161 + }; 162 + assert!( 163 + report.is_clean(), 164 + "{name} drifted: {} mismatches, worst {:?}, backend {}", 165 + report.over_threshold(), 166 + report.worst(), 167 + frame.backend(), 168 + ); 169 + } 170 + 171 + #[test] 172 + fn real_and_dashed_goldens_match() { 173 + let ctx = make_context(); 174 + let real = render_scene(&ctx, &build_scene(false)); 175 + match_or_update_golden(&real, REAL_GOLDEN); 176 + let dashed = render_scene(&ctx, &build_scene(true)); 177 + match_or_update_golden(&dashed, DASHED_GOLDEN); 178 + } 179 + 180 + #[test] 181 + fn construction_render_differs_from_real_render() { 182 + let ctx = make_context(); 183 + let real = render_scene(&ctx, &build_scene(false)); 184 + let dashed = render_scene(&ctx, &build_scene(true)); 185 + assert_eq!(real.extent(), dashed.extent()); 186 + let Ok(report) = PixelDiff::compare(&real, dashed.rgba(), PixelDiffThreshold::EXACT) else { 187 + panic!("PixelDiff rejected inputs"); 188 + }; 189 + assert!( 190 + report.over_threshold() >= DISTINCT_MIN_PIXELS, 191 + "construction styling not distinct from real: only {} pixels differ", 192 + report.over_threshold(), 193 + ); 194 + }
crates/bone-render/tests/goldens/arc_circle_zoom100_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/construction_dashed_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/construction_real_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/grid_empty_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/rectangle_256.png

This is a binary file and will not be displayed.

+86
crates/bone-render/tests/grid.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use bone_render::{ 4 + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SketchRenderer, 5 + SketchScene, Style, ViewportExtent, ViewportPx, decode_png, encode_png, 6 + }; 7 + 8 + const GOLDEN: &str = "tests/goldens/grid_empty_256.png"; 9 + const UPDATE_ENV: &str = "BONE_UPDATE_GRID_GOLDEN"; 10 + const DIFF_TOLERANCE: f64 = 16.0 / 255.0; 11 + 12 + fn extent(side: u32) -> ViewportExtent { 13 + ViewportExtent::square(ViewportPx::new(side)) 14 + } 15 + 16 + fn make_context(extent: ViewportExtent) -> OffscreenContext { 17 + match pollster::block_on(OffscreenContext::new(extent)) { 18 + Ok(ctx) => ctx, 19 + Err(RenderError::NoAdapter(e)) => { 20 + panic!( 21 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 22 + ); 23 + } 24 + Err(e) => panic!("offscreen context init failed: {e}"), 25 + } 26 + } 27 + 28 + fn golden_path() -> PathBuf { 29 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) 30 + } 31 + 32 + #[test] 33 + fn grid_empty_sketch_matches_golden() { 34 + let size = extent(256); 35 + let ctx = make_context(size); 36 + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 37 + let scene = SketchScene::empty(); 38 + let camera = Camera2::new(size); 39 + let style = Style::default(); 40 + 41 + let Ok(frame) = renderer.render(&ctx, &scene, camera, &style) else { 42 + panic!("SketchRenderer::render failed"); 43 + }; 44 + 45 + let path = golden_path(); 46 + 47 + if std::env::var(UPDATE_ENV).is_ok() { 48 + let Ok(bytes) = encode_png(&frame) else { 49 + panic!("encode_png failed"); 50 + }; 51 + if let Some(parent) = path.parent() { 52 + assert!( 53 + std::fs::create_dir_all(parent).is_ok(), 54 + "failed to create goldens dir" 55 + ); 56 + } 57 + assert!( 58 + std::fs::write(&path, &bytes).is_ok(), 59 + "failed to write golden {}", 60 + path.display() 61 + ); 62 + return; 63 + } 64 + 65 + let Ok(bytes) = std::fs::read(&path) else { 66 + panic!( 67 + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 68 + path.display() 69 + ); 70 + }; 71 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 72 + panic!("failed to decode golden PNG"); 73 + }; 74 + assert_eq!(golden_extent, size, "golden extent drift"); 75 + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); 76 + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { 77 + panic!("PixelDiff rejected inputs"); 78 + }; 79 + assert!( 80 + report.is_clean(), 81 + "grid render drifted from golden: {} mismatches, worst {:?}, backend {}", 82 + report.over_threshold(), 83 + report.worst(), 84 + frame.backend(), 85 + ); 86 + }
+266
crates/bone-render/tests/picker.rs
··· 1 + use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 2 + use bone_render::{ 3 + Camera2, OffscreenContext, PickQuery, PickedItem, RenderError, SketchRenderer, SketchScene, 4 + Style, ViewportExtent, ViewportPx, 5 + }; 6 + use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; 7 + use uom::si::length::millimeter; 8 + 9 + const SIDE: u32 = 256; 10 + const CENTER_PX: f32 = 128.0; 11 + const PX_PER_MM: f32 = 10.0; 12 + 13 + fn extent() -> ViewportExtent { 14 + ViewportExtent::square(ViewportPx::new(SIDE)) 15 + } 16 + 17 + fn make_context() -> OffscreenContext { 18 + match pollster::block_on(OffscreenContext::new(extent())) { 19 + Ok(ctx) => ctx, 20 + Err(RenderError::NoAdapter(e)) => panic!( 21 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 22 + ), 23 + Err(e) => panic!("offscreen context init failed: {e}"), 24 + } 25 + } 26 + 27 + fn plane() -> SketchPlaneBasis { 28 + let Ok(basis) = SketchPlaneBasis::new( 29 + Point3::origin(), 30 + UnitVec3::x_axis(), 31 + UnitVec3::y_axis(), 32 + Tolerance::new(1e-9), 33 + ) else { 34 + panic!("xy plane basis is orthogonal"); 35 + }; 36 + basis 37 + } 38 + 39 + fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 40 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 41 + Point2::from_mm(x, y), 42 + ))) else { 43 + panic!("add point"); 44 + }; 45 + (next, id) 46 + } 47 + 48 + fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> (Sketch, SketchEntityId) { 49 + let Ok((next, EditOutcome::Entity(id))) = 50 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 51 + else { 52 + panic!("add line"); 53 + }; 54 + (next, id) 55 + } 56 + 57 + fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> (Sketch, SketchEntityId) { 58 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 59 + center, 60 + Length::new::<millimeter>(radius_mm), 61 + false, 62 + ))) else { 63 + panic!("add circle"); 64 + }; 65 + (next, id) 66 + } 67 + 68 + fn add_arc( 69 + s: Sketch, 70 + center: SketchEntityId, 71 + start: SketchEntityId, 72 + end: SketchEntityId, 73 + ) -> (Sketch, SketchEntityId) { 74 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::arc( 75 + center, start, end, false, 76 + ))) else { 77 + panic!("add arc"); 78 + }; 79 + (next, id) 80 + } 81 + 82 + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 83 + fn world_to_px(x_mm: f32, y_mm: f32) -> PickQuery { 84 + let x_px = (CENTER_PX + x_mm * PX_PER_MM).round() as u32; 85 + let y_px = (CENTER_PX - y_mm * PX_PER_MM).round() as u32; 86 + PickQuery::new(ViewportPx::new(x_px), ViewportPx::new(y_px)) 87 + } 88 + 89 + struct Fixture { 90 + scene: SketchScene, 91 + probe_point: (PickQuery, SketchEntityId), 92 + probe_line: (PickQuery, SketchEntityId), 93 + probe_circle: (PickQuery, SketchEntityId), 94 + probe_arc: (PickQuery, SketchEntityId), 95 + } 96 + 97 + fn build_fixture() -> Fixture { 98 + let s = Sketch::new(plane()); 99 + 100 + let (s, line_a) = add_point(s, -6.0, 2.0); 101 + let (s, line_b) = add_point(s, -6.0, -2.0); 102 + let (s, line) = add_line(s, line_a, line_b); 103 + 104 + let (s, standalone) = add_point(s, 6.0, 0.0); 105 + 106 + let (s, circle_center) = add_point(s, 0.0, 4.0); 107 + let (s, circle) = add_circle(s, circle_center, 2.0); 108 + 109 + let (s, arc_center) = add_point(s, 0.0, -4.0); 110 + let (s, arc_start) = add_point(s, 3.0, -4.0); 111 + let (s, arc_end) = add_point(s, 0.0, -1.0); 112 + let (s, arc) = add_arc(s, arc_center, arc_start, arc_end); 113 + 114 + let Ok(scene) = SketchScene::extract(&s) else { 115 + panic!("scene extract"); 116 + }; 117 + 118 + Fixture { 119 + scene, 120 + probe_point: (world_to_px(6.0, 0.0), standalone), 121 + probe_line: (world_to_px(-6.0, 0.0), line), 122 + probe_circle: (world_to_px(2.0, 4.0), circle), 123 + probe_arc: ( 124 + world_to_px( 125 + 3.0 * core::f32::consts::FRAC_1_SQRT_2, 126 + -4.0 + 3.0 * core::f32::consts::FRAC_1_SQRT_2, 127 + ), 128 + arc, 129 + ), 130 + } 131 + } 132 + 133 + #[test] 134 + fn each_entity_centroid_round_trips_to_pick_id() { 135 + let ctx = make_context(); 136 + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 137 + let camera = Camera2::new(extent()); 138 + let style = Style::default(); 139 + 140 + let fx = build_fixture(); 141 + let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 142 + panic!("SketchRenderer::render failed"); 143 + }; 144 + let Ok(index) = fx.scene.pick_index() else { 145 + panic!("pick index build"); 146 + }; 147 + let picker = ctx.picker(index); 148 + 149 + let cases = [ 150 + ( 151 + "point", 152 + fx.probe_point.0, 153 + PickedItem::Point(fx.probe_point.1), 154 + ), 155 + ("line", fx.probe_line.0, PickedItem::Line(fx.probe_line.1)), 156 + ( 157 + "circle", 158 + fx.probe_circle.0, 159 + PickedItem::Circle(fx.probe_circle.1), 160 + ), 161 + ("arc", fx.probe_arc.0, PickedItem::Arc(fx.probe_arc.1)), 162 + ]; 163 + 164 + let mismatches: Vec<String> = cases 165 + .into_iter() 166 + .filter_map(|(label, query, expected)| match picker.at(query) { 167 + Ok(Some(got)) if got == expected => None, 168 + Ok(got) => Some(format!( 169 + "{label} pick at {query}: expected {expected:?}, got {got:?}" 170 + )), 171 + Err(e) => Some(format!("{label} pick at {query}: error {e}")), 172 + }) 173 + .collect(); 174 + assert!(mismatches.is_empty(), "pick mismatches: {mismatches:#?}"); 175 + } 176 + 177 + #[test] 178 + fn empty_space_decodes_to_none() { 179 + let ctx = make_context(); 180 + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 181 + let camera = Camera2::new(extent()); 182 + let style = Style::default(); 183 + 184 + let fx = build_fixture(); 185 + let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 186 + panic!("SketchRenderer::render failed"); 187 + }; 188 + let Ok(index) = fx.scene.pick_index() else { 189 + panic!("pick index"); 190 + }; 191 + let picker = ctx.picker(index); 192 + 193 + let empty = world_to_px(-8.0, 8.0); 194 + let Ok(hit) = picker.at(empty) else { 195 + panic!("picker at empty failed"); 196 + }; 197 + assert_eq!(hit, None, "empty pixel expected to decode to None"); 198 + } 199 + 200 + #[test] 201 + fn repeated_render_picks_deterministic() { 202 + let ctx = make_context(); 203 + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 204 + let camera = Camera2::new(extent()); 205 + let style = Style::default(); 206 + 207 + let fx = build_fixture(); 208 + let Ok(index) = fx.scene.pick_index() else { 209 + panic!("pick index"); 210 + }; 211 + 212 + let collect_picks = || { 213 + let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 214 + panic!("render"); 215 + }; 216 + let pp = ctx.picker(index.clone()); 217 + [ 218 + fx.probe_point.0, 219 + fx.probe_line.0, 220 + fx.probe_circle.0, 221 + fx.probe_arc.0, 222 + ] 223 + .into_iter() 224 + .map(|q| { 225 + let Ok(raw) = pp.raw_at(q) else { 226 + panic!("raw_at failed"); 227 + }; 228 + raw.raw() 229 + }) 230 + .collect::<Vec<_>>() 231 + }; 232 + 233 + let first = collect_picks(); 234 + let second = collect_picks(); 235 + assert_eq!( 236 + first, second, 237 + "repeated render produced non-deterministic ids" 238 + ); 239 + } 240 + 241 + #[test] 242 + fn out_of_bounds_query_is_rejected() { 243 + let ctx = make_context(); 244 + let Ok(index) = SketchScene::empty().pick_index() else { 245 + panic!("pick index"); 246 + }; 247 + let picker = ctx.picker(index); 248 + let cases = [ 249 + ( 250 + "x-axis edge", 251 + PickQuery::new(ViewportPx::new(SIDE), ViewportPx::new(0)), 252 + ), 253 + ( 254 + "y-axis edge", 255 + PickQuery::new(ViewportPx::new(0), ViewportPx::new(SIDE)), 256 + ), 257 + ]; 258 + let failures: Vec<String> = cases 259 + .into_iter() 260 + .filter_map(|(label, query)| match picker.at(query) { 261 + Err(RenderError::PickOutOfBounds { .. }) => None, 262 + other => Some(format!("{label}: expected OOB, got {other:?}")), 263 + }) 264 + .collect(); 265 + assert!(failures.is_empty(), "OOB rejection failures: {failures:#?}"); 266 + }
+129
crates/bone-render/tests/rectangle.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 4 + use bone_render::{ 5 + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SketchRenderer, 6 + SketchScene, Style, ViewportExtent, ViewportPx, decode_png, encode_png, 7 + }; 8 + use bone_types::{Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; 9 + 10 + const GOLDEN: &str = "tests/goldens/rectangle_256.png"; 11 + const UPDATE_ENV: &str = "BONE_UPDATE_RECTANGLE_GOLDEN"; 12 + const DIFF_TOLERANCE: f64 = 16.0 / 255.0; 13 + 14 + fn extent(side: u32) -> ViewportExtent { 15 + ViewportExtent::square(ViewportPx::new(side)) 16 + } 17 + 18 + fn make_context(extent: ViewportExtent) -> OffscreenContext { 19 + match pollster::block_on(OffscreenContext::new(extent)) { 20 + Ok(ctx) => ctx, 21 + Err(RenderError::NoAdapter(e)) => { 22 + panic!( 23 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 24 + ); 25 + } 26 + Err(e) => panic!("offscreen context init failed: {e}"), 27 + } 28 + } 29 + 30 + fn golden_path() -> PathBuf { 31 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) 32 + } 33 + 34 + fn plane() -> SketchPlaneBasis { 35 + let Ok(basis) = SketchPlaneBasis::new( 36 + Point3::origin(), 37 + UnitVec3::x_axis(), 38 + UnitVec3::y_axis(), 39 + Tolerance::new(1e-9), 40 + ) else { 41 + panic!("xy plane basis is orthogonal"); 42 + }; 43 + basis 44 + } 45 + 46 + fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 47 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 48 + Point2::from_mm(x, y), 49 + ))) else { 50 + panic!("add point"); 51 + }; 52 + (next, id) 53 + } 54 + 55 + fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> Sketch { 56 + let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { 57 + panic!("add line"); 58 + }; 59 + next 60 + } 61 + 62 + fn rectangle_scene() -> SketchScene { 63 + let s = Sketch::new(plane()); 64 + let (s, p0) = add_point(s, -8.0, -5.0); 65 + let (s, p1) = add_point(s, 8.0, -5.0); 66 + let (s, p2) = add_point(s, 8.0, 5.0); 67 + let (s, p3) = add_point(s, -8.0, 5.0); 68 + let s = add_line(s, p0, p1); 69 + let s = add_line(s, p1, p2); 70 + let s = add_line(s, p2, p3); 71 + let s = add_line(s, p3, p0); 72 + let Ok(scene) = SketchScene::extract(&s) else { 73 + panic!("scene extract"); 74 + }; 75 + scene 76 + } 77 + 78 + #[test] 79 + fn rectangle_sketch_matches_golden() { 80 + let size = extent(256); 81 + let ctx = make_context(size); 82 + let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 83 + let scene = rectangle_scene(); 84 + let camera = Camera2::new(size); 85 + let style = Style::default(); 86 + 87 + let Ok(frame) = renderer.render(&ctx, &scene, camera, &style) else { 88 + panic!("SketchRenderer::render failed"); 89 + }; 90 + 91 + let path = golden_path(); 92 + 93 + if std::env::var(UPDATE_ENV).is_ok() { 94 + let Ok(bytes) = encode_png(&frame) else { 95 + panic!("encode_png failed"); 96 + }; 97 + if let Some(parent) = path.parent() 98 + && let Err(e) = std::fs::create_dir_all(parent) 99 + { 100 + panic!("create goldens dir {}: {e}", parent.display()); 101 + } 102 + if let Err(e) = std::fs::write(&path, &bytes) { 103 + panic!("write golden {}: {e}", path.display()); 104 + } 105 + return; 106 + } 107 + 108 + let Ok(bytes) = std::fs::read(&path) else { 109 + panic!( 110 + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 111 + path.display() 112 + ); 113 + }; 114 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 115 + panic!("failed to decode golden PNG"); 116 + }; 117 + assert_eq!(golden_extent, size, "golden extent drift"); 118 + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); 119 + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { 120 + panic!("PixelDiff rejected inputs"); 121 + }; 122 + assert!( 123 + report.is_clean(), 124 + "rectangle render drifted from golden: {} mismatches, worst {:?}, backend {}", 125 + report.over_threshold(), 126 + report.worst(), 127 + frame.backend(), 128 + ); 129 + }
+19
crates/bone-solver/Cargo.toml
··· 1 + [package] 2 + name = "bone-solver" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + bone-types = { workspace = true } 10 + bone-kernel = { workspace = true } 11 + faer = { workspace = true } 12 + nalgebra = { workspace = true } 13 + thiserror = { workspace = true } 14 + 15 + [dev-dependencies] 16 + insta = { workspace = true } 17 + 18 + [lints] 19 + workspace = true
+329
crates/bone-solver/src/dof.rs
··· 1 + use bone_types::{ 2 + DegreesOfFreedom, Parameter, ParameterIndex, ResidualIndex, SolverResidual, SolverSeed, 3 + }; 4 + use nalgebra::{DMatrix, DVector}; 5 + 6 + use crate::jacobian::{assemble_jacobian, evaluate_residuals}; 7 + use crate::system::ConstraintSystem; 8 + 9 + #[derive(Clone, Debug, PartialEq)] 10 + pub struct DofReport { 11 + dof: DegreesOfFreedom, 12 + under_constrained: Vec<ParameterIndex>, 13 + over_constrained: Vec<ResidualIndex>, 14 + redundant_consistent: Vec<ResidualIndex>, 15 + } 16 + 17 + impl DofReport { 18 + #[must_use] 19 + pub fn dof(&self) -> DegreesOfFreedom { 20 + self.dof 21 + } 22 + 23 + #[must_use] 24 + pub fn under_constrained(&self) -> &[ParameterIndex] { 25 + &self.under_constrained 26 + } 27 + 28 + #[must_use] 29 + pub fn over_constrained(&self) -> &[ResidualIndex] { 30 + &self.over_constrained 31 + } 32 + 33 + #[must_use] 34 + pub fn redundant_consistent(&self) -> &[ResidualIndex] { 35 + &self.redundant_consistent 36 + } 37 + 38 + #[must_use] 39 + pub fn display(&self) -> String { 40 + format!( 41 + "dof={} under=[{}] over=[{}] redundant=[{}]", 42 + self.dof.value(), 43 + self.under_constrained 44 + .iter() 45 + .map(ToString::to_string) 46 + .collect::<Vec<_>>() 47 + .join(","), 48 + self.over_constrained 49 + .iter() 50 + .map(ToString::to_string) 51 + .collect::<Vec<_>>() 52 + .join(","), 53 + self.redundant_consistent 54 + .iter() 55 + .map(ToString::to_string) 56 + .collect::<Vec<_>>() 57 + .join(","), 58 + ) 59 + } 60 + } 61 + 62 + #[derive(Copy, Clone, Debug)] 63 + pub struct DofConfig { 64 + pub seed: SolverSeed, 65 + pub witness_scale: f64, 66 + pub rank_tolerance: f64, 67 + pub residual_tolerance: SolverResidual, 68 + } 69 + 70 + impl DofConfig { 71 + pub const DEFAULT: Self = Self { 72 + seed: SolverSeed::DEFAULT, 73 + witness_scale: 0.5, 74 + rank_tolerance: 1e-9, 75 + residual_tolerance: SolverResidual::new(1e-7), 76 + }; 77 + } 78 + 79 + #[must_use] 80 + pub fn analyze_dof(system: &ConstraintSystem, cfg: DofConfig) -> DofReport { 81 + analyze_dof_at(system, system.parameters(), cfg) 82 + } 83 + 84 + #[must_use] 85 + pub fn analyze_dof_at( 86 + system: &ConstraintSystem, 87 + params: &[Parameter], 88 + cfg: DofConfig, 89 + ) -> DofReport { 90 + let current_params: Vec<f64> = params.iter().map(|p| p.value()).collect(); 91 + let n = system.parameter_count(); 92 + let m = system.row_count(); 93 + if m == 0 || n == 0 { 94 + let Ok(dof_n) = u32::try_from(n) else { 95 + unreachable!("parameter count fits in u32 via ParameterIndex") 96 + }; 97 + return DofReport { 98 + dof: DegreesOfFreedom::new(dof_n), 99 + under_constrained: (0..dof_n).map(ParameterIndex::new).collect(), 100 + over_constrained: Vec::new(), 101 + redundant_consistent: Vec::new(), 102 + }; 103 + } 104 + let witness_params = perturb(&current_params, cfg.seed, cfg.witness_scale); 105 + let jacobian = assemble_jacobian(system, &witness_params); 106 + let qr_cols = col_piv_qr(&jacobian, cfg.rank_tolerance); 107 + let transpose = jacobian.transpose(); 108 + let qr_rows = col_piv_qr(&transpose, cfg.rank_tolerance); 109 + let rank = qr_cols.rank; 110 + let Ok(dof_n) = u32::try_from(n.saturating_sub(rank)) else { 111 + unreachable!("rank <= n <= ParameterIndex::MAX") 112 + }; 113 + let under_constrained: Vec<ParameterIndex> = qr_cols.col_perm[rank..] 114 + .iter() 115 + .map(|&c| { 116 + let Ok(cv) = u32::try_from(c) else { 117 + unreachable!("pivoted column index fits in u32 via ParameterIndex") 118 + }; 119 + ParameterIndex::new(cv) 120 + }) 121 + .collect(); 122 + let mut under_sorted = under_constrained; 123 + under_sorted.sort_by_key(|p| p.value()); 124 + 125 + let residuals_at_current = evaluate_residuals(system, &current_params); 126 + let tol = cfg.residual_tolerance.value(); 127 + let (over_constrained, redundant_consistent): (Vec<ResidualIndex>, Vec<ResidualIndex>) = 128 + qr_rows.col_perm[rank..] 129 + .iter() 130 + .map(|&row| { 131 + let Ok(rv) = u32::try_from(row) else { 132 + unreachable!("dependent row index fits in u32 via ResidualIndex") 133 + }; 134 + let value = residuals_at_current[row]; 135 + (ResidualIndex::new(rv), value.abs() <= tol) 136 + }) 137 + .fold( 138 + (Vec::new(), Vec::new()), 139 + |(mut over, mut red), (idx, consistent)| { 140 + if consistent { 141 + red.push(idx); 142 + } else { 143 + over.push(idx); 144 + } 145 + (over, red) 146 + }, 147 + ); 148 + let mut over_sorted = over_constrained; 149 + let mut red_sorted = redundant_consistent; 150 + over_sorted.sort_by_key(|r| r.value()); 151 + red_sorted.sort_by_key(|r| r.value()); 152 + DofReport { 153 + dof: DegreesOfFreedom::new(dof_n), 154 + under_constrained: under_sorted, 155 + over_constrained: over_sorted, 156 + redundant_consistent: red_sorted, 157 + } 158 + } 159 + 160 + fn perturb(params: &[f64], seed: SolverSeed, scale: f64) -> Vec<f64> { 161 + params 162 + .iter() 163 + .scan(WitnessRng::new(seed), |rng, &v| { 164 + let (u, next) = rng.step(); 165 + *rng = next; 166 + Some(v + (u - 0.5) * scale) 167 + }) 168 + .collect() 169 + } 170 + 171 + #[derive(Copy, Clone, Debug)] 172 + struct WitnessRng { 173 + state: u64, 174 + } 175 + 176 + impl WitnessRng { 177 + fn new(seed: SolverSeed) -> Self { 178 + Self { 179 + state: seed.value(), 180 + } 181 + } 182 + 183 + fn step(self) -> (f64, Self) { 184 + let state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); 185 + let mixed = mix64(state); 186 + let mantissa = mixed & 0x000F_FFFF_FFFF_FFFF; 187 + let uniform = f64::from_bits(0x3FF0_0000_0000_0000 | mantissa) - 1.0; 188 + (uniform, Self { state }) 189 + } 190 + } 191 + 192 + fn mix64(x: u64) -> u64 { 193 + let a = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); 194 + let b = (a ^ (a >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); 195 + b ^ (b >> 31) 196 + } 197 + 198 + #[derive(Clone, Debug)] 199 + struct ColPivQr { 200 + rank: usize, 201 + col_perm: Vec<usize>, 202 + } 203 + 204 + fn col_piv_qr(input: &DMatrix<f64>, tol: f64) -> ColPivQr { 205 + let m = input.nrows(); 206 + let n = input.ncols(); 207 + let initial_norms: Vec<f64> = (0..n).map(|j| input.column(j).norm_squared()).collect(); 208 + let initial = QrState { 209 + a: input.clone(), 210 + col_perm: (0..n).collect(), 211 + col_norms: initial_norms, 212 + rank: 0, 213 + }; 214 + let max_steps = m.min(n); 215 + let final_state = (0..max_steps).try_fold(initial, |state, k| state.step(k, tol)); 216 + let state = match final_state { 217 + Ok(s) | Err(s) => s, 218 + }; 219 + ColPivQr { 220 + rank: state.rank, 221 + col_perm: state.col_perm, 222 + } 223 + } 224 + 225 + #[derive(Clone, Debug)] 226 + struct QrState { 227 + a: DMatrix<f64>, 228 + col_perm: Vec<usize>, 229 + col_norms: Vec<f64>, 230 + rank: usize, 231 + } 232 + 233 + impl QrState { 234 + fn step(self, pivot_row: usize, tol: f64) -> Result<Self, Self> { 235 + let QrState { 236 + a: matrix, 237 + mut col_perm, 238 + mut col_norms, 239 + rank, 240 + } = self; 241 + let rows = matrix.nrows(); 242 + let cols = matrix.ncols(); 243 + let (best_col, best_norm2) = col_norms.iter().enumerate().skip(pivot_row).fold( 244 + (pivot_row, f64::NEG_INFINITY), 245 + |(best, best_v), (j, &value)| { 246 + if value > best_v { 247 + (j, value) 248 + } else { 249 + (best, best_v) 250 + } 251 + }, 252 + ); 253 + if best_norm2 <= tol * tol { 254 + return Err(QrState { 255 + a: matrix, 256 + col_perm, 257 + col_norms, 258 + rank, 259 + }); 260 + } 261 + let mut matrix = matrix; 262 + if best_col != pivot_row { 263 + matrix.swap_columns(pivot_row, best_col); 264 + col_perm.swap(pivot_row, best_col); 265 + col_norms.swap(pivot_row, best_col); 266 + } 267 + let tail = rows - pivot_row; 268 + let column: DVector<f64> = (0..tail) 269 + .map(|i| matrix[(pivot_row + i, pivot_row)]) 270 + .collect::<Vec<_>>() 271 + .into(); 272 + let column_norm = column.norm(); 273 + if column_norm <= tol { 274 + return Err(QrState { 275 + a: matrix, 276 + col_perm, 277 + col_norms, 278 + rank, 279 + }); 280 + } 281 + let alpha = if column[0] >= 0.0 { 282 + -column_norm 283 + } else { 284 + column_norm 285 + }; 286 + let reflector: DVector<f64> = column 287 + .iter() 288 + .enumerate() 289 + .map(|(idx, &value)| if idx == 0 { value - alpha } else { value }) 290 + .collect::<Vec<_>>() 291 + .into(); 292 + let reflector_dot = reflector.dot(&reflector); 293 + let applied = if reflector_dot > 0.0 { 294 + let beta = 2.0 / reflector_dot; 295 + (pivot_row..cols).fold(matrix, |mut mat, col| { 296 + let projection: f64 = (0..tail) 297 + .map(|i| reflector[i] * mat[(pivot_row + i, col)]) 298 + .sum(); 299 + let coef = beta * projection; 300 + (0..tail).for_each(|i| mat[(pivot_row + i, col)] -= coef * reflector[i]); 301 + mat 302 + }) 303 + } else { 304 + matrix 305 + }; 306 + let new_norms: Vec<f64> = col_norms 307 + .iter() 308 + .enumerate() 309 + .map(|(col, &prev)| { 310 + if col <= pivot_row || pivot_row + 1 >= rows { 311 + prev 312 + } else { 313 + (0..(tail - 1)) 314 + .map(|i| { 315 + let entry = applied[(pivot_row + 1 + i, col)]; 316 + entry * entry 317 + }) 318 + .sum::<f64>() 319 + } 320 + }) 321 + .collect(); 322 + Ok(QrState { 323 + a: applied, 324 + col_perm, 325 + col_norms: new_norms, 326 + rank: rank + 1, 327 + }) 328 + } 329 + }
+17
crates/bone-solver/src/error.rs
··· 1 + use core::time::Duration; 2 + 3 + use bone_types::{ParameterIndex, SketchItemId, SolverResidual}; 4 + 5 + #[derive(Debug, Clone, PartialEq, thiserror::Error)] 6 + pub enum SolverError { 7 + #[error("no solution found (last residual = {last})")] 8 + NoSolutionFound { last: SolverResidual }, 9 + #[error("sketch is over-defined ({} conflicting items)", conflicts.len())] 10 + OverDefined { conflicts: Vec<SketchItemId> }, 11 + #[error("invalid solution found: jacobian singular at parameter {at}")] 12 + InvalidSolutionFound { at: ParameterIndex }, 13 + #[error("solver budget exhausted after {elapsed:?}")] 14 + Budget { elapsed: Duration }, 15 + } 16 + 17 + pub type Result<T, E = SolverError> = core::result::Result<T, E>;
+146
crates/bone-solver/src/graph.rs
··· 1 + use std::collections::BTreeMap; 2 + 3 + use bone_types::{ParameterIndex, ParentIndex, ResidualIndex}; 4 + 5 + use crate::system::ConstraintSystem; 6 + 7 + #[derive(Clone, Debug, PartialEq, Eq)] 8 + pub struct Component { 9 + parameters: Vec<ParameterIndex>, 10 + residual_parents: Vec<ParentIndex>, 11 + } 12 + 13 + impl Component { 14 + #[must_use] 15 + pub fn parameters(&self) -> &[ParameterIndex] { 16 + &self.parameters 17 + } 18 + 19 + #[must_use] 20 + pub fn residual_parents(&self) -> &[ParentIndex] { 21 + &self.residual_parents 22 + } 23 + 24 + #[must_use] 25 + pub fn residual_rows(&self, system: &ConstraintSystem) -> Vec<ResidualIndex> { 26 + let offsets = system.row_offsets(); 27 + self.residual_parents 28 + .iter() 29 + .flat_map(|&parent| { 30 + let start = offsets[parent.as_usize()].value(); 31 + (0..system.residual_at_parent(parent).rows()).map(move |o| { 32 + let Ok(ov) = u32::try_from(o) else { 33 + unreachable!("residual row count is a small constant") 34 + }; 35 + ResidualIndex::new(start + ov) 36 + }) 37 + }) 38 + .collect() 39 + } 40 + } 41 + 42 + #[derive(Clone, Debug, PartialEq, Eq)] 43 + pub struct Decomposition { 44 + components: Vec<Component>, 45 + } 46 + 47 + impl Decomposition { 48 + #[must_use] 49 + pub fn components(&self) -> &[Component] { 50 + &self.components 51 + } 52 + } 53 + 54 + #[must_use] 55 + pub fn decompose(system: &ConstraintSystem) -> Decomposition { 56 + let n = system.parameter_count(); 57 + let dsu_final = system.residuals().iter().fold(Dsu::new(n), |dsu, r| { 58 + let ps = r.parameters(); 59 + match ps.split_first() { 60 + None => dsu, 61 + Some((&first, rest)) => rest 62 + .iter() 63 + .fold(dsu, |acc, &p| acc.union(first.as_usize(), p.as_usize())), 64 + } 65 + }); 66 + let roots: Vec<usize> = (0..n).map(|p| dsu_final.find(p)).collect(); 67 + let param_groups: BTreeMap<usize, Vec<ParameterIndex>> = 68 + (0..n).fold(BTreeMap::new(), |mut acc, p| { 69 + let Ok(pv) = u32::try_from(p) else { 70 + unreachable!("parameter count stays within ParameterIndex range") 71 + }; 72 + acc.entry(roots[p]) 73 + .or_default() 74 + .push(ParameterIndex::new(pv)); 75 + acc 76 + }); 77 + let parent_groups: BTreeMap<usize, Vec<ParentIndex>> = system 78 + .residuals() 79 + .iter() 80 + .enumerate() 81 + .fold(BTreeMap::new(), |mut acc, (pi, r)| { 82 + if let Some(&first) = r.parameters().first() { 83 + let Ok(piv) = u32::try_from(pi) else { 84 + unreachable!("parent count fits in u32 via ResidualIndex") 85 + }; 86 + acc.entry(roots[first.as_usize()]) 87 + .or_default() 88 + .push(ParentIndex::new(piv)); 89 + } 90 + acc 91 + }); 92 + let mut keyed: Vec<(usize, Component)> = param_groups 93 + .into_iter() 94 + .map(|(root, parameters)| { 95 + let residual_parents = parent_groups.get(&root).cloned().unwrap_or_default(); 96 + let min_param = parameters 97 + .first() 98 + .copied() 99 + .map_or(usize::MAX, ParameterIndex::as_usize); 100 + ( 101 + min_param, 102 + Component { 103 + parameters, 104 + residual_parents, 105 + }, 106 + ) 107 + }) 108 + .collect(); 109 + keyed.sort_by_key(|(min_param, _)| *min_param); 110 + Decomposition { 111 + components: keyed.into_iter().map(|(_, c)| c).collect(), 112 + } 113 + } 114 + 115 + #[derive(Clone, Debug)] 116 + struct Dsu { 117 + parent: Vec<usize>, 118 + } 119 + 120 + impl Dsu { 121 + fn new(n: usize) -> Self { 122 + Self { 123 + parent: (0..n).collect(), 124 + } 125 + } 126 + 127 + fn find(&self, x: usize) -> usize { 128 + std::iter::successors(Some(x), |&i| { 129 + let p = self.parent[i]; 130 + (p != i).then_some(p) 131 + }) 132 + .fold(x, |_, i| i) 133 + } 134 + 135 + fn union(self, a: usize, b: usize) -> Self { 136 + let ra = self.find(a); 137 + let rb = self.find(b); 138 + if ra == rb { 139 + return self; 140 + } 141 + let (lo, hi) = if ra < rb { (ra, rb) } else { (rb, ra) }; 142 + let Self { mut parent } = self; 143 + parent[hi] = lo; 144 + Self { parent } 145 + } 146 + }
+111
crates/bone-solver/src/jacobian.rs
··· 1 + use bone_types::ParameterIndex; 2 + use nalgebra::DMatrix; 3 + 4 + use crate::residual::Triplet; 5 + use crate::system::ConstraintSystem; 6 + 7 + #[derive(Clone, Debug, PartialEq)] 8 + pub struct SparsityPattern { 9 + rows: usize, 10 + cols: usize, 11 + entries: Vec<(usize, ParameterIndex)>, 12 + } 13 + 14 + impl SparsityPattern { 15 + #[must_use] 16 + pub fn rows(&self) -> usize { 17 + self.rows 18 + } 19 + 20 + #[must_use] 21 + pub fn cols(&self) -> usize { 22 + self.cols 23 + } 24 + 25 + #[must_use] 26 + pub fn entries(&self) -> &[(usize, ParameterIndex)] { 27 + &self.entries 28 + } 29 + 30 + #[must_use] 31 + pub fn display(&self) -> String { 32 + let mut grid = vec![vec![false; self.cols]; self.rows]; 33 + self.entries.iter().for_each(|(r, c)| { 34 + grid[*r][c.as_usize()] = true; 35 + }); 36 + grid.into_iter() 37 + .map(|row| { 38 + row.into_iter() 39 + .map(|filled| if filled { 'X' } else { '.' }) 40 + .collect::<String>() 41 + }) 42 + .collect::<Vec<_>>() 43 + .join("\n") 44 + } 45 + } 46 + 47 + #[must_use] 48 + pub fn evaluate_residuals(system: &ConstraintSystem, params: &[f64]) -> Vec<f64> { 49 + let mut out = vec![0.0; system.row_count()]; 50 + let offsets = system.row_offsets(); 51 + system 52 + .residuals() 53 + .iter() 54 + .zip(offsets.iter().copied()) 55 + .for_each(|(r, start)| { 56 + let len = r.rows(); 57 + let begin = start.as_usize(); 58 + r.evaluate(params, &mut out[begin..begin + len]); 59 + }); 60 + out 61 + } 62 + 63 + #[must_use] 64 + pub fn assemble_jacobian(system: &ConstraintSystem, params: &[f64]) -> DMatrix<f64> { 65 + let rows = system.row_count(); 66 + let cols = system.parameter_count(); 67 + let triplets = collect_triplets(system, params); 68 + triplets.into_iter().fold( 69 + DMatrix::<f64>::zeros(rows, cols), 70 + |mut mat, (row, col, val)| { 71 + mat[(row.as_usize(), col.as_usize())] += val; 72 + mat 73 + }, 74 + ) 75 + } 76 + 77 + #[must_use] 78 + pub fn sparsity_pattern(system: &ConstraintSystem) -> SparsityPattern { 79 + let dummy: Vec<f64> = std::iter::successors(Some(1.0_f64), |v| Some(v + 0.25)) 80 + .take(system.parameter_count()) 81 + .collect(); 82 + let triplets = collect_triplets(system, &dummy); 83 + let mut entries: Vec<(usize, ParameterIndex)> = triplets 84 + .into_iter() 85 + .map(|(r, c, _)| (r.as_usize(), c)) 86 + .collect(); 87 + entries.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.value().cmp(&b.1.value()))); 88 + entries.dedup(); 89 + SparsityPattern { 90 + rows: system.row_count(), 91 + cols: system.parameter_count(), 92 + entries, 93 + } 94 + } 95 + 96 + #[must_use] 97 + pub fn jacobian_triplets(system: &ConstraintSystem, params: &[f64]) -> Vec<Triplet> { 98 + collect_triplets(system, params) 99 + } 100 + 101 + fn collect_triplets(system: &ConstraintSystem, params: &[f64]) -> Vec<Triplet> { 102 + let offsets = system.row_offsets(); 103 + system 104 + .residuals() 105 + .iter() 106 + .zip(offsets) 107 + .fold(Vec::new(), |mut triplets, (r, start)| { 108 + r.jacobian(params, start, &mut triplets); 109 + triplets 110 + }) 111 + }
+21
crates/bone-solver/src/lib.rs
··· 1 + pub mod dof; 2 + pub mod error; 3 + pub mod graph; 4 + pub mod jacobian; 5 + pub mod mus; 6 + pub mod newton; 7 + pub mod residual; 8 + pub mod system; 9 + pub mod util; 10 + 11 + pub use dof::{DofConfig, DofReport, analyze_dof, analyze_dof_at}; 12 + pub use error::{Result, SolverError}; 13 + pub use graph::{Component, Decomposition, decompose}; 14 + pub use jacobian::{ 15 + SparsityPattern, assemble_jacobian, evaluate_residuals, jacobian_triplets, sparsity_pattern, 16 + }; 17 + pub use mus::minimal_unsatisfiable_subset; 18 + pub use newton::{NewtonConfig, residual_norm, solve_newton, solve_newton_decomposed}; 19 + pub use residual::{CurveRadius, LineHandle, PointHandle, Residual, Triplet}; 20 + pub use system::{ConstraintSystem, Subsystem}; 21 + pub use util::dedup_preserving_order;
+125
crates/bone-solver/src/mus.rs
··· 1 + use std::collections::BTreeSet; 2 + 3 + use bone_types::{ParentIndex, ResidualIndex}; 4 + 5 + use crate::dof::{DofConfig, analyze_dof_at}; 6 + use crate::graph::decompose; 7 + use crate::newton::{NewtonConfig, solve_newton_decomposed}; 8 + use crate::system::ConstraintSystem; 9 + use crate::util::dedup_preserving_order; 10 + 11 + #[must_use] 12 + pub fn minimal_unsatisfiable_subset( 13 + system: &ConstraintSystem, 14 + over_flagged: &[ResidualIndex], 15 + cfg: DofConfig, 16 + ) -> Vec<ResidualIndex> { 17 + let candidate_parents: Vec<ParentIndex> = dedup_preserving_order( 18 + over_flagged 19 + .iter() 20 + .copied() 21 + .map(|row| system.parent_of_row(row)), 22 + ); 23 + if candidate_parents.is_empty() { 24 + return Vec::new(); 25 + } 26 + let solver_cfg = NewtonConfig::DEFAULT; 27 + let kept_parents = candidate_parents 28 + .iter() 29 + .copied() 30 + .fold( 31 + (BTreeSet::<ParentIndex>::new(), Vec::<ParentIndex>::new()), 32 + |(mut excluded, mut kept), parent| { 33 + let mut trial = excluded.clone(); 34 + trial.insert(parent); 35 + if reduced_is_over(system, &trial, solver_cfg, cfg) { 36 + excluded.insert(parent); 37 + } else { 38 + kept.push(parent); 39 + } 40 + (excluded, kept) 41 + }, 42 + ) 43 + .1; 44 + kept_parents 45 + .into_iter() 46 + .flat_map(|p| system.rows_of_parent(p)) 47 + .filter(|row| over_flagged.contains(row)) 48 + .collect() 49 + } 50 + 51 + fn reduced_is_over( 52 + system: &ConstraintSystem, 53 + exclude: &BTreeSet<ParentIndex>, 54 + newton_cfg: NewtonConfig, 55 + dof_cfg: DofConfig, 56 + ) -> bool { 57 + let sub = system.without_parents(exclude); 58 + if sub.row_count() == 0 { 59 + return false; 60 + } 61 + let decomposition = decompose(&sub); 62 + match solve_newton_decomposed(&sub, &decomposition, newton_cfg) { 63 + Ok(solved) => { 64 + let report = analyze_dof_at(&sub, &solved, dof_cfg); 65 + !report.over_constrained().is_empty() 66 + } 67 + Err(_) => true, 68 + } 69 + } 70 + 71 + #[cfg(test)] 72 + mod tests { 73 + use super::*; 74 + use crate::residual::Residual; 75 + use bone_types::{Parameter, ParameterIndex}; 76 + 77 + fn p(i: u32) -> ParameterIndex { 78 + ParameterIndex::new(i) 79 + } 80 + 81 + fn pin(at: u32, target: f64) -> Residual { 82 + Residual::Pin { 83 + param: p(at), 84 + target, 85 + } 86 + } 87 + 88 + fn over_flagged_after_solve(system: &ConstraintSystem) -> Vec<ResidualIndex> { 89 + let decomposition = decompose(system); 90 + let solved = solve_newton_decomposed(system, &decomposition, NewtonConfig::DEFAULT) 91 + .unwrap_or_else(|_| system.parameters().to_vec()); 92 + analyze_dof_at(system, &solved, DofConfig::DEFAULT) 93 + .over_constrained() 94 + .to_vec() 95 + } 96 + 97 + #[test] 98 + fn three_conflicting_pins_isolate_the_odd_one_out() { 99 + let system = ConstraintSystem::new( 100 + vec![Parameter::new(0.0)], 101 + vec![pin(0, 1.0), pin(0, 2.0), pin(0, 1.0)], 102 + ); 103 + let over = over_flagged_after_solve(&system); 104 + assert!( 105 + !over.is_empty(), 106 + "fixture must produce at least one over row" 107 + ); 108 + let mus = minimal_unsatisfiable_subset(&system, &over, DofConfig::DEFAULT); 109 + assert!( 110 + !mus.is_empty(), 111 + "MUS must keep at least one essential row, got empty", 112 + ); 113 + assert!( 114 + mus.len() <= over.len(), 115 + "MUS must be a subset of over-flagged rows: mus={mus:?} over={over:?}", 116 + ); 117 + } 118 + 119 + #[test] 120 + fn empty_over_flagged_yields_empty_mus() { 121 + let system = ConstraintSystem::new(vec![Parameter::new(0.0)], vec![pin(0, 0.0)]); 122 + let mus = minimal_unsatisfiable_subset(&system, &[], DofConfig::DEFAULT); 123 + assert!(mus.is_empty()); 124 + } 125 + }
+378
crates/bone-solver/src/newton.rs
··· 1 + use std::collections::BTreeMap; 2 + use std::time::{Duration, Instant}; 3 + 4 + use bone_types::{ 5 + BudgetCeiling, NewtonDamping, NewtonStepTolerance, Parameter, ParameterIndex, ResidualIndex, 6 + SolverResidual, 7 + }; 8 + use faer::Mat; 9 + use faer::linalg::solvers::Solve; 10 + use faer::sparse::{SparseColMat, Triplet as FaerTriplet}; 11 + use nalgebra::DVector; 12 + 13 + use crate::error::{Result, SolverError}; 14 + use crate::graph::Decomposition; 15 + use crate::jacobian::{evaluate_residuals, jacobian_triplets}; 16 + use crate::residual::Triplet; 17 + use crate::system::ConstraintSystem; 18 + 19 + #[derive(Copy, Clone, Debug, PartialEq)] 20 + pub struct NewtonConfig { 21 + pub max_iterations: u32, 22 + pub residual_tolerance: SolverResidual, 23 + pub step_tolerance: NewtonStepTolerance, 24 + pub line_search_shrinks: u32, 25 + pub damping: NewtonDamping, 26 + pub budget: Option<BudgetCeiling>, 27 + } 28 + 29 + impl NewtonConfig { 30 + pub const DEFAULT: Self = Self { 31 + max_iterations: 64, 32 + residual_tolerance: SolverResidual::new(1e-10), 33 + step_tolerance: NewtonStepTolerance::DEFAULT, 34 + line_search_shrinks: 20, 35 + damping: NewtonDamping::DEFAULT, 36 + budget: None, 37 + }; 38 + } 39 + 40 + #[must_use] 41 + pub fn residual_norm(residuals: &[f64]) -> SolverResidual { 42 + let sum: f64 = residuals.iter().map(|r| r * r).sum(); 43 + SolverResidual::new(sum.sqrt()) 44 + } 45 + 46 + #[derive(Copy, Clone, Debug)] 47 + struct Stopwatch { 48 + started_at: Instant, 49 + budget: Option<BudgetCeiling>, 50 + } 51 + 52 + impl Stopwatch { 53 + fn start(budget: Option<BudgetCeiling>) -> Self { 54 + Self { 55 + started_at: Instant::now(), 56 + budget, 57 + } 58 + } 59 + 60 + fn check(self) -> Result<(), SolverError> { 61 + let Some(budget) = self.budget else { 62 + return Ok(()); 63 + }; 64 + let elapsed = self.started_at.elapsed(); 65 + if elapsed >= budget.duration() { 66 + Err(SolverError::Budget { elapsed }) 67 + } else { 68 + Ok(()) 69 + } 70 + } 71 + } 72 + 73 + #[must_use] 74 + fn remaining_budget(outer: BudgetCeiling, elapsed: Duration) -> BudgetCeiling { 75 + BudgetCeiling::new(outer.duration().saturating_sub(elapsed)) 76 + } 77 + 78 + struct Iterate { 79 + params: Vec<f64>, 80 + residuals: Vec<f64>, 81 + norm: SolverResidual, 82 + } 83 + 84 + impl Iterate { 85 + fn evaluate(system: &ConstraintSystem, params: Vec<f64>) -> Self { 86 + let residuals = evaluate_residuals(system, &params); 87 + let norm = residual_norm(&residuals); 88 + Self { 89 + params, 90 + residuals, 91 + norm, 92 + } 93 + } 94 + } 95 + 96 + pub fn solve_newton(system: &ConstraintSystem, cfg: NewtonConfig) -> Result<Vec<Parameter>> { 97 + let stopwatch = Stopwatch::start(cfg.budget); 98 + let seed: Vec<f64> = system.parameters().iter().map(|p| p.value()).collect(); 99 + converge( 100 + system, 101 + cfg, 102 + &Iterate::evaluate(system, seed), 103 + cfg.max_iterations, 104 + stopwatch, 105 + ) 106 + } 107 + 108 + pub fn solve_newton_decomposed( 109 + system: &ConstraintSystem, 110 + decomposition: &Decomposition, 111 + cfg: NewtonConfig, 112 + ) -> Result<Vec<Parameter>> { 113 + let stopwatch = Stopwatch::start(cfg.budget); 114 + let seed: Vec<Parameter> = system.parameters().to_vec(); 115 + decomposition 116 + .components() 117 + .iter() 118 + .try_fold(seed, |acc, component| { 119 + stopwatch.check()?; 120 + let sub = system.subsystem(component); 121 + if sub.system().row_count() == 0 { 122 + return Ok(acc); 123 + } 124 + let sub_cfg = NewtonConfig { 125 + budget: cfg 126 + .budget 127 + .map(|b| remaining_budget(b, stopwatch.started_at.elapsed())), 128 + ..cfg 129 + }; 130 + let solved = solve_newton(sub.system(), sub_cfg) 131 + .map_err(|err| lift_subsystem_error(err, sub.param_map()))?; 132 + Ok(splice(acc, sub.param_map(), &solved)) 133 + }) 134 + } 135 + 136 + fn lift_subsystem_error(err: SolverError, param_map: &[ParameterIndex]) -> SolverError { 137 + match err { 138 + SolverError::InvalidSolutionFound { at } => SolverError::InvalidSolutionFound { 139 + at: param_map[at.as_usize()], 140 + }, 141 + other => other, 142 + } 143 + } 144 + 145 + fn splice( 146 + mut whole: Vec<Parameter>, 147 + param_map: &[bone_types::ParameterIndex], 148 + sub: &[Parameter], 149 + ) -> Vec<Parameter> { 150 + param_map 151 + .iter() 152 + .zip(sub.iter().copied()) 153 + .for_each(|(orig, value)| { 154 + whole[orig.as_usize()] = value; 155 + }); 156 + whole 157 + } 158 + 159 + fn converge( 160 + system: &ConstraintSystem, 161 + cfg: NewtonConfig, 162 + state: &Iterate, 163 + remaining: u32, 164 + stopwatch: Stopwatch, 165 + ) -> Result<Vec<Parameter>> { 166 + if state.norm.value() < cfg.residual_tolerance.value() { 167 + return Ok(to_parameters(&state.params)); 168 + } 169 + if remaining == 0 { 170 + return Err(SolverError::NoSolutionFound { last: state.norm }); 171 + } 172 + stopwatch.check()?; 173 + let triplets = jacobian_triplets(system, &state.params); 174 + let step = least_squares_step( 175 + system.parameter_count(), 176 + &triplets, 177 + &state.residuals, 178 + cfg.damping, 179 + )?; 180 + if step.norm() < cfg.step_tolerance.value() { 181 + return Ok(to_parameters(&state.params)); 182 + } 183 + match line_search( 184 + system, 185 + &state.params, 186 + &step, 187 + state.norm, 188 + cfg.line_search_shrinks, 189 + ) { 190 + Some(next) => converge(system, cfg, &next, remaining - 1, stopwatch), 191 + None => Err(SolverError::NoSolutionFound { last: state.norm }), 192 + } 193 + } 194 + 195 + fn to_parameters(values: &[f64]) -> Vec<Parameter> { 196 + values.iter().copied().map(Parameter::new).collect() 197 + } 198 + 199 + fn least_squares_step( 200 + params_len: usize, 201 + triplets: &[Triplet], 202 + residuals: &[f64], 203 + damping: NewtonDamping, 204 + ) -> Result<DVector<f64>> { 205 + let (jtj_triplets, jtr) = 206 + assemble_normal_equations(params_len, triplets, residuals, damping.value()); 207 + let Ok(jtj) = 208 + SparseColMat::<usize, f64>::try_new_from_triplets(params_len, params_len, &jtj_triplets) 209 + else { 210 + unreachable!("normal-equations triplets are built with in-bounds indices") 211 + }; 212 + let lu = jtj.sp_lu().map_err(|err| match err { 213 + faer::sparse::linalg::LuError::SymbolicSingular { index } => { 214 + let Ok(at) = u32::try_from(index) else { 215 + unreachable!("J^T J column count fits in u32 via ParameterIndex") 216 + }; 217 + SolverError::InvalidSolutionFound { 218 + at: ParameterIndex::new(at), 219 + } 220 + } 221 + faer::sparse::linalg::LuError::Generic(_) => { 222 + unreachable!("faer sp_lu returns Generic only on malformed input") 223 + } 224 + })?; 225 + let mut rhs = Mat::<f64>::from_fn(params_len, 1, |i, _| -jtr[i]); 226 + lu.solve_in_place(rhs.as_mut()); 227 + Ok(DVector::from_iterator( 228 + params_len, 229 + (0..params_len).map(|i| rhs[(i, 0)]), 230 + )) 231 + } 232 + 233 + fn assemble_normal_equations( 234 + params_len: usize, 235 + triplets: &[Triplet], 236 + residuals: &[f64], 237 + damping: f64, 238 + ) -> (Vec<FaerTriplet<usize, usize, f64>>, Vec<f64>) { 239 + let rows: BTreeMap<ResidualIndex, Vec<(usize, f64)>> = 240 + triplets 241 + .iter() 242 + .fold(BTreeMap::new(), |mut acc, (row, col, val)| { 243 + acc.entry(*row).or_default().push((col.as_usize(), *val)); 244 + acc 245 + }); 246 + let jtj: BTreeMap<(usize, usize), f64> = rows 247 + .values() 248 + .flat_map(|entries| { 249 + entries 250 + .iter() 251 + .flat_map(move |(i, vi)| entries.iter().map(move |(j, vj)| ((*i, *j), vi * vj))) 252 + }) 253 + .fold(BTreeMap::new(), |mut acc, (key, val)| { 254 + *acc.entry(key).or_insert(0.0) += val; 255 + acc 256 + }); 257 + let damped = (0..params_len).fold(jtj, |mut acc, i| { 258 + *acc.entry((i, i)).or_insert(0.0) += damping; 259 + acc 260 + }); 261 + let jtj_triplets: Vec<FaerTriplet<usize, usize, f64>> = damped 262 + .into_iter() 263 + .map(|((i, j), v)| FaerTriplet::new(i, j, v)) 264 + .collect(); 265 + let jtr = triplets 266 + .iter() 267 + .fold(vec![0.0_f64; params_len], |mut acc, (row, col, val)| { 268 + acc[col.as_usize()] += val * residuals[row.as_usize()]; 269 + acc 270 + }); 271 + (jtj_triplets, jtr) 272 + } 273 + 274 + fn line_search( 275 + system: &ConstraintSystem, 276 + params: &[f64], 277 + step: &DVector<f64>, 278 + baseline: SolverResidual, 279 + max_shrinks: u32, 280 + ) -> Option<Iterate> { 281 + let count = usize::try_from(max_shrinks) 282 + .unwrap_or(usize::MAX) 283 + .saturating_add(1); 284 + std::iter::successors(Some(1.0_f64), |a| Some(a * 0.5)) 285 + .take(count) 286 + .find_map(|alpha| { 287 + let trial: Vec<f64> = params 288 + .iter() 289 + .zip(step.iter()) 290 + .map(|(p, s)| p + alpha * s) 291 + .collect(); 292 + let candidate = Iterate::evaluate(system, trial); 293 + (candidate.norm.value() < baseline.value()).then_some(candidate) 294 + }) 295 + } 296 + 297 + #[cfg(test)] 298 + mod tests { 299 + use super::*; 300 + 301 + #[test] 302 + fn lift_subsystem_error_translates_singular_index() { 303 + let param_map = vec![ 304 + ParameterIndex::new(4), 305 + ParameterIndex::new(9), 306 + ParameterIndex::new(12), 307 + ]; 308 + let local = SolverError::InvalidSolutionFound { 309 + at: ParameterIndex::new(1), 310 + }; 311 + match lift_subsystem_error(local, &param_map) { 312 + SolverError::InvalidSolutionFound { at } => assert_eq!(at, ParameterIndex::new(9)), 313 + other => panic!("expected InvalidSolutionFound, got {other:?}"), 314 + } 315 + } 316 + 317 + #[test] 318 + fn lift_subsystem_error_passes_through_other_variants() { 319 + let param_map = [ParameterIndex::new(2), ParameterIndex::new(5)]; 320 + let no_solution = SolverError::NoSolutionFound { 321 + last: SolverResidual::new(3.5), 322 + }; 323 + match lift_subsystem_error(no_solution, &param_map) { 324 + SolverError::NoSolutionFound { last } => { 325 + assert!((last.value() - 3.5).abs() < f64::EPSILON); 326 + } 327 + other => panic!("expected NoSolutionFound, got {other:?}"), 328 + } 329 + } 330 + 331 + #[test] 332 + fn remaining_budget_subtracts_elapsed() { 333 + let outer = BudgetCeiling::new(Duration::from_millis(10)); 334 + let r = remaining_budget(outer, Duration::from_millis(3)); 335 + assert_eq!(r.duration(), Duration::from_millis(7)); 336 + } 337 + 338 + #[test] 339 + fn remaining_budget_saturates_at_zero_when_overshot() { 340 + let outer = BudgetCeiling::new(Duration::from_millis(10)); 341 + let r = remaining_budget(outer, Duration::from_millis(25)); 342 + assert_eq!(r.duration(), Duration::ZERO); 343 + } 344 + 345 + #[test] 346 + fn decomposed_zero_budget_fires_across_disjoint_components() { 347 + use crate::graph::decompose; 348 + use crate::residual::Residual; 349 + use crate::system::ConstraintSystem; 350 + let system = ConstraintSystem::new( 351 + vec![Parameter::new(0.0), Parameter::new(0.0)], 352 + vec![ 353 + Residual::Pin { 354 + param: ParameterIndex::new(0), 355 + target: 1.0, 356 + }, 357 + Residual::Pin { 358 + param: ParameterIndex::new(1), 359 + target: 2.0, 360 + }, 361 + ], 362 + ); 363 + let decomp = decompose(&system); 364 + assert!( 365 + decomp.components().len() >= 2, 366 + "fixture must decompose into at least two components, got {}", 367 + decomp.components().len(), 368 + ); 369 + let cfg = NewtonConfig { 370 + budget: Some(BudgetCeiling::new(Duration::ZERO)), 371 + ..NewtonConfig::DEFAULT 372 + }; 373 + match solve_newton_decomposed(&system, &decomp, cfg) { 374 + Err(SolverError::Budget { .. }) => {} 375 + other => panic!("expected Budget, got {other:?}"), 376 + } 377 + } 378 + }
+584
crates/bone-solver/src/residual.rs
··· 1 + use bone_types::{ParameterIndex, ResidualIndex}; 2 + 3 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 4 + pub struct PointHandle { 5 + pub x: ParameterIndex, 6 + pub y: ParameterIndex, 7 + } 8 + 9 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 10 + pub struct LineHandle { 11 + pub a: PointHandle, 12 + pub b: PointHandle, 13 + } 14 + 15 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 16 + pub enum CurveRadius { 17 + Explicit { 18 + center: PointHandle, 19 + radius: ParameterIndex, 20 + }, 21 + FromSpoke { 22 + center: PointHandle, 23 + spoke: PointHandle, 24 + }, 25 + } 26 + 27 + impl CurveRadius { 28 + #[must_use] 29 + pub fn center(self) -> PointHandle { 30 + match self { 31 + Self::Explicit { center, .. } | Self::FromSpoke { center, .. } => center, 32 + } 33 + } 34 + } 35 + 36 + #[derive(Clone, Debug, PartialEq)] 37 + pub enum Residual { 38 + Pin { 39 + param: ParameterIndex, 40 + target: f64, 41 + }, 42 + Horizontal(LineHandle), 43 + Vertical(LineHandle), 44 + Parallel(LineHandle, LineHandle), 45 + Perpendicular(LineHandle, LineHandle), 46 + TangentLineCurve { 47 + line: LineHandle, 48 + curve: CurveRadius, 49 + }, 50 + TangentCurveCurve { 51 + a: CurveRadius, 52 + b: CurveRadius, 53 + }, 54 + CoincidentPointPoint(PointHandle, PointHandle), 55 + CoincidentPointLine { 56 + point: PointHandle, 57 + line: LineHandle, 58 + }, 59 + CoincidentPointCurve { 60 + point: PointHandle, 61 + curve: CurveRadius, 62 + }, 63 + EqualLength(LineHandle, LineHandle), 64 + EqualRadius(CurveRadius, CurveRadius), 65 + LinearDistance { 66 + a: PointHandle, 67 + b: PointHandle, 68 + value_mm: f64, 69 + }, 70 + AngularBetweenLines { 71 + a: LineHandle, 72 + b: LineHandle, 73 + angle_rad: f64, 74 + }, 75 + RadiusCurve { 76 + curve: CurveRadius, 77 + value_mm: f64, 78 + }, 79 + } 80 + 81 + pub type Triplet = (ResidualIndex, ParameterIndex, f64); 82 + 83 + impl Residual { 84 + #[must_use] 85 + pub fn rows(&self) -> usize { 86 + match self { 87 + Self::CoincidentPointPoint(..) => 2, 88 + _ => 1, 89 + } 90 + } 91 + 92 + #[must_use] 93 + pub fn parameters(&self) -> Vec<ParameterIndex> { 94 + match *self { 95 + Self::Pin { param, .. } => vec![param], 96 + Self::Horizontal(l) | Self::Vertical(l) => line_params(l), 97 + Self::Parallel(l1, l2) | Self::Perpendicular(l1, l2) | Self::EqualLength(l1, l2) => { 98 + line_params(l1).into_iter().chain(line_params(l2)).collect() 99 + } 100 + Self::TangentLineCurve { line, curve } => line_params(line) 101 + .into_iter() 102 + .chain(curve_params(curve)) 103 + .collect(), 104 + Self::TangentCurveCurve { a, b } | Self::EqualRadius(a, b) => { 105 + curve_params(a).into_iter().chain(curve_params(b)).collect() 106 + } 107 + Self::CoincidentPointPoint(p, q) => { 108 + point_params(p).into_iter().chain(point_params(q)).collect() 109 + } 110 + Self::CoincidentPointLine { point, line } => point_params(point) 111 + .into_iter() 112 + .chain(line_params(line)) 113 + .collect(), 114 + Self::CoincidentPointCurve { point, curve } => point_params(point) 115 + .into_iter() 116 + .chain(curve_params(curve)) 117 + .collect(), 118 + Self::LinearDistance { a, b, .. } => { 119 + point_params(a).into_iter().chain(point_params(b)).collect() 120 + } 121 + Self::AngularBetweenLines { a, b, .. } => { 122 + line_params(a).into_iter().chain(line_params(b)).collect() 123 + } 124 + Self::RadiusCurve { curve, .. } => curve_params(curve), 125 + } 126 + } 127 + 128 + pub fn evaluate(&self, params: &[f64], out: &mut [f64]) { 129 + match *self { 130 + Self::Pin { param, target } => { 131 + out[0] = params[param.as_usize()] - target; 132 + } 133 + Self::Horizontal(l) => out[0] = get(params, l.a.y) - get(params, l.b.y), 134 + Self::Vertical(l) => out[0] = get(params, l.a.x) - get(params, l.b.x), 135 + Self::Parallel(l1, l2) => { 136 + let (d1x, d1y) = delta(params, l1); 137 + let (d2x, d2y) = delta(params, l2); 138 + out[0] = d1x * d2y - d1y * d2x; 139 + } 140 + Self::Perpendicular(l1, l2) => { 141 + let (d1x, d1y) = delta(params, l1); 142 + let (d2x, d2y) = delta(params, l2); 143 + out[0] = d1x * d2x + d1y * d2y; 144 + } 145 + Self::TangentLineCurve { line, curve } => { 146 + let (dx, dy) = delta(params, line); 147 + let cx = get(params, curve.center().x); 148 + let cy = get(params, curve.center().y); 149 + let ex = cx - get(params, line.a.x); 150 + let ey = cy - get(params, line.a.y); 151 + let cross = ex * dy - ey * dx; 152 + let len2 = dx * dx + dy * dy; 153 + let r2 = radius_squared(params, curve); 154 + out[0] = cross * cross - r2 * len2; 155 + } 156 + Self::TangentCurveCurve { a, b } => { 157 + let ax = get(params, a.center().x); 158 + let ay = get(params, a.center().y); 159 + let bx = get(params, b.center().x); 160 + let by = get(params, b.center().y); 161 + let ra = radius_squared(params, a).sqrt(); 162 + let rb = radius_squared(params, b).sqrt(); 163 + let u = ax - bx; 164 + let v = ay - by; 165 + let rsum = ra + rb; 166 + out[0] = u * u + v * v - rsum * rsum; 167 + } 168 + Self::CoincidentPointPoint(p, q) => { 169 + out[0] = get(params, p.x) - get(params, q.x); 170 + out[1] = get(params, p.y) - get(params, q.y); 171 + } 172 + Self::CoincidentPointLine { point, line } => { 173 + let (dx, dy) = delta(params, line); 174 + let ex = get(params, point.x) - get(params, line.a.x); 175 + let ey = get(params, point.y) - get(params, line.a.y); 176 + out[0] = ex * dy - ey * dx; 177 + } 178 + Self::CoincidentPointCurve { point, curve } => { 179 + let cx = get(params, curve.center().x); 180 + let cy = get(params, curve.center().y); 181 + let ex = get(params, point.x) - cx; 182 + let ey = get(params, point.y) - cy; 183 + let r2 = radius_squared(params, curve); 184 + out[0] = ex * ex + ey * ey - r2; 185 + } 186 + Self::EqualLength(l1, l2) => { 187 + let (d1x, d1y) = delta(params, l1); 188 + let (d2x, d2y) = delta(params, l2); 189 + out[0] = d1x * d1x + d1y * d1y - (d2x * d2x + d2y * d2y); 190 + } 191 + Self::EqualRadius(a, b) => { 192 + out[0] = radius_squared(params, a) - radius_squared(params, b); 193 + } 194 + Self::LinearDistance { a, b, value_mm } => { 195 + let dx = get(params, b.x) - get(params, a.x); 196 + let dy = get(params, b.y) - get(params, a.y); 197 + out[0] = dx * dx + dy * dy - value_mm * value_mm; 198 + } 199 + Self::AngularBetweenLines { a, b, angle_rad } => { 200 + let (d1x, d1y) = delta(params, a); 201 + let (d2x, d2y) = delta(params, b); 202 + let dot = d1x * d2x + d1y * d2y; 203 + let cross = d1x * d2y - d1y * d2x; 204 + let (s, c) = angle_rad.sin_cos(); 205 + out[0] = s * dot - c * cross; 206 + } 207 + Self::RadiusCurve { curve, value_mm } => { 208 + out[0] = radius_squared(params, curve) - value_mm * value_mm; 209 + } 210 + } 211 + } 212 + 213 + pub fn jacobian(&self, params: &[f64], row: ResidualIndex, sink: &mut Vec<Triplet>) { 214 + match *self { 215 + Self::Pin { param, .. } => sink.push((row, param, 1.0)), 216 + Self::Horizontal(l) => { 217 + sink.push((row, l.a.y, 1.0)); 218 + sink.push((row, l.b.y, -1.0)); 219 + } 220 + Self::Vertical(l) => { 221 + sink.push((row, l.a.x, 1.0)); 222 + sink.push((row, l.b.x, -1.0)); 223 + } 224 + Self::Parallel(l1, l2) => jacobian_parallel(params, row, l1, l2, sink), 225 + Self::Perpendicular(l1, l2) => jacobian_perpendicular(params, row, l1, l2, sink), 226 + Self::TangentLineCurve { line, curve } => { 227 + tangent_line_curve_jacobian(params, row, line, curve, sink); 228 + } 229 + Self::TangentCurveCurve { a, b } => { 230 + tangent_curve_curve_jacobian(params, row, a, b, sink); 231 + } 232 + Self::CoincidentPointPoint(p, q) => { 233 + let row2 = row.next(); 234 + sink.push((row, p.x, 1.0)); 235 + sink.push((row, q.x, -1.0)); 236 + sink.push((row2, p.y, 1.0)); 237 + sink.push((row2, q.y, -1.0)); 238 + } 239 + Self::CoincidentPointLine { point, line } => { 240 + jacobian_coincident_point_line(params, row, point, line, sink); 241 + } 242 + Self::CoincidentPointCurve { point, curve } => { 243 + jacobian_coincident_point_curve(params, row, point, curve, sink); 244 + } 245 + Self::EqualLength(l1, l2) => jacobian_equal_length(params, row, l1, l2, sink), 246 + Self::EqualRadius(a, b) => { 247 + add_radius_squared_jacobian(params, row, a, sink); 248 + subtract_radius_squared_jacobian(params, row, b, sink); 249 + } 250 + Self::LinearDistance { a, b, .. } => { 251 + jacobian_linear_distance(params, row, a, b, sink); 252 + } 253 + Self::AngularBetweenLines { a, b, angle_rad } => { 254 + jacobian_angular_between_lines(params, row, a, b, angle_rad, sink); 255 + } 256 + Self::RadiusCurve { curve, .. } => { 257 + add_radius_squared_jacobian(params, row, curve, sink); 258 + } 259 + } 260 + } 261 + } 262 + 263 + fn jacobian_parallel( 264 + params: &[f64], 265 + row: ResidualIndex, 266 + l1: LineHandle, 267 + l2: LineHandle, 268 + sink: &mut Vec<Triplet>, 269 + ) { 270 + let (d1x, d1y) = delta(params, l1); 271 + let (d2x, d2y) = delta(params, l2); 272 + sink.push((row, l1.a.x, -d2y)); 273 + sink.push((row, l1.b.x, d2y)); 274 + sink.push((row, l1.a.y, d2x)); 275 + sink.push((row, l1.b.y, -d2x)); 276 + sink.push((row, l2.a.x, d1y)); 277 + sink.push((row, l2.b.x, -d1y)); 278 + sink.push((row, l2.a.y, -d1x)); 279 + sink.push((row, l2.b.y, d1x)); 280 + } 281 + 282 + fn jacobian_perpendicular( 283 + params: &[f64], 284 + row: ResidualIndex, 285 + l1: LineHandle, 286 + l2: LineHandle, 287 + sink: &mut Vec<Triplet>, 288 + ) { 289 + let (d1x, d1y) = delta(params, l1); 290 + let (d2x, d2y) = delta(params, l2); 291 + sink.push((row, l1.a.x, -d2x)); 292 + sink.push((row, l1.b.x, d2x)); 293 + sink.push((row, l1.a.y, -d2y)); 294 + sink.push((row, l1.b.y, d2y)); 295 + sink.push((row, l2.a.x, -d1x)); 296 + sink.push((row, l2.b.x, d1x)); 297 + sink.push((row, l2.a.y, -d1y)); 298 + sink.push((row, l2.b.y, d1y)); 299 + } 300 + 301 + fn jacobian_coincident_point_line( 302 + params: &[f64], 303 + row: ResidualIndex, 304 + point: PointHandle, 305 + line: LineHandle, 306 + sink: &mut Vec<Triplet>, 307 + ) { 308 + let (dx, dy) = delta(params, line); 309 + let ex = get(params, point.x) - get(params, line.a.x); 310 + let ey = get(params, point.y) - get(params, line.a.y); 311 + sink.push((row, point.x, dy)); 312 + sink.push((row, point.y, -dx)); 313 + sink.push((row, line.a.x, ey - dy)); 314 + sink.push((row, line.a.y, dx - ex)); 315 + sink.push((row, line.b.x, -ey)); 316 + sink.push((row, line.b.y, ex)); 317 + } 318 + 319 + fn jacobian_coincident_point_curve( 320 + params: &[f64], 321 + row: ResidualIndex, 322 + point: PointHandle, 323 + curve: CurveRadius, 324 + sink: &mut Vec<Triplet>, 325 + ) { 326 + let center = curve.center(); 327 + let ex = get(params, point.x) - get(params, center.x); 328 + let ey = get(params, point.y) - get(params, center.y); 329 + sink.push((row, point.x, 2.0 * ex)); 330 + sink.push((row, point.y, 2.0 * ey)); 331 + sink.push((row, center.x, -2.0 * ex)); 332 + sink.push((row, center.y, -2.0 * ey)); 333 + subtract_radius_squared_jacobian(params, row, curve, sink); 334 + } 335 + 336 + fn jacobian_equal_length( 337 + params: &[f64], 338 + row: ResidualIndex, 339 + l1: LineHandle, 340 + l2: LineHandle, 341 + sink: &mut Vec<Triplet>, 342 + ) { 343 + let (d1x, d1y) = delta(params, l1); 344 + let (d2x, d2y) = delta(params, l2); 345 + sink.push((row, l1.a.x, -2.0 * d1x)); 346 + sink.push((row, l1.b.x, 2.0 * d1x)); 347 + sink.push((row, l1.a.y, -2.0 * d1y)); 348 + sink.push((row, l1.b.y, 2.0 * d1y)); 349 + sink.push((row, l2.a.x, 2.0 * d2x)); 350 + sink.push((row, l2.b.x, -2.0 * d2x)); 351 + sink.push((row, l2.a.y, 2.0 * d2y)); 352 + sink.push((row, l2.b.y, -2.0 * d2y)); 353 + } 354 + 355 + fn jacobian_linear_distance( 356 + params: &[f64], 357 + row: ResidualIndex, 358 + a: PointHandle, 359 + b: PointHandle, 360 + sink: &mut Vec<Triplet>, 361 + ) { 362 + let dx = get(params, b.x) - get(params, a.x); 363 + let dy = get(params, b.y) - get(params, a.y); 364 + sink.push((row, a.x, -2.0 * dx)); 365 + sink.push((row, a.y, -2.0 * dy)); 366 + sink.push((row, b.x, 2.0 * dx)); 367 + sink.push((row, b.y, 2.0 * dy)); 368 + } 369 + 370 + fn jacobian_angular_between_lines( 371 + params: &[f64], 372 + row: ResidualIndex, 373 + a: LineHandle, 374 + b: LineHandle, 375 + angle_rad: f64, 376 + sink: &mut Vec<Triplet>, 377 + ) { 378 + let (d1x, d1y) = delta(params, a); 379 + let (d2x, d2y) = delta(params, b); 380 + let (sin_t, cos_t) = angle_rad.sin_cos(); 381 + let d1_partial_x = sin_t * d2x - cos_t * d2y; 382 + let d1_partial_y = sin_t * d2y + cos_t * d2x; 383 + let d2_partial_x = sin_t * d1x + cos_t * d1y; 384 + let d2_partial_y = sin_t * d1y - cos_t * d1x; 385 + sink.push((row, a.a.x, -d1_partial_x)); 386 + sink.push((row, a.b.x, d1_partial_x)); 387 + sink.push((row, a.a.y, -d1_partial_y)); 388 + sink.push((row, a.b.y, d1_partial_y)); 389 + sink.push((row, b.a.x, -d2_partial_x)); 390 + sink.push((row, b.b.x, d2_partial_x)); 391 + sink.push((row, b.a.y, -d2_partial_y)); 392 + sink.push((row, b.b.y, d2_partial_y)); 393 + } 394 + 395 + fn line_params(l: LineHandle) -> Vec<ParameterIndex> { 396 + vec![l.a.x, l.a.y, l.b.x, l.b.y] 397 + } 398 + 399 + fn point_params(p: PointHandle) -> Vec<ParameterIndex> { 400 + vec![p.x, p.y] 401 + } 402 + 403 + fn curve_params(c: CurveRadius) -> Vec<ParameterIndex> { 404 + match c { 405 + CurveRadius::Explicit { center, radius } => vec![center.x, center.y, radius], 406 + CurveRadius::FromSpoke { center, spoke } => vec![center.x, center.y, spoke.x, spoke.y], 407 + } 408 + } 409 + 410 + fn get(params: &[f64], idx: ParameterIndex) -> f64 { 411 + params[idx.as_usize()] 412 + } 413 + 414 + fn delta(params: &[f64], line: LineHandle) -> (f64, f64) { 415 + ( 416 + get(params, line.b.x) - get(params, line.a.x), 417 + get(params, line.b.y) - get(params, line.a.y), 418 + ) 419 + } 420 + 421 + fn radius_squared(params: &[f64], curve: CurveRadius) -> f64 { 422 + match curve { 423 + CurveRadius::Explicit { radius, .. } => { 424 + let r = get(params, radius); 425 + r * r 426 + } 427 + CurveRadius::FromSpoke { center, spoke } => { 428 + let dx = get(params, spoke.x) - get(params, center.x); 429 + let dy = get(params, spoke.y) - get(params, center.y); 430 + dx * dx + dy * dy 431 + } 432 + } 433 + } 434 + 435 + fn add_radius_squared_jacobian( 436 + params: &[f64], 437 + row: ResidualIndex, 438 + curve: CurveRadius, 439 + sink: &mut Vec<Triplet>, 440 + ) { 441 + match curve { 442 + CurveRadius::Explicit { radius, .. } => { 443 + sink.push((row, radius, 2.0 * get(params, radius))); 444 + } 445 + CurveRadius::FromSpoke { center, spoke } => { 446 + let dx = get(params, spoke.x) - get(params, center.x); 447 + let dy = get(params, spoke.y) - get(params, center.y); 448 + sink.push((row, spoke.x, 2.0 * dx)); 449 + sink.push((row, spoke.y, 2.0 * dy)); 450 + sink.push((row, center.x, -2.0 * dx)); 451 + sink.push((row, center.y, -2.0 * dy)); 452 + } 453 + } 454 + } 455 + 456 + fn subtract_radius_squared_jacobian( 457 + params: &[f64], 458 + row: ResidualIndex, 459 + curve: CurveRadius, 460 + sink: &mut Vec<Triplet>, 461 + ) { 462 + match curve { 463 + CurveRadius::Explicit { radius, .. } => { 464 + sink.push((row, radius, -2.0 * get(params, radius))); 465 + } 466 + CurveRadius::FromSpoke { center, spoke } => { 467 + let dx = get(params, spoke.x) - get(params, center.x); 468 + let dy = get(params, spoke.y) - get(params, center.y); 469 + sink.push((row, spoke.x, -2.0 * dx)); 470 + sink.push((row, spoke.y, -2.0 * dy)); 471 + sink.push((row, center.x, 2.0 * dx)); 472 + sink.push((row, center.y, 2.0 * dy)); 473 + } 474 + } 475 + } 476 + 477 + fn tangent_line_curve_jacobian( 478 + params: &[f64], 479 + row: ResidualIndex, 480 + line: LineHandle, 481 + curve: CurveRadius, 482 + sink: &mut Vec<Triplet>, 483 + ) { 484 + let (dx, dy) = delta(params, line); 485 + let cx = get(params, curve.center().x); 486 + let cy = get(params, curve.center().y); 487 + let ex = cx - get(params, line.a.x); 488 + let ey = cy - get(params, line.a.y); 489 + let cross = ex * dy - ey * dx; 490 + let len2 = dx * dx + dy * dy; 491 + let r2 = radius_squared(params, curve); 492 + let two_cross = 2.0 * cross; 493 + 494 + sink.push(( 495 + row, 496 + line.a.x, 497 + two_cross * (cy - get(params, line.b.y)) + r2 * 2.0 * dx, 498 + )); 499 + sink.push(( 500 + row, 501 + line.a.y, 502 + two_cross * (get(params, line.b.x) - cx) + r2 * 2.0 * dy, 503 + )); 504 + sink.push(( 505 + row, 506 + line.b.x, 507 + two_cross * (get(params, line.a.y) - cy) - r2 * 2.0 * dx, 508 + )); 509 + sink.push(( 510 + row, 511 + line.b.y, 512 + two_cross * (cx - get(params, line.a.x)) - r2 * 2.0 * dy, 513 + )); 514 + sink.push((row, curve.center().x, two_cross * dy)); 515 + sink.push((row, curve.center().y, -two_cross * dx)); 516 + 517 + let neg_len2 = -len2; 518 + match curve { 519 + CurveRadius::Explicit { radius, .. } => { 520 + sink.push((row, radius, neg_len2 * 2.0 * get(params, radius))); 521 + } 522 + CurveRadius::FromSpoke { center, spoke } => { 523 + let sdx = get(params, spoke.x) - get(params, center.x); 524 + let sdy = get(params, spoke.y) - get(params, center.y); 525 + sink.push((row, spoke.x, neg_len2 * 2.0 * sdx)); 526 + sink.push((row, spoke.y, neg_len2 * 2.0 * sdy)); 527 + sink.push((row, center.x, neg_len2 * -2.0 * sdx)); 528 + sink.push((row, center.y, neg_len2 * -2.0 * sdy)); 529 + } 530 + } 531 + } 532 + 533 + fn tangent_curve_curve_jacobian( 534 + params: &[f64], 535 + row: ResidualIndex, 536 + a: CurveRadius, 537 + b: CurveRadius, 538 + sink: &mut Vec<Triplet>, 539 + ) { 540 + let ra = radius_squared(params, a).sqrt(); 541 + let rb = radius_squared(params, b).sqrt(); 542 + let rsum = ra + rb; 543 + let u = get(params, a.center().x) - get(params, b.center().x); 544 + let v = get(params, a.center().y) - get(params, b.center().y); 545 + 546 + sink.push((row, a.center().x, 2.0 * u)); 547 + sink.push((row, a.center().y, 2.0 * v)); 548 + sink.push((row, b.center().x, -2.0 * u)); 549 + sink.push((row, b.center().y, -2.0 * v)); 550 + 551 + let coef = -2.0 * rsum; 552 + push_radius_linear_jacobian(params, row, a, coef, sink); 553 + push_radius_linear_jacobian(params, row, b, coef, sink); 554 + } 555 + 556 + fn push_radius_linear_jacobian( 557 + params: &[f64], 558 + row: ResidualIndex, 559 + curve: CurveRadius, 560 + coef: f64, 561 + sink: &mut Vec<Triplet>, 562 + ) { 563 + if coef == 0.0 { 564 + return; 565 + } 566 + match curve { 567 + CurveRadius::Explicit { radius, .. } => { 568 + sink.push((row, radius, coef)); 569 + } 570 + CurveRadius::FromSpoke { center, spoke } => { 571 + let dx = get(params, spoke.x) - get(params, center.x); 572 + let dy = get(params, spoke.y) - get(params, center.y); 573 + let len = (dx * dx + dy * dy).sqrt(); 574 + if len == 0.0 { 575 + return; 576 + } 577 + let k = coef / len; 578 + sink.push((row, spoke.x, k * dx)); 579 + sink.push((row, spoke.y, k * dy)); 580 + sink.push((row, center.x, -k * dx)); 581 + sink.push((row, center.y, -k * dy)); 582 + } 583 + } 584 + }
+254
crates/bone-solver/src/system.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + 3 + use bone_types::{Parameter, ParameterIndex, ParentIndex, ResidualIndex}; 4 + 5 + use crate::graph::Component; 6 + use crate::residual::{CurveRadius, LineHandle, PointHandle, Residual}; 7 + 8 + #[derive(Clone, Debug, PartialEq)] 9 + pub struct ConstraintSystem { 10 + parameters: Vec<Parameter>, 11 + residuals: Vec<Residual>, 12 + } 13 + 14 + impl ConstraintSystem { 15 + #[must_use] 16 + pub fn new(parameters: Vec<Parameter>, residuals: Vec<Residual>) -> Self { 17 + Self { 18 + parameters, 19 + residuals, 20 + } 21 + } 22 + 23 + #[must_use] 24 + pub fn parameters(&self) -> &[Parameter] { 25 + &self.parameters 26 + } 27 + 28 + #[must_use] 29 + pub fn residuals(&self) -> &[Residual] { 30 + &self.residuals 31 + } 32 + 33 + #[must_use] 34 + pub fn parameter_count(&self) -> usize { 35 + self.parameters.len() 36 + } 37 + 38 + #[must_use] 39 + pub fn row_count(&self) -> usize { 40 + self.residuals.iter().map(Residual::rows).sum() 41 + } 42 + 43 + #[must_use] 44 + pub fn row_offsets(&self) -> Vec<ResidualIndex> { 45 + self.residuals 46 + .iter() 47 + .scan(0u32, |acc, r| { 48 + let start = ResidualIndex::new(*acc); 49 + let Ok(n) = u32::try_from(r.rows()) else { 50 + unreachable!("Residual::rows is bounded to a small constant") 51 + }; 52 + let Some(next) = acc.checked_add(n) else { 53 + unreachable!("row count fits in u32 by construction of ResidualIndex") 54 + }; 55 + *acc = next; 56 + Some(start) 57 + }) 58 + .collect() 59 + } 60 + 61 + #[must_use] 62 + pub fn parameter_at(&self, idx: ParameterIndex) -> Parameter { 63 + self.parameters[idx.as_usize()] 64 + } 65 + 66 + #[must_use] 67 + pub fn residual_at_parent(&self, parent: ParentIndex) -> &Residual { 68 + &self.residuals[parent.as_usize()] 69 + } 70 + 71 + #[must_use] 72 + pub fn with_extra_residuals(&self, extra: Vec<Residual>) -> Self { 73 + let mut residuals = self.residuals.clone(); 74 + residuals.extend(extra); 75 + Self { 76 + parameters: self.parameters.clone(), 77 + residuals, 78 + } 79 + } 80 + 81 + #[must_use] 82 + pub fn parent_of_row(&self, row: ResidualIndex) -> ParentIndex { 83 + let target = row.as_usize(); 84 + let found = self 85 + .residuals 86 + .iter() 87 + .scan(0usize, |acc, r| { 88 + let start = *acc; 89 + *acc += r.rows(); 90 + Some((start, *acc)) 91 + }) 92 + .position(|(begin, end)| target >= begin && target < end); 93 + let Some(idx) = found else { 94 + unreachable!("ResidualIndex {row} is past the residual row count") 95 + }; 96 + let Ok(iv) = u32::try_from(idx) else { 97 + unreachable!("parent count fits in u32 via ResidualIndex") 98 + }; 99 + ParentIndex::new(iv) 100 + } 101 + 102 + #[must_use] 103 + pub fn rows_of_parent(&self, parent: ParentIndex) -> Vec<ResidualIndex> { 104 + let idx = parent.as_usize(); 105 + let Some(residual) = self.residuals.get(idx) else { 106 + unreachable!("ParentIndex {parent} out of range for this ConstraintSystem") 107 + }; 108 + let start_usize: usize = self.residuals[..idx].iter().map(Residual::rows).sum(); 109 + let Ok(start) = u32::try_from(start_usize) else { 110 + unreachable!("row count fits in u32 via ResidualIndex") 111 + }; 112 + (0..residual.rows()) 113 + .map(|o| { 114 + let Ok(ov) = u32::try_from(o) else { 115 + unreachable!("residual row count is a small constant") 116 + }; 117 + ResidualIndex::new(start + ov) 118 + }) 119 + .collect() 120 + } 121 + 122 + #[must_use] 123 + pub fn without_parents(&self, exclude: &BTreeSet<ParentIndex>) -> Self { 124 + let mask: BTreeSet<usize> = exclude.iter().copied().map(ParentIndex::as_usize).collect(); 125 + let residuals = self 126 + .residuals 127 + .iter() 128 + .enumerate() 129 + .filter(|(i, _)| !mask.contains(i)) 130 + .map(|(_, r)| r.clone()) 131 + .collect(); 132 + Self { 133 + parameters: self.parameters.clone(), 134 + residuals, 135 + } 136 + } 137 + 138 + #[must_use] 139 + pub fn subsystem(&self, component: &Component) -> Subsystem { 140 + let orig_params: &[ParameterIndex] = component.parameters(); 141 + let remap: BTreeMap<ParameterIndex, ParameterIndex> = orig_params 142 + .iter() 143 + .copied() 144 + .enumerate() 145 + .map(|(i, orig)| { 146 + let Ok(iv) = u32::try_from(i) else { 147 + unreachable!("component parameter count fits in u32 by construction") 148 + }; 149 + (orig, ParameterIndex::new(iv)) 150 + }) 151 + .collect(); 152 + let parameters: Vec<Parameter> = orig_params 153 + .iter() 154 + .map(|p| self.parameters[p.as_usize()]) 155 + .collect(); 156 + let residuals: Vec<Residual> = component 157 + .residual_parents() 158 + .iter() 159 + .map(|&parent| remap_residual(self.residual_at_parent(parent), &remap)) 160 + .collect(); 161 + Subsystem { 162 + system: ConstraintSystem::new(parameters, residuals), 163 + param_map: orig_params.to_vec(), 164 + } 165 + } 166 + } 167 + 168 + #[derive(Clone, Debug)] 169 + pub struct Subsystem { 170 + system: ConstraintSystem, 171 + param_map: Vec<ParameterIndex>, 172 + } 173 + 174 + impl Subsystem { 175 + #[must_use] 176 + pub fn system(&self) -> &ConstraintSystem { 177 + &self.system 178 + } 179 + 180 + #[must_use] 181 + pub fn param_map(&self) -> &[ParameterIndex] { 182 + &self.param_map 183 + } 184 + } 185 + 186 + fn remap_residual(r: &Residual, m: &BTreeMap<ParameterIndex, ParameterIndex>) -> Residual { 187 + let idx = |p: ParameterIndex| -> ParameterIndex { 188 + let Some(&mapped) = m.get(&p) else { 189 + unreachable!("residual parameter missing from component remap (graph bug)") 190 + }; 191 + mapped 192 + }; 193 + let point = |p: PointHandle| PointHandle { 194 + x: idx(p.x), 195 + y: idx(p.y), 196 + }; 197 + let line = |l: LineHandle| LineHandle { 198 + a: point(l.a), 199 + b: point(l.b), 200 + }; 201 + let curve = |c: CurveRadius| match c { 202 + CurveRadius::Explicit { center, radius } => CurveRadius::Explicit { 203 + center: point(center), 204 + radius: idx(radius), 205 + }, 206 + CurveRadius::FromSpoke { center, spoke } => CurveRadius::FromSpoke { 207 + center: point(center), 208 + spoke: point(spoke), 209 + }, 210 + }; 211 + match *r { 212 + Residual::Pin { param, target } => Residual::Pin { 213 + param: idx(param), 214 + target, 215 + }, 216 + Residual::Horizontal(l) => Residual::Horizontal(line(l)), 217 + Residual::Vertical(l) => Residual::Vertical(line(l)), 218 + Residual::Parallel(a, b) => Residual::Parallel(line(a), line(b)), 219 + Residual::Perpendicular(a, b) => Residual::Perpendicular(line(a), line(b)), 220 + Residual::TangentLineCurve { line: l, curve: c } => Residual::TangentLineCurve { 221 + line: line(l), 222 + curve: curve(c), 223 + }, 224 + Residual::TangentCurveCurve { a, b } => Residual::TangentCurveCurve { 225 + a: curve(a), 226 + b: curve(b), 227 + }, 228 + Residual::CoincidentPointPoint(p, q) => Residual::CoincidentPointPoint(point(p), point(q)), 229 + Residual::CoincidentPointLine { point: p, line: l } => Residual::CoincidentPointLine { 230 + point: point(p), 231 + line: line(l), 232 + }, 233 + Residual::CoincidentPointCurve { point: p, curve: c } => Residual::CoincidentPointCurve { 234 + point: point(p), 235 + curve: curve(c), 236 + }, 237 + Residual::EqualLength(a, b) => Residual::EqualLength(line(a), line(b)), 238 + Residual::EqualRadius(a, b) => Residual::EqualRadius(curve(a), curve(b)), 239 + Residual::LinearDistance { a, b, value_mm } => Residual::LinearDistance { 240 + a: point(a), 241 + b: point(b), 242 + value_mm, 243 + }, 244 + Residual::AngularBetweenLines { a, b, angle_rad } => Residual::AngularBetweenLines { 245 + a: line(a), 246 + b: line(b), 247 + angle_rad, 248 + }, 249 + Residual::RadiusCurve { curve: c, value_mm } => Residual::RadiusCurve { 250 + curve: curve(c), 251 + value_mm, 252 + }, 253 + } 254 + }
+21
crates/bone-solver/src/util.rs
··· 1 + use std::collections::BTreeSet; 2 + 3 + #[must_use] 4 + pub fn dedup_preserving_order<T, I>(items: I) -> Vec<T> 5 + where 6 + T: Copy + Ord, 7 + I: IntoIterator<Item = T>, 8 + { 9 + items 10 + .into_iter() 11 + .fold( 12 + (Vec::new(), BTreeSet::<T>::new()), 13 + |(mut acc, mut seen), item| { 14 + if seen.insert(item) { 15 + acc.push(item); 16 + } 17 + (acc, seen) 18 + }, 19 + ) 20 + .0 21 + }
+196
crates/bone-solver/tests/core.rs
··· 1 + use bone_solver::{ 2 + ConstraintSystem, LineHandle, NewtonConfig, PointHandle, Residual, SolverError, 3 + assemble_jacobian, decompose, evaluate_residuals, jacobian_triplets, residual_norm, 4 + solve_newton, solve_newton_decomposed, sparsity_pattern, 5 + }; 6 + use bone_types::{NewtonDamping, Parameter, ParameterIndex, ResidualIndex, SolverResidual}; 7 + 8 + fn p(idx: u32) -> ParameterIndex { 9 + ParameterIndex::new(idx) 10 + } 11 + 12 + fn point(x: u32, y: u32) -> PointHandle { 13 + PointHandle { x: p(x), y: p(y) } 14 + } 15 + 16 + fn parameters(values: &[f64]) -> Vec<Parameter> { 17 + values.iter().copied().map(Parameter::new).collect() 18 + } 19 + 20 + #[test] 21 + fn horizontal_residual_row_and_jacobian_match_hand_derivation() { 22 + let system = ConstraintSystem::new( 23 + parameters(&[0.0, 1.5, 3.0, 4.0]), 24 + vec![Residual::Horizontal(LineHandle { 25 + a: point(0, 1), 26 + b: point(2, 3), 27 + })], 28 + ); 29 + assert_eq!(system.row_count(), 1); 30 + let values = evaluate_residuals(&system, &[0.0, 1.5, 3.0, 4.0]); 31 + assert!((values[0] - (1.5 - 4.0)).abs() < 1e-15); 32 + let triplets = jacobian_triplets(&system, &[0.0, 1.5, 3.0, 4.0]); 33 + assert_eq!(triplets.len(), 2); 34 + let zero = ResidualIndex::new(0); 35 + assert!(triplets.contains(&(zero, p(1), 1.0))); 36 + assert!(triplets.contains(&(zero, p(3), -1.0))); 37 + } 38 + 39 + #[test] 40 + fn pin_solves_in_one_newton_step() { 41 + let system = ConstraintSystem::new( 42 + parameters(&[3.0]), 43 + vec![Residual::Pin { 44 + param: p(0), 45 + target: 7.0, 46 + }], 47 + ); 48 + let Ok(out) = solve_newton(&system, NewtonConfig::DEFAULT) else { 49 + panic!("pin is linear, must converge"); 50 + }; 51 + assert!((out[0].value() - 7.0).abs() < 1e-9); 52 + } 53 + 54 + #[test] 55 + fn coincident_point_point_is_two_row_residual() { 56 + let system = ConstraintSystem::new( 57 + parameters(&[0.0, 0.0, 1.0, 2.0]), 58 + vec![Residual::CoincidentPointPoint(point(0, 1), point(2, 3))], 59 + ); 60 + assert_eq!(system.row_count(), 2); 61 + let r = evaluate_residuals(&system, &[0.0, 0.0, 1.0, 2.0]); 62 + assert_eq!(r.len(), 2); 63 + assert!((r[0] - (0.0 - 1.0)).abs() < 1e-15); 64 + assert!((r[1] - (0.0 - 2.0)).abs() < 1e-15); 65 + } 66 + 67 + #[test] 68 + fn sparsity_pattern_drops_duplicate_positions() { 69 + let system = ConstraintSystem::new( 70 + parameters(&[0.0, 0.0]), 71 + vec![Residual::Pin { 72 + param: p(0), 73 + target: 1.0, 74 + }], 75 + ); 76 + let pattern = sparsity_pattern(&system); 77 + assert_eq!(pattern.rows(), 1); 78 + assert_eq!(pattern.cols(), 2); 79 + assert_eq!(pattern.entries().len(), 1); 80 + } 81 + 82 + #[test] 83 + fn default_damping_regularises_rank_deficient_jacobian() { 84 + let system = ConstraintSystem::new( 85 + parameters(&[0.0, 3.0, 5.0, 7.0]), 86 + vec![Residual::Horizontal(LineHandle { 87 + a: point(0, 1), 88 + b: point(2, 3), 89 + })], 90 + ); 91 + let Ok(out) = solve_newton(&system, NewtonConfig::DEFAULT) else { 92 + panic!("default damping must regularise rank-deficient J^T J"); 93 + }; 94 + assert!((out[1].value() - out[3].value()).abs() < 1e-9); 95 + } 96 + 97 + #[test] 98 + fn zero_damping_on_rank_deficient_jacobian_does_not_silently_succeed() { 99 + let system = ConstraintSystem::new( 100 + parameters(&[0.0, 3.0, 5.0, 7.0]), 101 + vec![Residual::Horizontal(LineHandle { 102 + a: point(0, 1), 103 + b: point(2, 3), 104 + })], 105 + ); 106 + let cfg = NewtonConfig { 107 + damping: NewtonDamping::new(0.0), 108 + ..NewtonConfig::DEFAULT 109 + }; 110 + let Err(err) = solve_newton(&system, cfg) else { 111 + panic!("damping=0 on rank-deficient system must surface an error"); 112 + }; 113 + assert!( 114 + matches!( 115 + err, 116 + SolverError::InvalidSolutionFound { .. } | SolverError::NoSolutionFound { .. } 117 + ), 118 + "expected InvalidSolutionFound or NoSolutionFound, got {err:?}" 119 + ); 120 + } 121 + 122 + #[test] 123 + fn residual_norm_matches_euclidean_length() { 124 + let out = evaluate_residuals( 125 + &ConstraintSystem::new( 126 + parameters(&[3.0]), 127 + vec![Residual::Pin { 128 + param: p(0), 129 + target: 0.0, 130 + }], 131 + ), 132 + &[3.0], 133 + ); 134 + let norm = residual_norm(&out); 135 + assert!((norm.value() - 3.0).abs() < 1e-15); 136 + let _ = SolverResidual::new(norm.value()); 137 + } 138 + 139 + #[test] 140 + fn decomposed_singular_index_is_translated_to_global_parameter() { 141 + let pins: Vec<Residual> = (0..10) 142 + .map(|i| Residual::Pin { 143 + param: p(i), 144 + target: 0.0, 145 + }) 146 + .collect(); 147 + let residuals: Vec<Residual> = pins 148 + .into_iter() 149 + .chain(std::iter::once(Residual::Horizontal(LineHandle { 150 + a: point(10, 11), 151 + b: point(12, 13), 152 + }))) 153 + .collect(); 154 + let seed = [0.0; 10] 155 + .iter() 156 + .copied() 157 + .chain([0.0, 5.0, 1.0, 7.0]) 158 + .collect::<Vec<_>>(); 159 + let system = ConstraintSystem::new(parameters(&seed), residuals); 160 + let decomp = decompose(&system); 161 + let cfg = NewtonConfig { 162 + damping: NewtonDamping::new(0.0), 163 + ..NewtonConfig::DEFAULT 164 + }; 165 + match solve_newton_decomposed(&system, &decomp, cfg) { 166 + Err(SolverError::InvalidSolutionFound { at }) => { 167 + assert!( 168 + at.as_usize() >= 10, 169 + "expected global parameter index (>=10), got {at:?}" 170 + ); 171 + } 172 + Err(SolverError::NoSolutionFound { .. }) => {} 173 + other => panic!("expected InvalidSolutionFound or NoSolutionFound, got {other:?}"), 174 + } 175 + } 176 + 177 + #[test] 178 + fn assemble_jacobian_and_triplets_agree_on_dense_shape() { 179 + let system = ConstraintSystem::new( 180 + parameters(&[0.0, 0.0, 2.0, 0.0]), 181 + vec![Residual::Horizontal(LineHandle { 182 + a: point(0, 1), 183 + b: point(2, 3), 184 + })], 185 + ); 186 + let params = vec![0.0, 0.0, 2.0, 0.0]; 187 + let dense = assemble_jacobian(&system, &params); 188 + assert_eq!(dense.nrows(), 1); 189 + assert_eq!(dense.ncols(), 4); 190 + let triplets = jacobian_triplets(&system, &params); 191 + let sum: f64 = triplets 192 + .iter() 193 + .map(|(r, c, v)| dense[(r.as_usize(), c.as_usize())] - v) 194 + .sum(); 195 + assert!(sum.abs() < 1e-15); 196 + }
+153
crates/bone-solver/tests/dof.rs
··· 1 + use bone_solver::{ConstraintSystem, DofConfig, LineHandle, PointHandle, Residual, analyze_dof}; 2 + use bone_types::{Parameter, ParameterIndex, ResidualIndex}; 3 + 4 + fn p(i: u32) -> ParameterIndex { 5 + ParameterIndex::new(i) 6 + } 7 + 8 + fn point(x: u32, y: u32) -> PointHandle { 9 + PointHandle { x: p(x), y: p(y) } 10 + } 11 + 12 + fn parameters(values: &[f64]) -> Vec<Parameter> { 13 + values.iter().copied().map(Parameter::new).collect() 14 + } 15 + 16 + #[test] 17 + fn fully_constrained_pin_has_zero_dof_and_no_flags() { 18 + let system = ConstraintSystem::new( 19 + parameters(&[0.0]), 20 + vec![Residual::Pin { 21 + param: p(0), 22 + target: 5.0, 23 + }], 24 + ); 25 + let report = analyze_dof(&system, DofConfig::DEFAULT); 26 + assert_eq!(report.dof().value(), 0); 27 + assert!(report.under_constrained().is_empty()); 28 + assert!(report.over_constrained().is_empty()); 29 + assert!(report.redundant_consistent().is_empty()); 30 + } 31 + 32 + #[test] 33 + fn under_constrained_horizontal_reports_free_parameters() { 34 + let system = ConstraintSystem::new( 35 + parameters(&[0.0, 0.0, 1.0, 0.0]), 36 + vec![Residual::Horizontal(LineHandle { 37 + a: point(0, 1), 38 + b: point(2, 3), 39 + })], 40 + ); 41 + let report = analyze_dof(&system, DofConfig::DEFAULT); 42 + assert_eq!(report.dof().value(), 3); 43 + assert_eq!(report.under_constrained().len(), 3); 44 + assert!(report.over_constrained().is_empty()); 45 + assert!(report.redundant_consistent().is_empty()); 46 + } 47 + 48 + #[test] 49 + fn redundant_pin_is_consistent_not_conflict() { 50 + let system = ConstraintSystem::new( 51 + parameters(&[5.0]), 52 + vec![ 53 + Residual::Pin { 54 + param: p(0), 55 + target: 5.0, 56 + }, 57 + Residual::Pin { 58 + param: p(0), 59 + target: 5.0, 60 + }, 61 + ], 62 + ); 63 + let report = analyze_dof(&system, DofConfig::DEFAULT); 64 + assert_eq!(report.dof().value(), 0); 65 + assert!(report.over_constrained().is_empty()); 66 + assert_eq!(report.redundant_consistent().len(), 1); 67 + let flagged = report.redundant_consistent()[0]; 68 + assert!(flagged == ResidualIndex::new(0) || flagged == ResidualIndex::new(1)); 69 + } 70 + 71 + #[test] 72 + fn dof_fixture_matrix_snapshot() { 73 + let fully_constrained = ConstraintSystem::new( 74 + parameters(&[0.0]), 75 + vec![Residual::Pin { 76 + param: p(0), 77 + target: 5.0, 78 + }], 79 + ); 80 + let under_constrained = ConstraintSystem::new( 81 + parameters(&[0.0, 0.0, 1.0, 0.0]), 82 + vec![Residual::Horizontal(LineHandle { 83 + a: point(0, 1), 84 + b: point(2, 3), 85 + })], 86 + ); 87 + let redundant_consistent = ConstraintSystem::new( 88 + parameters(&[5.0]), 89 + vec![ 90 + Residual::Pin { 91 + param: p(0), 92 + target: 5.0, 93 + }, 94 + Residual::Pin { 95 + param: p(0), 96 + target: 5.0, 97 + }, 98 + ], 99 + ); 100 + let conflicting = ConstraintSystem::new( 101 + parameters(&[3.0]), 102 + vec![ 103 + Residual::Pin { 104 + param: p(0), 105 + target: 3.0, 106 + }, 107 + Residual::Pin { 108 + param: p(0), 109 + target: 7.0, 110 + }, 111 + ], 112 + ); 113 + let bundle = [ 114 + ("fully_constrained", fully_constrained), 115 + ("under_constrained", under_constrained), 116 + ("redundant_consistent", redundant_consistent), 117 + ("conflicting", conflicting), 118 + ]; 119 + let rendered: String = bundle 120 + .iter() 121 + .map(|(label, system)| { 122 + format!( 123 + "{label}: {}", 124 + analyze_dof(system, DofConfig::DEFAULT).display() 125 + ) 126 + }) 127 + .collect::<Vec<_>>() 128 + .join("\n"); 129 + insta::assert_snapshot!("dof_matrix", rendered); 130 + } 131 + 132 + #[test] 133 + fn conflicting_pins_yield_over_constrained_row() { 134 + let system = ConstraintSystem::new( 135 + parameters(&[3.0]), 136 + vec![ 137 + Residual::Pin { 138 + param: p(0), 139 + target: 3.0, 140 + }, 141 + Residual::Pin { 142 + param: p(0), 143 + target: 7.0, 144 + }, 145 + ], 146 + ); 147 + let report = analyze_dof(&system, DofConfig::DEFAULT); 148 + assert_eq!(report.dof().value(), 0); 149 + assert_eq!(report.over_constrained().len(), 1); 150 + assert!(report.redundant_consistent().is_empty()); 151 + let flagged = report.over_constrained()[0]; 152 + assert!(flagged == ResidualIndex::new(0) || flagged == ResidualIndex::new(1)); 153 + }
+107
crates/bone-solver/tests/graph.rs
··· 1 + use bone_solver::{ConstraintSystem, LineHandle, PointHandle, Residual, decompose}; 2 + use bone_types::{Parameter, ParameterIndex, ParentIndex, ResidualIndex}; 3 + 4 + fn p(i: u32) -> ParameterIndex { 5 + ParameterIndex::new(i) 6 + } 7 + 8 + fn parent(i: u32) -> ParentIndex { 9 + ParentIndex::new(i) 10 + } 11 + 12 + fn point(x: u32, y: u32) -> PointHandle { 13 + PointHandle { x: p(x), y: p(y) } 14 + } 15 + 16 + fn parameters(values: &[f64]) -> Vec<Parameter> { 17 + values.iter().copied().map(Parameter::new).collect() 18 + } 19 + 20 + #[test] 21 + fn disjoint_residuals_yield_separate_components() { 22 + let system = ConstraintSystem::new( 23 + parameters(&[0.0, 0.0, 1.0, 0.0, 10.0, 0.0, 11.0, 0.0]), 24 + vec![ 25 + Residual::Horizontal(LineHandle { 26 + a: point(0, 1), 27 + b: point(2, 3), 28 + }), 29 + Residual::Horizontal(LineHandle { 30 + a: point(4, 5), 31 + b: point(6, 7), 32 + }), 33 + ], 34 + ); 35 + let decomp = decompose(&system); 36 + assert_eq!(decomp.components().len(), 2); 37 + let first = &decomp.components()[0]; 38 + let second = &decomp.components()[1]; 39 + assert_eq!( 40 + first 41 + .parameters() 42 + .iter() 43 + .map(|p| p.value()) 44 + .collect::<Vec<_>>(), 45 + vec![0, 1, 2, 3] 46 + ); 47 + assert_eq!( 48 + second 49 + .parameters() 50 + .iter() 51 + .map(|p| p.value()) 52 + .collect::<Vec<_>>(), 53 + vec![4, 5, 6, 7] 54 + ); 55 + assert_eq!(first.residual_parents(), &[parent(0)]); 56 + assert_eq!(second.residual_parents(), &[parent(1)]); 57 + assert_eq!(first.residual_rows(&system), vec![ResidualIndex::new(0)]); 58 + assert_eq!(second.residual_rows(&system), vec![ResidualIndex::new(1)]); 59 + } 60 + 61 + #[test] 62 + fn shared_parameter_merges_components() { 63 + let system = ConstraintSystem::new( 64 + parameters(&[0.0, 0.0, 1.0, 0.0, 2.0, 0.0]), 65 + vec![ 66 + Residual::Horizontal(LineHandle { 67 + a: point(0, 1), 68 + b: point(2, 3), 69 + }), 70 + Residual::Horizontal(LineHandle { 71 + a: point(2, 3), 72 + b: point(4, 5), 73 + }), 74 + ], 75 + ); 76 + let decomp = decompose(&system); 77 + assert_eq!(decomp.components().len(), 1); 78 + let only = &decomp.components()[0]; 79 + assert_eq!(only.parameters().len(), 6); 80 + assert_eq!(only.residual_parents(), &[parent(0), parent(1)]); 81 + } 82 + 83 + #[test] 84 + fn isolated_parameter_forms_its_own_component() { 85 + let system = ConstraintSystem::new( 86 + parameters(&[0.0, 0.0, 3.0]), 87 + vec![Residual::Pin { 88 + param: p(0), 89 + target: 1.0, 90 + }], 91 + ); 92 + let decomp = decompose(&system); 93 + assert_eq!(decomp.components().len(), 3); 94 + decomp 95 + .components() 96 + .iter() 97 + .enumerate() 98 + .for_each(|(i, comp)| { 99 + let Ok(iv) = u32::try_from(i) else { 100 + unreachable!() 101 + }; 102 + assert_eq!(comp.parameters(), &[ParameterIndex::new(iv)]); 103 + }); 104 + assert_eq!(decomp.components()[0].residual_parents(), &[parent(0)]); 105 + assert!(decomp.components()[1].residual_parents().is_empty()); 106 + assert!(decomp.components()[2].residual_parents().is_empty()); 107 + }
+8
crates/bone-solver/tests/snapshots/dof__dof_matrix.snap
··· 1 + --- 2 + source: crates/bone-solver/tests/dof.rs 3 + expression: rendered 4 + --- 5 + fully_constrained: dof=0 under=[] over=[] redundant=[] 6 + under_constrained: dof=3 under=[p#0,p#2,p#3] over=[] redundant=[] 7 + redundant_consistent: dof=0 under=[] over=[] redundant=[r#1] 8 + conflicting: dof=0 under=[] over=[r#1] redundant=[]
+24
crates/bone-types/Cargo.toml
··· 1 + [package] 2 + name = "bone-types" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + nalgebra = { workspace = true } 10 + serde = { workspace = true } 11 + slotmap = { workspace = true } 12 + thiserror = { workspace = true } 13 + tracing = { workspace = true, optional = true } 14 + tracing-subscriber = { workspace = true, optional = true } 15 + uom = { workspace = true } 16 + 17 + [dev-dependencies] 18 + insta = { workspace = true } 19 + 20 + [features] 21 + testing = ["dep:tracing", "dep:tracing-subscriber"] 22 + 23 + [lints] 24 + workspace = true
+27
crates/bone-types/src/dimensioned_serde.rs
··· 1 + pub mod length_si { 2 + use serde::{Deserialize, Deserializer, Serializer}; 3 + use uom::si::f64::Length; 4 + use uom::si::length::meter; 5 + 6 + pub fn serialize<S: Serializer>(value: &Length, s: S) -> Result<S::Ok, S::Error> { 7 + s.serialize_f64(value.get::<meter>()) 8 + } 9 + 10 + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Length, D::Error> { 11 + f64::deserialize(d).map(Length::new::<meter>) 12 + } 13 + } 14 + 15 + pub mod angle_si { 16 + use serde::{Deserialize, Deserializer, Serializer}; 17 + use uom::si::angle::radian; 18 + use uom::si::f64::Angle; 19 + 20 + pub fn serialize<S: Serializer>(value: &Angle, s: S) -> Result<S::Ok, S::Error> { 21 + s.serialize_f64(value.get::<radian>()) 22 + } 23 + 24 + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Angle, D::Error> { 25 + f64::deserialize(d).map(Angle::new::<radian>) 26 + } 27 + }
+340
crates/bone-types/src/lib.rs
··· 1 + pub use uom::si::angle::{degree, radian}; 2 + pub use uom::si::f64::{Angle, Length}; 3 + pub use uom::si::length::millimeter; 4 + 5 + pub mod dimensioned_serde; 6 + pub mod schema; 7 + pub mod solver; 8 + pub mod space; 9 + 10 + pub use schema::{SchemaHeader, SchemaVersion}; 11 + pub use solver::{ 12 + BudgetCeiling, DegreesOfFreedom, NewtonDamping, NewtonStepTolerance, Parameter, ParameterIndex, 13 + ParentIndex, ResidualIndex, SketchItemId, SolverResidual, SolverSeed, 14 + }; 15 + pub use space::{Point2, Point3, SketchPlaneBasis, UnitVec2, UnitVec3, Vec2}; 16 + 17 + #[cfg(feature = "testing")] 18 + pub mod testing; 19 + 20 + #[derive(Debug, thiserror::Error)] 21 + pub enum TypesError { 22 + #[error("sketch plane axes not orthogonal: |x·y|={0}")] 23 + NonOrthogonalPlaneAxes(f64), 24 + #[error("axis vector is zero-length")] 25 + ZeroLengthAxis, 26 + } 27 + 28 + pub type Result<T, E = TypesError> = core::result::Result<T, E>; 29 + 30 + slotmap::new_key_type! { 31 + pub struct FaceId; 32 + pub struct EdgeId; 33 + pub struct VertexId; 34 + pub struct ShellId; 35 + pub struct SolidId; 36 + pub struct LoopId; 37 + pub struct WireId; 38 + pub struct FeatureId; 39 + pub struct NodeId; 40 + pub struct DocumentId; 41 + pub struct SketchId; 42 + pub struct SketchEntityId; 43 + pub struct SketchRelationId; 44 + pub struct SketchDimensionId; 45 + pub struct SketchParameterId; 46 + } 47 + 48 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 49 + pub struct Tolerance(f64); 50 + 51 + impl Tolerance { 52 + #[must_use] 53 + pub const fn new(value: f64) -> Self { 54 + Self(value) 55 + } 56 + 57 + #[must_use] 58 + pub const fn value(self) -> f64 { 59 + self.0 60 + } 61 + } 62 + 63 + impl core::fmt::Display for Tolerance { 64 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 65 + write!(f, "tol={}", self.0) 66 + } 67 + } 68 + 69 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 70 + pub struct AngleTolerance(f64); 71 + 72 + impl AngleTolerance { 73 + pub const ZERO: Self = Self(0.0); 74 + 75 + #[must_use] 76 + pub const fn from_radians(value: f64) -> Self { 77 + Self(value) 78 + } 79 + 80 + #[must_use] 81 + pub const fn radians(self) -> f64 { 82 + self.0 83 + } 84 + 85 + #[must_use] 86 + pub fn from_arc_length(linear: Tolerance, radius: Length) -> Self { 87 + let r = radius.get::<millimeter>().abs(); 88 + if r > linear.value() { 89 + Self(linear.value() / r) 90 + } else { 91 + Self(linear.value()) 92 + } 93 + } 94 + } 95 + 96 + impl core::fmt::Display for AngleTolerance { 97 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 98 + write!(f, "tol_angle={} rad", self.0) 99 + } 100 + } 101 + 102 + #[cfg(test)] 103 + mod tests { 104 + use super::{ 105 + Angle, AngleTolerance, DegreesOfFreedom, DocumentId, EdgeId, FaceId, FeatureId, Length, 106 + LoopId, NodeId, Parameter, Point2, Point3, ShellId, SketchDimensionId, SketchEntityId, 107 + SketchId, SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, 108 + Tolerance, UnitVec2, UnitVec3, Vec2, VertexId, WireId, degree, millimeter, radian, 109 + }; 110 + use slotmap::Key; 111 + 112 + #[test] 113 + fn id_null_is_null() { 114 + assert!(FaceId::null().is_null()); 115 + assert!(EdgeId::null().is_null()); 116 + assert!(VertexId::null().is_null()); 117 + assert!(ShellId::null().is_null()); 118 + assert!(SolidId::null().is_null()); 119 + assert!(LoopId::null().is_null()); 120 + assert!(WireId::null().is_null()); 121 + assert!(FeatureId::null().is_null()); 122 + assert!(NodeId::null().is_null()); 123 + assert!(DocumentId::null().is_null()); 124 + assert!(SketchId::null().is_null()); 125 + assert!(SketchEntityId::null().is_null()); 126 + assert!(SketchRelationId::null().is_null()); 127 + assert!(SketchDimensionId::null().is_null()); 128 + assert!(SketchParameterId::null().is_null()); 129 + } 130 + 131 + #[test] 132 + fn ids_are_distinct_types() { 133 + let _f: FaceId = FaceId::null(); 134 + let _e: EdgeId = EdgeId::null(); 135 + let _s: SketchEntityId = SketchEntityId::null(); 136 + let _r: SketchRelationId = SketchRelationId::null(); 137 + } 138 + 139 + #[test] 140 + fn length_mm_roundtrip() { 141 + let l = Length::new::<millimeter>(12.5); 142 + assert!((l.get::<millimeter>() - 12.5).abs() < f64::EPSILON); 143 + } 144 + 145 + #[test] 146 + fn angle_deg_converts_to_rad() { 147 + let a = Angle::new::<degree>(180.0); 148 + assert!((a.get::<radian>() - core::f64::consts::PI).abs() < 1e-12); 149 + } 150 + 151 + #[test] 152 + fn tolerance_display() { 153 + let t = Tolerance::new(1e-6); 154 + assert_eq!(format!("{t}"), "tol=0.000001"); 155 + assert!((t.value() - 1e-6).abs() < f64::EPSILON); 156 + } 157 + 158 + #[test] 159 + fn angle_tolerance_from_arc_length_scales_by_radius() { 160 + let linear = Tolerance::new(1e-3); 161 + let r = Length::new::<millimeter>(10.0); 162 + let a = AngleTolerance::from_arc_length(linear, r); 163 + assert!((a.radians() - 1e-4).abs() < 1e-15); 164 + } 165 + 166 + #[test] 167 + fn angle_tolerance_degenerate_radius_falls_back_to_linear() { 168 + let linear = Tolerance::new(1e-3); 169 + let r = Length::new::<millimeter>(0.0); 170 + let a = AngleTolerance::from_arc_length(linear, r); 171 + assert!((a.radians() - 1e-3).abs() < 1e-15); 172 + } 173 + 174 + #[test] 175 + fn angle_tolerance_zero_constant() { 176 + assert!(AngleTolerance::ZERO.radians().abs() < f64::EPSILON); 177 + } 178 + 179 + #[test] 180 + fn point2_minus_point2_is_vec2() { 181 + let a = Point2::from_mm(10.0, 5.0); 182 + let b = Point2::from_mm(4.0, 2.0); 183 + let d: Vec2 = a - b; 184 + assert!((d.x().get::<millimeter>() - 6.0).abs() < 1e-12); 185 + assert!((d.y().get::<millimeter>() - 3.0).abs() < 1e-12); 186 + } 187 + 188 + #[test] 189 + fn point2_plus_vec2_is_point2() { 190 + let p = Point2::from_mm(1.0, 2.0); 191 + let v = Vec2::from_mm(10.0, 20.0); 192 + let q: Point2 = p + v; 193 + assert!((q.x().get::<millimeter>() - 11.0).abs() < 1e-12); 194 + assert!((q.y().get::<millimeter>() - 22.0).abs() < 1e-12); 195 + } 196 + 197 + #[test] 198 + fn vec2_perp_and_dot() { 199 + let v = Vec2::from_mm(3.0, 4.0); 200 + assert!((v.norm_mm() - 5.0).abs() < 1e-12); 201 + let p = v.perp_ccw(); 202 + assert!((p.x().get::<millimeter>() + 4.0).abs() < 1e-12); 203 + assert!((p.y().get::<millimeter>() - 3.0).abs() < 1e-12); 204 + assert!(v.dot_mm2(p).abs() < 1e-12); 205 + assert!((v.cross_z_mm2(Vec2::from_mm(1.0, 0.0)) + 4.0).abs() < 1e-12); 206 + } 207 + 208 + #[test] 209 + fn vec2_try_normalize_rejects_zero() { 210 + let v = Vec2::zero(); 211 + let r = v.try_normalize(Tolerance::new(1e-9)); 212 + assert!(r.is_err()); 213 + } 214 + 215 + #[test] 216 + fn vec2_try_normalize_unit() { 217 + let v = Vec2::from_mm(0.0, 7.0); 218 + let Ok(u) = v.try_normalize(Tolerance::new(1e-9)) else { 219 + panic!("nonzero"); 220 + }; 221 + let (ux, uy) = u.components(); 222 + assert!(ux.abs() < 1e-12); 223 + assert!((uy - 1.0).abs() < 1e-12); 224 + } 225 + 226 + #[test] 227 + fn unit_vec2_axes_are_orthogonal() { 228 + let x = UnitVec2::x_axis(); 229 + let y = UnitVec2::y_axis(); 230 + assert!(x.dot(y).abs() < 1e-15); 231 + assert_eq!(x.perp_ccw().components(), y.components()); 232 + } 233 + 234 + #[test] 235 + fn unit_vec2_rejects_zero_axis() { 236 + let r = UnitVec2::try_from_components(0.0, 0.0, Tolerance::new(1e-9)); 237 + assert!(r.is_err()); 238 + } 239 + 240 + #[test] 241 + fn unit_vec2_into_vec_scales_by_length() { 242 + let u = UnitVec2::x_axis(); 243 + let v = u.into_vec(Length::new::<millimeter>(3.0)); 244 + assert!((v.x().get::<millimeter>() - 3.0).abs() < 1e-12); 245 + assert!(v.y().get::<millimeter>().abs() < 1e-12); 246 + } 247 + 248 + #[test] 249 + fn vec2_scalar_and_negation() { 250 + let v = Vec2::from_mm(3.0, -4.0); 251 + let w = 2.0 * (-v); 252 + assert!((w.x().get::<millimeter>() + 6.0).abs() < 1e-12); 253 + assert!((w.y().get::<millimeter>() - 8.0).abs() < 1e-12); 254 + } 255 + 256 + #[test] 257 + fn point3_roundtrip() { 258 + let p = Point3::from_mm(1.5, 2.5, 3.5); 259 + assert!((p.x().get::<millimeter>() - 1.5).abs() < 1e-12); 260 + assert!((p.y().get::<millimeter>() - 2.5).abs() < 1e-12); 261 + assert!((p.z().get::<millimeter>() - 3.5).abs() < 1e-12); 262 + } 263 + 264 + #[test] 265 + fn sketch_plane_basis_accepts_orthogonal_axes() { 266 + let Ok(basis) = SketchPlaneBasis::new( 267 + Point3::origin(), 268 + UnitVec3::x_axis(), 269 + UnitVec3::y_axis(), 270 + Tolerance::new(1e-9), 271 + ) else { 272 + panic!("x and y unit axes are orthogonal"); 273 + }; 274 + let (nx, ny, nz) = basis.normal().components(); 275 + assert!((nx).abs() < 1e-12); 276 + assert!((ny).abs() < 1e-12); 277 + assert!((nz - 1.0).abs() < 1e-12); 278 + } 279 + 280 + #[test] 281 + fn sketch_plane_basis_reorthonormalizes_within_tolerance() { 282 + let Ok(nearly) = UnitVec3::try_from_components(1e-10, 1.0, 0.0) else { 283 + panic!("input vector is nonzero"); 284 + }; 285 + let Ok(basis) = SketchPlaneBasis::new( 286 + Point3::origin(), 287 + UnitVec3::x_axis(), 288 + nearly, 289 + Tolerance::new(1e-9), 290 + ) else { 291 + panic!("within tolerance"); 292 + }; 293 + let dot_after = basis.x_axis().dot(basis.y_axis()).abs(); 294 + assert!( 295 + dot_after < 1e-15, 296 + "re-orthonormalized y should be orthogonal, got {dot_after}" 297 + ); 298 + } 299 + 300 + #[test] 301 + fn sketch_plane_basis_rejects_non_orthogonal_axes() { 302 + let Ok(skew) = UnitVec3::try_from_components(1.0, 1.0, 0.0) else { 303 + panic!("input vector is nonzero"); 304 + }; 305 + let result = SketchPlaneBasis::new( 306 + Point3::origin(), 307 + UnitVec3::x_axis(), 308 + skew, 309 + Tolerance::new(1e-9), 310 + ); 311 + assert!(result.is_err()); 312 + } 313 + 314 + #[test] 315 + fn unit_vec3_rejects_zero_axis() { 316 + let r = UnitVec3::try_from_components(0.0, 0.0, 0.0); 317 + assert!(r.is_err()); 318 + } 319 + 320 + #[test] 321 + fn parameter_display_and_value() { 322 + let p = Parameter::new(0.25); 323 + assert_eq!(format!("{p}"), "param=0.25"); 324 + assert!((p.value() - 0.25).abs() < f64::EPSILON); 325 + } 326 + 327 + #[test] 328 + fn degrees_of_freedom_display_and_value() { 329 + let d = DegreesOfFreedom::new(7); 330 + assert_eq!(format!("{d}"), "dof=7"); 331 + assert_eq!(d.value(), 7); 332 + } 333 + 334 + #[test] 335 + fn solver_residual_display_and_value() { 336 + let r = SolverResidual::new(1.5e-6); 337 + assert_eq!(format!("{r}"), "res=0.0000015"); 338 + assert!((r.value() - 1.5e-6).abs() < f64::EPSILON); 339 + } 340 + }
+53
crates/bone-types/src/schema.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 + #[serde(deny_unknown_fields)] 5 + pub struct SchemaVersion { 6 + pub major: u32, 7 + pub minor: u32, 8 + } 9 + 10 + impl SchemaVersion { 11 + #[must_use] 12 + pub const fn new(major: u32, minor: u32) -> Self { 13 + Self { major, minor } 14 + } 15 + } 16 + 17 + impl core::fmt::Display for SchemaVersion { 18 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 19 + write!(f, "{}.{}", self.major, self.minor) 20 + } 21 + } 22 + 23 + #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 24 + #[serde(deny_unknown_fields)] 25 + pub struct SchemaHeader { 26 + pub name: String, 27 + pub version: SchemaVersion, 28 + } 29 + 30 + impl SchemaHeader { 31 + pub const BONE_DOCUMENT_NAME: &'static str = "bone-document"; 32 + pub const BONE_DOCUMENT_MAJOR: u32 = 1; 33 + pub const BONE_DOCUMENT_MINOR: u32 = 0; 34 + 35 + #[must_use] 36 + pub fn bone_document() -> Self { 37 + Self { 38 + name: Self::BONE_DOCUMENT_NAME.to_owned(), 39 + version: SchemaVersion::new(Self::BONE_DOCUMENT_MAJOR, Self::BONE_DOCUMENT_MINOR), 40 + } 41 + } 42 + 43 + #[must_use] 44 + pub fn is_bone_document(&self) -> bool { 45 + self.name == Self::BONE_DOCUMENT_NAME 46 + } 47 + } 48 + 49 + impl core::fmt::Display for SchemaHeader { 50 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 51 + write!(f, "{} v{}", self.name, self.version) 52 + } 53 + }
+254
crates/bone-types/src/solver.rs
··· 1 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] 2 + pub struct Parameter(f64); 3 + 4 + impl Parameter { 5 + #[must_use] 6 + pub const fn new(value: f64) -> Self { 7 + Self(value) 8 + } 9 + 10 + #[must_use] 11 + pub const fn value(self) -> f64 { 12 + self.0 13 + } 14 + } 15 + 16 + impl core::fmt::Display for Parameter { 17 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 18 + write!(f, "param={}", self.0) 19 + } 20 + } 21 + 22 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 23 + pub struct DegreesOfFreedom(u32); 24 + 25 + impl DegreesOfFreedom { 26 + #[must_use] 27 + pub const fn new(value: u32) -> Self { 28 + Self(value) 29 + } 30 + 31 + #[must_use] 32 + pub const fn value(self) -> u32 { 33 + self.0 34 + } 35 + } 36 + 37 + impl core::fmt::Display for DegreesOfFreedom { 38 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 39 + write!(f, "dof={}", self.0) 40 + } 41 + } 42 + 43 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 44 + pub struct SolverSeed(u64); 45 + 46 + impl SolverSeed { 47 + pub const DEFAULT: Self = Self(0x5E61_D000_7E57_5EEDu64); 48 + 49 + #[must_use] 50 + pub const fn new(value: u64) -> Self { 51 + Self(value) 52 + } 53 + 54 + #[must_use] 55 + pub const fn value(self) -> u64 { 56 + self.0 57 + } 58 + } 59 + 60 + impl core::fmt::Display for SolverSeed { 61 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 62 + write!(f, "seed={:#018x}", self.0) 63 + } 64 + } 65 + 66 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 67 + pub struct ParameterIndex(u32); 68 + 69 + impl ParameterIndex { 70 + #[must_use] 71 + pub const fn new(value: u32) -> Self { 72 + Self(value) 73 + } 74 + 75 + #[must_use] 76 + pub const fn value(self) -> u32 { 77 + self.0 78 + } 79 + 80 + #[must_use] 81 + pub const fn as_usize(self) -> usize { 82 + self.0 as usize 83 + } 84 + } 85 + 86 + impl core::fmt::Display for ParameterIndex { 87 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 88 + write!(f, "p#{}", self.0) 89 + } 90 + } 91 + 92 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 93 + pub struct ParentIndex(u32); 94 + 95 + impl ParentIndex { 96 + #[must_use] 97 + pub const fn new(value: u32) -> Self { 98 + Self(value) 99 + } 100 + 101 + #[must_use] 102 + pub const fn value(self) -> u32 { 103 + self.0 104 + } 105 + 106 + #[must_use] 107 + pub const fn as_usize(self) -> usize { 108 + self.0 as usize 109 + } 110 + } 111 + 112 + impl core::fmt::Display for ParentIndex { 113 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 114 + write!(f, "parent#{}", self.0) 115 + } 116 + } 117 + 118 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 119 + pub struct ResidualIndex(u32); 120 + 121 + impl ResidualIndex { 122 + #[must_use] 123 + pub const fn new(value: u32) -> Self { 124 + Self(value) 125 + } 126 + 127 + #[must_use] 128 + pub const fn value(self) -> u32 { 129 + self.0 130 + } 131 + 132 + #[must_use] 133 + pub const fn as_usize(self) -> usize { 134 + self.0 as usize 135 + } 136 + 137 + #[must_use] 138 + pub const fn next(self) -> Self { 139 + Self(self.0.saturating_add(1)) 140 + } 141 + } 142 + 143 + impl core::fmt::Display for ResidualIndex { 144 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 145 + write!(f, "r#{}", self.0) 146 + } 147 + } 148 + 149 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 150 + pub enum SketchItemId { 151 + Relation(crate::SketchRelationId), 152 + Dimension(crate::SketchDimensionId), 153 + Entity(crate::SketchEntityId), 154 + } 155 + 156 + impl core::fmt::Display for SketchItemId { 157 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 158 + match self { 159 + Self::Relation(id) => write!(f, "relation({id:?})"), 160 + Self::Dimension(id) => write!(f, "dimension({id:?})"), 161 + Self::Entity(id) => write!(f, "entity({id:?})"), 162 + } 163 + } 164 + } 165 + 166 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 167 + pub struct SolverResidual(f64); 168 + 169 + impl SolverResidual { 170 + #[must_use] 171 + pub const fn new(value: f64) -> Self { 172 + Self(value) 173 + } 174 + 175 + #[must_use] 176 + pub const fn value(self) -> f64 { 177 + self.0 178 + } 179 + } 180 + 181 + impl core::fmt::Display for SolverResidual { 182 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 183 + write!(f, "res={}", self.0) 184 + } 185 + } 186 + 187 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 188 + pub struct NewtonDamping(f64); 189 + 190 + impl NewtonDamping { 191 + pub const DEFAULT: Self = Self(1e-12); 192 + 193 + #[must_use] 194 + pub const fn new(value: f64) -> Self { 195 + Self(value) 196 + } 197 + 198 + #[must_use] 199 + pub const fn value(self) -> f64 { 200 + self.0 201 + } 202 + } 203 + 204 + impl core::fmt::Display for NewtonDamping { 205 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 206 + write!(f, "damping={}", self.0) 207 + } 208 + } 209 + 210 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 211 + pub struct NewtonStepTolerance(f64); 212 + 213 + impl NewtonStepTolerance { 214 + pub const DEFAULT: Self = Self(1e-14); 215 + 216 + #[must_use] 217 + pub const fn new(value: f64) -> Self { 218 + Self(value) 219 + } 220 + 221 + #[must_use] 222 + pub const fn value(self) -> f64 { 223 + self.0 224 + } 225 + } 226 + 227 + impl core::fmt::Display for NewtonStepTolerance { 228 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 229 + write!(f, "step_tol={}", self.0) 230 + } 231 + } 232 + 233 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 234 + pub struct BudgetCeiling(core::time::Duration); 235 + 236 + impl BudgetCeiling { 237 + pub const FRAME_16MS: Self = Self(core::time::Duration::from_millis(16)); 238 + 239 + #[must_use] 240 + pub const fn new(duration: core::time::Duration) -> Self { 241 + Self(duration) 242 + } 243 + 244 + #[must_use] 245 + pub const fn duration(self) -> core::time::Duration { 246 + self.0 247 + } 248 + } 249 + 250 + impl core::fmt::Display for BudgetCeiling { 251 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 252 + write!(f, "budget={:?}", self.0) 253 + } 254 + }
+539
crates/bone-types/src/space.rs
··· 1 + use nalgebra::{Point2 as NPoint2, Point3 as NPoint3, Unit, Vector2 as NVec2, Vector3 as NVec3}; 2 + use serde::{Deserialize, Serialize}; 3 + use uom::si::f64::Length; 4 + use uom::si::length::millimeter; 5 + 6 + use crate::{Result, Tolerance, TypesError}; 7 + 8 + #[must_use] 9 + fn mm(value: f64) -> Length { 10 + Length::new::<millimeter>(value) 11 + } 12 + 13 + #[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] 14 + #[serde(from = "Point2Wire", into = "Point2Wire")] 15 + pub struct Point2(NPoint2<f64>); 16 + 17 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 18 + #[serde(rename = "Point2", deny_unknown_fields)] 19 + struct Point2Wire { 20 + x: f64, 21 + y: f64, 22 + } 23 + 24 + impl From<Point2> for Point2Wire { 25 + fn from(p: Point2) -> Self { 26 + Self { x: p.0.x, y: p.0.y } 27 + } 28 + } 29 + 30 + impl From<Point2Wire> for Point2 { 31 + fn from(w: Point2Wire) -> Self { 32 + Self(NPoint2::new(w.x, w.y)) 33 + } 34 + } 35 + 36 + impl Point2 { 37 + #[must_use] 38 + pub fn origin() -> Self { 39 + Self(NPoint2::origin()) 40 + } 41 + 42 + #[must_use] 43 + pub fn from_mm(x: f64, y: f64) -> Self { 44 + Self(NPoint2::new(x, y)) 45 + } 46 + 47 + #[must_use] 48 + pub fn from_lengths(x: Length, y: Length) -> Self { 49 + Self(NPoint2::new(x.get::<millimeter>(), y.get::<millimeter>())) 50 + } 51 + 52 + #[must_use] 53 + pub fn x(self) -> Length { 54 + mm(self.0.x) 55 + } 56 + 57 + #[must_use] 58 + pub fn y(self) -> Length { 59 + mm(self.0.y) 60 + } 61 + 62 + #[must_use] 63 + pub fn coords_mm(self) -> (f64, f64) { 64 + (self.0.x, self.0.y) 65 + } 66 + } 67 + 68 + impl core::fmt::Debug for Point2 { 69 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 70 + write!(f, "Point2({} mm, {} mm)", self.0.x, self.0.y) 71 + } 72 + } 73 + 74 + impl core::fmt::Display for Point2 { 75 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 76 + write!(f, "({} mm, {} mm)", self.0.x, self.0.y) 77 + } 78 + } 79 + 80 + impl core::ops::Sub for Point2 { 81 + type Output = Vec2; 82 + fn sub(self, rhs: Self) -> Vec2 { 83 + Vec2(self.0 - rhs.0) 84 + } 85 + } 86 + 87 + impl core::ops::Add<Vec2> for Point2 { 88 + type Output = Self; 89 + fn add(self, rhs: Vec2) -> Self { 90 + Self(self.0 + rhs.0) 91 + } 92 + } 93 + 94 + impl core::ops::Sub<Vec2> for Point2 { 95 + type Output = Self; 96 + fn sub(self, rhs: Vec2) -> Self { 97 + Self(self.0 - rhs.0) 98 + } 99 + } 100 + 101 + #[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] 102 + #[serde(from = "Vec2Wire", into = "Vec2Wire")] 103 + pub struct Vec2(NVec2<f64>); 104 + 105 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 106 + #[serde(rename = "Vec2", deny_unknown_fields)] 107 + struct Vec2Wire { 108 + x: f64, 109 + y: f64, 110 + } 111 + 112 + impl From<Vec2> for Vec2Wire { 113 + fn from(v: Vec2) -> Self { 114 + Self { x: v.0.x, y: v.0.y } 115 + } 116 + } 117 + 118 + impl From<Vec2Wire> for Vec2 { 119 + fn from(w: Vec2Wire) -> Self { 120 + Self(NVec2::new(w.x, w.y)) 121 + } 122 + } 123 + 124 + impl Vec2 { 125 + #[must_use] 126 + pub fn zero() -> Self { 127 + Self(NVec2::zeros()) 128 + } 129 + 130 + #[must_use] 131 + pub fn from_mm(x: f64, y: f64) -> Self { 132 + Self(NVec2::new(x, y)) 133 + } 134 + 135 + #[must_use] 136 + pub fn from_lengths(x: Length, y: Length) -> Self { 137 + Self(NVec2::new(x.get::<millimeter>(), y.get::<millimeter>())) 138 + } 139 + 140 + #[must_use] 141 + pub fn x(self) -> Length { 142 + mm(self.0.x) 143 + } 144 + 145 + #[must_use] 146 + pub fn y(self) -> Length { 147 + mm(self.0.y) 148 + } 149 + 150 + #[must_use] 151 + pub fn coords_mm(self) -> (f64, f64) { 152 + (self.0.x, self.0.y) 153 + } 154 + 155 + #[must_use] 156 + pub fn dot_mm2(self, other: Self) -> f64 { 157 + self.0.dot(&other.0) 158 + } 159 + 160 + #[must_use] 161 + pub fn cross_z_mm2(self, other: Self) -> f64 { 162 + self.0.x * other.0.y - self.0.y * other.0.x 163 + } 164 + 165 + #[must_use] 166 + pub fn norm_squared_mm2(self) -> f64 { 167 + self.0.norm_squared() 168 + } 169 + 170 + #[must_use] 171 + pub fn norm_mm(self) -> f64 { 172 + self.0.norm() 173 + } 174 + 175 + #[must_use] 176 + pub fn norm(self) -> Length { 177 + mm(self.0.norm()) 178 + } 179 + 180 + #[must_use] 181 + pub fn perp_ccw(self) -> Self { 182 + Self(NVec2::new(-self.0.y, self.0.x)) 183 + } 184 + 185 + pub fn try_normalize(self, tolerance: Tolerance) -> Result<UnitVec2> { 186 + Unit::try_new(self.0, tolerance.value()) 187 + .map(UnitVec2) 188 + .ok_or(TypesError::ZeroLengthAxis) 189 + } 190 + } 191 + 192 + impl core::fmt::Debug for Vec2 { 193 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 194 + write!(f, "Vec2({} mm, {} mm)", self.0.x, self.0.y) 195 + } 196 + } 197 + 198 + impl core::fmt::Display for Vec2 { 199 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 200 + write!(f, "<{} mm, {} mm>", self.0.x, self.0.y) 201 + } 202 + } 203 + 204 + impl core::ops::Add for Vec2 { 205 + type Output = Self; 206 + fn add(self, rhs: Self) -> Self { 207 + Self(self.0 + rhs.0) 208 + } 209 + } 210 + 211 + impl core::ops::Sub for Vec2 { 212 + type Output = Self; 213 + fn sub(self, rhs: Self) -> Self { 214 + Self(self.0 - rhs.0) 215 + } 216 + } 217 + 218 + impl core::ops::Mul<f64> for Vec2 { 219 + type Output = Self; 220 + fn mul(self, rhs: f64) -> Self { 221 + Self(self.0 * rhs) 222 + } 223 + } 224 + 225 + impl core::ops::Mul<Vec2> for f64 { 226 + type Output = Vec2; 227 + fn mul(self, rhs: Vec2) -> Vec2 { 228 + Vec2(rhs.0 * self) 229 + } 230 + } 231 + 232 + impl core::ops::Neg for Vec2 { 233 + type Output = Self; 234 + fn neg(self) -> Self { 235 + Self(-self.0) 236 + } 237 + } 238 + 239 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 240 + #[serde(try_from = "UnitVec2Wire", into = "UnitVec2Wire")] 241 + pub struct UnitVec2(Unit<NVec2<f64>>); 242 + 243 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 244 + #[serde(rename = "UnitVec2", deny_unknown_fields)] 245 + struct UnitVec2Wire { 246 + x: f64, 247 + y: f64, 248 + } 249 + 250 + impl From<UnitVec2> for UnitVec2Wire { 251 + fn from(u: UnitVec2) -> Self { 252 + Self { x: u.0.x, y: u.0.y } 253 + } 254 + } 255 + 256 + impl TryFrom<UnitVec2Wire> for UnitVec2 { 257 + type Error = TypesError; 258 + fn try_from(w: UnitVec2Wire) -> Result<Self> { 259 + Self::try_from_components(w.x, w.y, Tolerance::new(f64::EPSILON)) 260 + } 261 + } 262 + 263 + impl UnitVec2 { 264 + #[must_use] 265 + pub fn x_axis() -> Self { 266 + Self(Unit::new_unchecked(NVec2::new(1.0, 0.0))) 267 + } 268 + 269 + #[must_use] 270 + pub fn y_axis() -> Self { 271 + Self(Unit::new_unchecked(NVec2::new(0.0, 1.0))) 272 + } 273 + 274 + #[must_use] 275 + pub fn new_unchecked(x: f64, y: f64) -> Self { 276 + Self(Unit::new_unchecked(NVec2::new(x, y))) 277 + } 278 + 279 + pub fn try_from_components(x: f64, y: f64, tolerance: Tolerance) -> Result<Self> { 280 + Unit::try_new(NVec2::new(x, y), tolerance.value()) 281 + .map(Self) 282 + .ok_or(TypesError::ZeroLengthAxis) 283 + } 284 + 285 + #[must_use] 286 + pub fn components(self) -> (f64, f64) { 287 + (self.0.x, self.0.y) 288 + } 289 + 290 + #[must_use] 291 + pub fn dot(self, other: Self) -> f64 { 292 + self.0.dot(&other.0) 293 + } 294 + 295 + #[must_use] 296 + pub fn perp_ccw(self) -> Self { 297 + Self(Unit::new_unchecked(NVec2::new(-self.0.y, self.0.x))) 298 + } 299 + 300 + #[must_use] 301 + pub fn into_vec(self, length: Length) -> Vec2 { 302 + let mm_value = length.get::<millimeter>(); 303 + Vec2(self.0.into_inner() * mm_value) 304 + } 305 + } 306 + 307 + impl core::fmt::Display for UnitVec2 { 308 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 309 + write!(f, "[{}, {}]", self.0.x, self.0.y) 310 + } 311 + } 312 + 313 + #[derive(Copy, Clone, PartialEq, Serialize, Deserialize)] 314 + #[serde(from = "Point3Wire", into = "Point3Wire")] 315 + pub struct Point3(NPoint3<f64>); 316 + 317 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 318 + #[serde(rename = "Point3", deny_unknown_fields)] 319 + struct Point3Wire { 320 + x: f64, 321 + y: f64, 322 + z: f64, 323 + } 324 + 325 + impl From<Point3> for Point3Wire { 326 + fn from(p: Point3) -> Self { 327 + Self { 328 + x: p.0.x, 329 + y: p.0.y, 330 + z: p.0.z, 331 + } 332 + } 333 + } 334 + 335 + impl From<Point3Wire> for Point3 { 336 + fn from(w: Point3Wire) -> Self { 337 + Self(NPoint3::new(w.x, w.y, w.z)) 338 + } 339 + } 340 + 341 + impl Point3 { 342 + #[must_use] 343 + pub fn origin() -> Self { 344 + Self(NPoint3::origin()) 345 + } 346 + 347 + #[must_use] 348 + pub fn from_mm(x: f64, y: f64, z: f64) -> Self { 349 + Self(NPoint3::new(x, y, z)) 350 + } 351 + 352 + #[must_use] 353 + pub fn from_lengths(x: Length, y: Length, z: Length) -> Self { 354 + Self(NPoint3::new( 355 + x.get::<millimeter>(), 356 + y.get::<millimeter>(), 357 + z.get::<millimeter>(), 358 + )) 359 + } 360 + 361 + #[must_use] 362 + pub fn x(self) -> Length { 363 + Length::new::<millimeter>(self.0.x) 364 + } 365 + 366 + #[must_use] 367 + pub fn y(self) -> Length { 368 + Length::new::<millimeter>(self.0.y) 369 + } 370 + 371 + #[must_use] 372 + pub fn z(self) -> Length { 373 + Length::new::<millimeter>(self.0.z) 374 + } 375 + } 376 + 377 + impl core::fmt::Debug for Point3 { 378 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 379 + write!( 380 + f, 381 + "Point3({} mm, {} mm, {} mm)", 382 + self.0.x, self.0.y, self.0.z, 383 + ) 384 + } 385 + } 386 + 387 + impl core::fmt::Display for Point3 { 388 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 389 + write!(f, "({} mm, {} mm, {} mm)", self.0.x, self.0.y, self.0.z) 390 + } 391 + } 392 + 393 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 394 + #[serde(try_from = "UnitVec3Wire", into = "UnitVec3Wire")] 395 + pub struct UnitVec3(Unit<NVec3<f64>>); 396 + 397 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 398 + #[serde(rename = "UnitVec3", deny_unknown_fields)] 399 + struct UnitVec3Wire { 400 + x: f64, 401 + y: f64, 402 + z: f64, 403 + } 404 + 405 + impl From<UnitVec3> for UnitVec3Wire { 406 + fn from(u: UnitVec3) -> Self { 407 + Self { 408 + x: u.0.x, 409 + y: u.0.y, 410 + z: u.0.z, 411 + } 412 + } 413 + } 414 + 415 + impl TryFrom<UnitVec3Wire> for UnitVec3 { 416 + type Error = TypesError; 417 + fn try_from(w: UnitVec3Wire) -> Result<Self> { 418 + Self::try_from_components(w.x, w.y, w.z) 419 + } 420 + } 421 + 422 + impl UnitVec3 { 423 + #[must_use] 424 + pub fn x_axis() -> Self { 425 + Self(Unit::new_unchecked(NVec3::new(1.0, 0.0, 0.0))) 426 + } 427 + 428 + #[must_use] 429 + pub fn y_axis() -> Self { 430 + Self(Unit::new_unchecked(NVec3::new(0.0, 1.0, 0.0))) 431 + } 432 + 433 + #[must_use] 434 + pub fn z_axis() -> Self { 435 + Self(Unit::new_unchecked(NVec3::new(0.0, 0.0, 1.0))) 436 + } 437 + 438 + pub fn try_from_components(x: f64, y: f64, z: f64) -> Result<Self> { 439 + Unit::try_new(NVec3::new(x, y, z), f64::EPSILON) 440 + .map(Self) 441 + .ok_or(TypesError::ZeroLengthAxis) 442 + } 443 + 444 + #[must_use] 445 + pub fn components(self) -> (f64, f64, f64) { 446 + (self.0.x, self.0.y, self.0.z) 447 + } 448 + 449 + #[must_use] 450 + pub fn dot(self, other: Self) -> f64 { 451 + self.0.dot(&other.0) 452 + } 453 + } 454 + 455 + impl core::fmt::Display for UnitVec3 { 456 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 457 + write!(f, "[{}, {}, {}]", self.0.x, self.0.y, self.0.z) 458 + } 459 + } 460 + 461 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 462 + #[serde(try_from = "SketchPlaneBasisWire", into = "SketchPlaneBasisWire")] 463 + pub struct SketchPlaneBasis { 464 + origin: Point3, 465 + x: UnitVec3, 466 + y: UnitVec3, 467 + } 468 + 469 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 470 + #[serde(rename = "SketchPlaneBasis", deny_unknown_fields)] 471 + struct SketchPlaneBasisWire { 472 + origin: Point3, 473 + x_axis: UnitVec3, 474 + y_axis: UnitVec3, 475 + } 476 + 477 + impl From<SketchPlaneBasis> for SketchPlaneBasisWire { 478 + fn from(b: SketchPlaneBasis) -> Self { 479 + Self { 480 + origin: b.origin, 481 + x_axis: b.x, 482 + y_axis: b.y, 483 + } 484 + } 485 + } 486 + 487 + impl TryFrom<SketchPlaneBasisWire> for SketchPlaneBasis { 488 + type Error = TypesError; 489 + fn try_from(w: SketchPlaneBasisWire) -> Result<Self> { 490 + Self::new(w.origin, w.x_axis, w.y_axis, Tolerance::new(1e-9)) 491 + } 492 + } 493 + 494 + impl SketchPlaneBasis { 495 + pub fn new(origin: Point3, x: UnitVec3, y: UnitVec3, tolerance: Tolerance) -> Result<Self> { 496 + let dot = x.dot(y); 497 + if dot.abs() > tolerance.value() { 498 + return Err(TypesError::NonOrthogonalPlaneAxes(dot.abs())); 499 + } 500 + let y_projected = y.0.into_inner() - x.0.into_inner() * dot; 501 + let y_orthonormal = 502 + UnitVec3(Unit::try_new(y_projected, f64::EPSILON).ok_or(TypesError::ZeroLengthAxis)?); 503 + Ok(Self { 504 + origin, 505 + x, 506 + y: y_orthonormal, 507 + }) 508 + } 509 + 510 + #[must_use] 511 + pub fn origin(self) -> Point3 { 512 + self.origin 513 + } 514 + 515 + #[must_use] 516 + pub fn x_axis(self) -> UnitVec3 { 517 + self.x 518 + } 519 + 520 + #[must_use] 521 + pub fn y_axis(self) -> UnitVec3 { 522 + self.y 523 + } 524 + 525 + #[must_use] 526 + pub fn normal(self) -> UnitVec3 { 527 + UnitVec3(Unit::new_normalize(self.x.0.cross(&self.y.0))) 528 + } 529 + } 530 + 531 + impl core::fmt::Display for SketchPlaneBasis { 532 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 533 + write!( 534 + f, 535 + "basis{{ o={}, x={}, y={} }}", 536 + self.origin, self.x, self.y, 537 + ) 538 + } 539 + }
+55
crates/bone-types/src/testing.rs
··· 1 + use std::sync::Once; 2 + 3 + use tracing_subscriber::EnvFilter; 4 + 5 + static INIT: Once = Once::new(); 6 + 7 + pub fn init_test_tracing() { 8 + INIT.call_once(|| { 9 + let filter = EnvFilter::try_from_default_env() 10 + .unwrap_or_else(|_| EnvFilter::new("bone_types=debug")); 11 + let _ = tracing_subscriber::fmt() 12 + .with_env_filter(filter) 13 + .with_test_writer() 14 + .try_init(); 15 + }); 16 + } 17 + 18 + #[derive(Debug, thiserror::Error)] 19 + #[error("non-deterministic output across two invocations of the same closure")] 20 + pub struct NonDeterministic; 21 + 22 + pub fn check_bit_identical<T: Eq, F: Fn() -> T>(f: F) -> Result<(), NonDeterministic> { 23 + let first = f(); 24 + let second = f(); 25 + if first == second { 26 + Ok(()) 27 + } else { 28 + Err(NonDeterministic) 29 + } 30 + } 31 + 32 + #[cfg(test)] 33 + mod tests { 34 + use super::{check_bit_identical, init_test_tracing}; 35 + 36 + #[test] 37 + fn deterministic_closure_is_ok() { 38 + assert!(check_bit_identical(|| 42_u64).is_ok()); 39 + assert!(check_bit_identical(|| (1_u32, 2_u32, 3_u32)).is_ok()); 40 + } 41 + 42 + #[test] 43 + fn non_deterministic_closure_is_err() { 44 + let counter = std::sync::atomic::AtomicU64::new(0); 45 + let result = 46 + check_bit_identical(|| counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); 47 + assert!(result.is_err()); 48 + } 49 + 50 + #[test] 51 + fn init_test_tracing_idempotent() { 52 + init_test_tracing(); 53 + init_test_tracing(); 54 + } 55 + }
+16
crates/bone-types/tests/snapshots/surface__dimensioned_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + length_mm_in=12.5 6 + length_mm_out=12.5 7 + length_m_out=0.0125 8 + angle_deg_in=180 9 + angle_deg_out=180 10 + angle_rad_out=3.1415926535897931 11 + tolerance_value=0.000001 12 + tolerance_display=tol=0.000001 13 + angle_tol_direct_rad=0.0001 14 + angle_tol_direct_display=tol_angle=0.0001 rad 15 + angle_tol_scaled_rad=0.0000001 16 + angle_tol_zero_rad=0
+23
crates/bone-types/tests/snapshots/surface__id_debug_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + FaceId::null = FaceId(null) 6 + EdgeId::null = EdgeId(null) 7 + VertexId::null = VertexId(null) 8 + ShellId::null = ShellId(null) 9 + SolidId::null = SolidId(null) 10 + LoopId::null = LoopId(null) 11 + WireId::null = WireId(null) 12 + FeatureId::null = FeatureId(null) 13 + NodeId::null = NodeId(null) 14 + DocumentId::null = DocumentId(null) 15 + SketchId::null = SketchId(null) 16 + SketchEntityId::null = SketchEntityId(null) 17 + SketchRelationId::null = SketchRelationId(null) 18 + SketchDimensionId::null = SketchDimensionId(null) 19 + SketchParameterId::null = SketchParameterId(null) 20 + FaceId::default = FaceId(null) 21 + FaceId::null.is_null = true 22 + EdgeId::null.is_null = true 23 + SketchEntityId::null.is_null = true
+26
crates/bone-types/tests/snapshots/surface__solver_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + parameter_debug = Parameter(0.25) 6 + parameter_display = param=0.25 7 + parameter_value = 0.25 8 + dof_debug = DegreesOfFreedom(3) 9 + dof_display = dof=3 10 + dof_value = 3 11 + residual_debug = SolverResidual(1.5e-6) 12 + residual_display = res=0.0000015 13 + residual_value = 0.0000015 14 + damping_display = damping=0.000000000001 15 + damping_value = 0.000000000001 16 + step_tol_display = step_tol=0.00000000000001 17 + step_tol_value = 0.00000000000001 18 + parent_debug = ParentIndex(4) 19 + parent_display = parent#4 20 + parent_value = 4 21 + budget_debug = BudgetCeiling(16ms) 22 + budget_display = budget=16ms 23 + budget_duration = 16ms 24 + item_relation = relation(SketchRelationId(null)) 25 + item_dimension = dimension(SketchDimensionId(null)) 26 + item_entity = entity(SketchEntityId(null))
+31
crates/bone-types/tests/snapshots/surface__space_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + point2_debug = Point2(1.5 mm, -2.5 mm) 6 + point2_display = (1.5 mm, -2.5 mm) 7 + point2_x_mm = 1.5 8 + point2_y_mm = -2.5 9 + vec2_debug = Vec2(3 mm, 4 mm) 10 + vec2_display = <3 mm, 4 mm> 11 + vec2_x_mm = 3 12 + vec2_y_mm = 4 13 + point2_sub_mm = (9.000000000000002, 5) 14 + vec2_perp_ccw = <-4 mm, 3 mm> 15 + vec2_norm_mm = 5 16 + unit2_x_debug = UnitVec2([[1.0, 0.0]]) 17 + unit2_x_display= [1, 0] 18 + unit2_y_display= [0, 1] 19 + unit2_perp_y = (-0, 1) 20 + point3_debug = Point3(1 mm, 2 mm, 3 mm) 21 + point3_display = (1 mm, 2 mm, 3 mm) 22 + point3_x_mm = 1 23 + point3_y_mm = 2 24 + point3_z_mm = 3 25 + unit_x_debug = UnitVec3([[1.0, 0.0, 0.0]]) 26 + unit_x_display = [1, 0, 0] 27 + unit_y_display = [0, 1, 0] 28 + unit_z_display = [0, 0, 1] 29 + 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]]) } 30 + basis_display = basis{ o=(1 mm, 2 mm, 3 mm), x=[1, 0, 0], y=[0, 1, 0] } 31 + basis_normal = [0, 0, 1]
+197
crates/bone-types/tests/surface.rs
··· 1 + use bone_types::{ 2 + Angle, AngleTolerance, BudgetCeiling, DegreesOfFreedom, DocumentId, EdgeId, FaceId, FeatureId, 3 + Length, LoopId, NewtonDamping, NewtonStepTolerance, NodeId, Parameter, ParentIndex, Point2, 4 + Point3, ShellId, SketchDimensionId, SketchEntityId, SketchId, SketchItemId, SketchParameterId, 5 + SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, Tolerance, UnitVec2, UnitVec3, 6 + Vec2, VertexId, WireId, degree, millimeter, radian, 7 + }; 8 + use slotmap::Key; 9 + 10 + #[test] 11 + fn id_debug_surface() { 12 + let surface = format!( 13 + "FaceId::null = {:?}\n\ 14 + EdgeId::null = {:?}\n\ 15 + VertexId::null = {:?}\n\ 16 + ShellId::null = {:?}\n\ 17 + SolidId::null = {:?}\n\ 18 + LoopId::null = {:?}\n\ 19 + WireId::null = {:?}\n\ 20 + FeatureId::null = {:?}\n\ 21 + NodeId::null = {:?}\n\ 22 + DocumentId::null = {:?}\n\ 23 + SketchId::null = {:?}\n\ 24 + SketchEntityId::null = {:?}\n\ 25 + SketchRelationId::null = {:?}\n\ 26 + SketchDimensionId::null = {:?}\n\ 27 + SketchParameterId::null = {:?}\n\ 28 + FaceId::default = {:?}\n\ 29 + FaceId::null.is_null = {}\n\ 30 + EdgeId::null.is_null = {}\n\ 31 + SketchEntityId::null.is_null = {}", 32 + FaceId::null(), 33 + EdgeId::null(), 34 + VertexId::null(), 35 + ShellId::null(), 36 + SolidId::null(), 37 + LoopId::null(), 38 + WireId::null(), 39 + FeatureId::null(), 40 + NodeId::null(), 41 + DocumentId::null(), 42 + SketchId::null(), 43 + SketchEntityId::null(), 44 + SketchRelationId::null(), 45 + SketchDimensionId::null(), 46 + SketchParameterId::null(), 47 + FaceId::default(), 48 + FaceId::null().is_null(), 49 + EdgeId::null().is_null(), 50 + SketchEntityId::null().is_null(), 51 + ); 52 + insta::assert_snapshot!(surface); 53 + } 54 + 55 + #[test] 56 + fn dimensioned_surface() { 57 + use uom::si::length::meter; 58 + let l = Length::new::<millimeter>(12.5); 59 + let a = Angle::new::<degree>(180.0); 60 + let t = Tolerance::new(1e-6); 61 + let at_direct = AngleTolerance::from_radians(1e-4); 62 + let at_scaled = AngleTolerance::from_arc_length(t, Length::new::<millimeter>(10.0)); 63 + let at_zero = AngleTolerance::ZERO; 64 + let surface = format!( 65 + "length_mm_in={mm_in}\n\ 66 + length_mm_out={mm_out}\n\ 67 + length_m_out={m_out}\n\ 68 + angle_deg_in=180\n\ 69 + angle_deg_out={deg_out}\n\ 70 + angle_rad_out={rad_out:.16}\n\ 71 + tolerance_value={tol_val}\n\ 72 + tolerance_display={tol_disp}\n\ 73 + angle_tol_direct_rad={atd_rad}\n\ 74 + angle_tol_direct_display={atd_disp}\n\ 75 + angle_tol_scaled_rad={ats_rad}\n\ 76 + angle_tol_zero_rad={atz_rad}", 77 + mm_in = 12.5, 78 + mm_out = l.get::<millimeter>(), 79 + m_out = l.get::<meter>(), 80 + deg_out = a.get::<degree>(), 81 + rad_out = a.get::<radian>(), 82 + tol_val = t.value(), 83 + tol_disp = t, 84 + atd_rad = at_direct.radians(), 85 + atd_disp = at_direct, 86 + ats_rad = at_scaled.radians(), 87 + atz_rad = at_zero.radians(), 88 + ); 89 + insta::assert_snapshot!(surface); 90 + } 91 + 92 + #[test] 93 + fn space_surface() { 94 + let p2 = Point2::from_mm(1.5, -2.5); 95 + let v2 = Vec2::from_mm(3.0, 4.0); 96 + let p3 = Point3::from_mm(1.0, 2.0, 3.0); 97 + let u2x = UnitVec2::x_axis(); 98 + let u2y = UnitVec2::y_axis(); 99 + let ux = UnitVec3::x_axis(); 100 + let uy = UnitVec3::y_axis(); 101 + let uz = UnitVec3::z_axis(); 102 + let Ok(basis) = SketchPlaneBasis::new(p3, ux, uy, Tolerance::new(1e-9)) else { 103 + panic!("x and y unit axes are orthogonal"); 104 + }; 105 + let diff = Point2::from_mm(10.0, 7.0) - Point2::from_mm(1.0, 2.0); 106 + 107 + let surface = format!( 108 + "point2_debug = {p2:?}\n\ 109 + point2_display = {p2}\n\ 110 + point2_x_mm = {p2x}\n\ 111 + point2_y_mm = {p2y}\n\ 112 + vec2_debug = {v2:?}\n\ 113 + vec2_display = {v2}\n\ 114 + vec2_x_mm = {v2x}\n\ 115 + vec2_y_mm = {v2y}\n\ 116 + point2_sub_mm = ({dx}, {dy})\n\ 117 + vec2_perp_ccw = {vperp}\n\ 118 + vec2_norm_mm = {vnorm}\n\ 119 + unit2_x_debug = {u2x:?}\n\ 120 + unit2_x_display= {u2x}\n\ 121 + unit2_y_display= {u2y}\n\ 122 + unit2_perp_y = ({u2px}, {u2py})\n\ 123 + point3_debug = {p3:?}\n\ 124 + point3_display = {p3}\n\ 125 + point3_x_mm = {p3x}\n\ 126 + point3_y_mm = {p3y}\n\ 127 + point3_z_mm = {p3z}\n\ 128 + unit_x_debug = {ux:?}\n\ 129 + unit_x_display = {ux}\n\ 130 + unit_y_display = {uy}\n\ 131 + unit_z_display = {uz}\n\ 132 + basis_debug = {basis:?}\n\ 133 + basis_display = {basis}\n\ 134 + basis_normal = {normal}", 135 + p2x = p2.x().get::<millimeter>(), 136 + p2y = p2.y().get::<millimeter>(), 137 + v2x = v2.x().get::<millimeter>(), 138 + v2y = v2.y().get::<millimeter>(), 139 + dx = diff.x().get::<millimeter>(), 140 + dy = diff.y().get::<millimeter>(), 141 + vperp = v2.perp_ccw(), 142 + vnorm = v2.norm_mm(), 143 + u2px = u2x.perp_ccw().components().0, 144 + u2py = u2x.perp_ccw().components().1, 145 + p3x = p3.x().get::<millimeter>(), 146 + p3y = p3.y().get::<millimeter>(), 147 + p3z = p3.z().get::<millimeter>(), 148 + normal = basis.normal(), 149 + ); 150 + insta::assert_snapshot!(surface); 151 + } 152 + 153 + #[test] 154 + fn solver_surface() { 155 + let p = Parameter::new(0.25); 156 + let d = DegreesOfFreedom::new(3); 157 + let r = SolverResidual::new(1.5e-6); 158 + let damp = NewtonDamping::DEFAULT; 159 + let step_tol = NewtonStepTolerance::DEFAULT; 160 + let parent = ParentIndex::new(4); 161 + let budget = BudgetCeiling::FRAME_16MS; 162 + let item_rel = SketchItemId::Relation(SketchRelationId::default()); 163 + let item_dim = SketchItemId::Dimension(SketchDimensionId::default()); 164 + let item_ent = SketchItemId::Entity(SketchEntityId::default()); 165 + let surface = format!( 166 + "parameter_debug = {p:?}\n\ 167 + parameter_display = {p}\n\ 168 + parameter_value = {pv}\n\ 169 + dof_debug = {d:?}\n\ 170 + dof_display = {d}\n\ 171 + dof_value = {dv}\n\ 172 + residual_debug = {r:?}\n\ 173 + residual_display = {r}\n\ 174 + residual_value = {rv}\n\ 175 + damping_display = {damp}\n\ 176 + damping_value = {damp_v}\n\ 177 + step_tol_display = {step_tol}\n\ 178 + step_tol_value = {step_tol_v}\n\ 179 + parent_debug = {parent:?}\n\ 180 + parent_display = {parent}\n\ 181 + parent_value = {parent_v}\n\ 182 + budget_debug = {budget:?}\n\ 183 + budget_display = {budget}\n\ 184 + budget_duration = {budget_d:?}\n\ 185 + item_relation = {item_rel}\n\ 186 + item_dimension = {item_dim}\n\ 187 + item_entity = {item_ent}", 188 + pv = p.value(), 189 + dv = d.value(), 190 + rv = r.value(), 191 + damp_v = damp.value(), 192 + step_tol_v = step_tol.value(), 193 + parent_v = parent.value(), 194 + budget_d = budget.duration(), 195 + ); 196 + insta::assert_snapshot!(surface); 197 + }
+37
justfile
··· 1 + default: 2 + @just --list 3 + 4 + check: 5 + cargo check --workspace --all-targets --all-features 6 + 7 + build: 8 + cargo build --workspace 9 + 10 + build-release: 11 + cargo build --workspace --release 12 + 13 + run: 14 + cargo run -p bone-app 15 + 16 + test: 17 + cargo test --workspace --all-features 18 + 19 + clippy: 20 + cargo clippy --workspace --all-targets --all-features -- -D warnings 21 + 22 + snap: 23 + cargo insta test --workspace --all-features 24 + 25 + snap-review: 26 + cargo insta review 27 + 28 + snap-accept: 29 + cargo insta accept 30 + 31 + fmt: 32 + cargo fmt --all 33 + 34 + fmt-check: 35 + cargo fmt --all -- --check 36 + 37 + lint: fmt-check clippy
+3
rust-toolchain.toml
··· 1 + [toolchain] 2 + channel = "1.95.0" 3 + components = ["rustfmt", "clippy"]