From dafce1f4e2057d56801b630472bd2616d1584ed8 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 17:01:29 +0000 Subject: [PATCH] M0: workspace, geometry foundation, sketch constraint solver, kernel boundary Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 2 + .gitignore | 7 + Cargo.lock | 1388 +++++++++++++++++++++++++++++++ Cargo.toml | 66 ++ LICENSE-APACHE | 177 ++++ LICENSE-MIT | 21 + apps/solvecraft-cli/Cargo.toml | 5 + apps/solvecraft-cli/src/main.rs | 1 + apps/solvecraft/Cargo.toml | 5 + apps/solvecraft/src/main.rs | 1 + clippy.toml | 4 + crates/doc/Cargo.toml | 8 + crates/doc/src/lib.rs | 1 + crates/engine/Cargo.toml | 8 + crates/engine/src/lib.rs | 1 + crates/geom/Cargo.toml | 15 + crates/geom/src/lib.rs | 30 + crates/geom/src/mesh.rs | 141 ++++ crates/geom/src/plane.rs | 83 ++ crates/geom/src/profile.rs | 263 ++++++ crates/geom/src/tests.rs | 64 ++ crates/geom/src/vec.rs | 159 ++++ crates/io/Cargo.toml | 8 + crates/io/src/lib.rs | 1 + crates/kernel/Cargo.toml | 19 + crates/kernel/src/body.rs | 232 ++++++ crates/kernel/src/build.rs | 202 +++++ crates/kernel/src/lib.rs | 55 ++ crates/kernel/src/measure.rs | 42 + crates/kernel/src/ops.rs | 112 +++ crates/kernel/src/step.rs | 18 + crates/kernel/src/tests.rs | 137 +++ crates/kernel/src/topo.rs | 263 ++++++ crates/render/Cargo.toml | 8 + crates/render/src/lib.rs | 1 + crates/sketch/Cargo.toml | 17 + crates/sketch/src/lib.rs | 21 + crates/sketch/src/linalg.rs | 182 ++++ crates/sketch/src/model.rs | 674 +++++++++++++++ crates/sketch/src/profiles.rs | 273 ++++++ crates/sketch/src/solver.rs | 636 ++++++++++++++ crates/sketch/src/tests.rs | 252 ++++++ crates/ui-egui/Cargo.toml | 8 + crates/ui-egui/src/lib.rs | 1 + rustfmt.toml | 2 + xtask/Cargo.toml | 9 + xtask/src/main.rs | 1 + 47 files changed, 5624 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 apps/solvecraft-cli/Cargo.toml create mode 100644 apps/solvecraft-cli/src/main.rs create mode 100644 apps/solvecraft/Cargo.toml create mode 100644 apps/solvecraft/src/main.rs create mode 100644 clippy.toml create mode 100644 crates/doc/Cargo.toml create mode 100644 crates/doc/src/lib.rs create mode 100644 crates/engine/Cargo.toml create mode 100644 crates/engine/src/lib.rs create mode 100644 crates/geom/Cargo.toml create mode 100644 crates/geom/src/lib.rs create mode 100644 crates/geom/src/mesh.rs create mode 100644 crates/geom/src/plane.rs create mode 100644 crates/geom/src/profile.rs create mode 100644 crates/geom/src/tests.rs create mode 100644 crates/geom/src/vec.rs create mode 100644 crates/io/Cargo.toml create mode 100644 crates/io/src/lib.rs create mode 100644 crates/kernel/Cargo.toml create mode 100644 crates/kernel/src/body.rs create mode 100644 crates/kernel/src/build.rs create mode 100644 crates/kernel/src/lib.rs create mode 100644 crates/kernel/src/measure.rs create mode 100644 crates/kernel/src/ops.rs create mode 100644 crates/kernel/src/step.rs create mode 100644 crates/kernel/src/tests.rs create mode 100644 crates/kernel/src/topo.rs create mode 100644 crates/render/Cargo.toml create mode 100644 crates/render/src/lib.rs create mode 100644 crates/sketch/Cargo.toml create mode 100644 crates/sketch/src/lib.rs create mode 100644 crates/sketch/src/linalg.rs create mode 100644 crates/sketch/src/model.rs create mode 100644 crates/sketch/src/profiles.rs create mode 100644 crates/sketch/src/solver.rs create mode 100644 crates/sketch/src/tests.rs create mode 100644 crates/ui-egui/Cargo.toml create mode 100644 crates/ui-egui/src/lib.rs create mode 100644 rustfmt.toml create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e30dd0a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/plan/ +/dist/ +*~ +.DS_Store +*.swp +/.claude/worktrees/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..9c69f3e9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1388 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "array-macro" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220a2c618ab466efe41d0eace94dfeff1c35e3aa47891bdb95e1c0fefffd3c99" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "branches" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx", + "num-traits", + "serde", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "katexit" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfb0b7ce7938f84a5ecbdca5d0a991e46bc9d6d078934ad5e92c5270fe547db" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matext4cgmath" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178" +dependencies = [ + "cgmath", + "katexit", + "num-complex", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "monstertruck-assembly" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1041107e797756c1176344c81461d7bb7298ee00c3e9ee5adf093d14f627d56" +dependencies = [ + "monstertruck-core", + "rayon", +] + +[[package]] +name = "monstertruck-core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f34f2f284b16af3672085cdcaf5d6521feecc104bacf1a37f1fac6c8c1a5884" +dependencies = [ + "cgmath", + "matext4cgmath", + "rustc-hash", + "serde", + "xxhash-rust", +] + +[[package]] +name = "monstertruck-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c2cdecf25f2d799557e29b5b8c9b6db4d766d150e29aad6d4bba5204f2ea58" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "monstertruck-fillet" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512bba6a317473cd83210019054373af9d13af4273417f28ee54bee5d16bc6c3" +dependencies = [ + "ahash", + "derive_more", + "getrandom 0.3.4", + "itertools 0.14.0", + "monstertruck-core", + "monstertruck-geometry", + "monstertruck-meshing", + "monstertruck-topology", + "monstertruck-traits", + "smallvec", + "thiserror", +] + +[[package]] +name = "monstertruck-geometry" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cee8ce45200826a20c752ae24c82f5048dcddd2f168230c1799c214b804d6f40" +dependencies = [ + "monstertruck-core", + "monstertruck-traits", + "parking_lot", + "serde", + "smallvec", + "thiserror", +] + +[[package]] +name = "monstertruck-io" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9db95ba8ed9ba913084f04e865b8e522813f9885f40497b8717ede6c6f1542e" +dependencies = [ + "chrono", + "derive_more", + "monstertruck-assembly", + "monstertruck-core", + "monstertruck-derive", + "monstertruck-geometry", + "monstertruck-mesh", + "monstertruck-modeling", + "monstertruck-topology", + "monstertruck-traits", + "serde", + "step-p21", + "thiserror", + "web-time", +] + +[[package]] +name = "monstertruck-mesh" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875dbf22ba985790b87e652bfaa1ed24852d3746b1ac022ac7b6c28ba35121c6" +dependencies = [ + "array-macro", + "bytemuck", + "itertools 0.14.0", + "monstertruck-core", + "monstertruck-traits", + "rayon", + "rustc-hash", + "serde", + "thiserror", +] + +[[package]] +name = "monstertruck-meshing" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea03901ea0b3cfaf380c1cc8c2efee85500cdfe260c38bb8278c2a66a18de939" +dependencies = [ + "array-macro", + "derive_more", + "itertools 0.14.0", + "log", + "monstertruck-core", + "monstertruck-geometry", + "monstertruck-mesh", + "monstertruck-topology", + "rayon", + "rustc-hash", + "spade", + "web-time", +] + +[[package]] +name = "monstertruck-modeling" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518f0c01eb240558e830d6734ab88dc0bf559923811553302aa1f3c16051c211" +dependencies = [ + "derive_more", + "itertools 0.14.0", + "monstertruck-core", + "monstertruck-fillet", + "monstertruck-geometry", + "monstertruck-mesh", + "monstertruck-topology", + "monstertruck-traits", + "rayon", + "rustc-hash", + "serde", + "thiserror", + "web-time", +] + +[[package]] +name = "monstertruck-solid" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29e8fab1d370bfe553a171c0b0948810eefc66ecb2e3dca87e26c985bda27e50" +dependencies = [ + "ahash", + "derive_more", + "getrandom 0.3.4", + "itertools 0.14.0", + "monstertruck-core", + "monstertruck-geometry", + "monstertruck-meshing", + "monstertruck-topology", + "monstertruck-traits", + "rustc-hash", + "serde", + "smallvec", + "thiserror", +] + +[[package]] +name = "monstertruck-topology" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def1bdeca2fd361435fec506495aaade7caba299accfbb4da1997a1bc300faf5" +dependencies = [ + "monstertruck-core", + "monstertruck-traits", + "parking_lot", + "rayon", + "rclite", + "rustc-hash", + "serde", + "thiserror", +] + +[[package]] +name = "monstertruck-traits" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b8f72eb09b5f613f6ccc314c53bc4c43de1a0d1d5a29eae61f8b9b8b04eed1" +dependencies = [ + "getrandom 0.4.3", + "monstertruck-core", + "monstertruck-derive", + "num-traits", + "rand", + "thiserror", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rclite" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6716a637a8a17bce72da6b6ddab8548619cc748658c41ab6ee5c4a3c1001385" +dependencies = [ + "branches", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "solvecraft" +version = "0.1.0" + +[[package]] +name = "solvecraft-cli" +version = "0.1.0" + +[[package]] +name = "solvecraft-doc" +version = "0.1.0" + +[[package]] +name = "solvecraft-engine" +version = "0.1.0" + +[[package]] +name = "solvecraft-geom" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "solvecraft-io" +version = "0.1.0" + +[[package]] +name = "solvecraft-kernel" +version = "0.1.0" +dependencies = [ + "log", + "monstertruck-io", + "monstertruck-meshing", + "monstertruck-modeling", + "monstertruck-solid", + "serde", + "solvecraft-geom", + "thiserror", +] + +[[package]] +name = "solvecraft-render" +version = "0.1.0" + +[[package]] +name = "solvecraft-sketch" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "solvecraft-geom", + "thiserror", +] + +[[package]] +name = "solvecraft-ui-egui" +version = "0.1.0" + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "step-p21" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261fb24eb3fd1de07c36523c41063ea901cde514594603cb6dfe7afe64f1b967" +dependencies = [ + "Inflector", + "derive-new", + "derive_more", + "itertools 0.15.0", + "nom", + "nom-language", + "serde", + "step-p21-derive", + "thiserror", +] + +[[package]] +name = "step-p21-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "432f60e41744c351a88a444fbe134ca9bb8d930c6442fe6334728963ba5dd782" +dependencies = [ + "Inflector", + "proc-macro-crate", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + +[[package]] +name = "zerocopy" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..f69b017f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,66 @@ +[workspace] +resolver = "3" +members = ["crates/*", "apps/*", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" +authors = ["SolveCraft contributors"] +repository = "https://github.com/storytold/solvecraft" + +[workspace.dependencies] +# internal +solvecraft-geom = { path = "crates/geom" } +solvecraft-sketch = { path = "crates/sketch" } +solvecraft-kernel = { path = "crates/kernel" } +solvecraft-render = { path = "crates/render" } +solvecraft-doc = { path = "crates/doc" } +solvecraft-io = { path = "crates/io" } +solvecraft-engine = { path = "crates/engine" } +solvecraft-ui-egui = { path = "crates/ui-egui" } + +# foundation +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" + +# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it. +monstertruck-modeling = { version = "0.4.1", features = ["fillet"] } +monstertruck-solid = "0.4.1" +monstertruck-meshing = "0.4.1" +monstertruck-io = "0.4.1" + +# images +image = { version = "0.25", default-features = false, features = ["png"] } + +# ui +egui = "0.36" +eframe = { version = "0.36", default-features = false } +egui-wgpu = "0.36" +rfd = "0.17" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +# `!(x > tol)` is our idiom for "not a usable number" (NaN-safe). +neg_cmp_op_on_partial_ord = "allow" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 16 +debug = 0 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..d19e8efe --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SolveCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/solvecraft-cli/Cargo.toml b/apps/solvecraft-cli/Cargo.toml new file mode 100644 index 00000000..bbefa761 --- /dev/null +++ b/apps/solvecraft-cli/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "solvecraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs new file mode 100644 index 00000000..f328e4d9 --- /dev/null +++ b/apps/solvecraft-cli/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/apps/solvecraft/Cargo.toml b/apps/solvecraft/Cargo.toml new file mode 100644 index 00000000..5f08b89d --- /dev/null +++ b/apps/solvecraft/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "solvecraft" +version.workspace = true +edition.workspace = true +license.workspace = true diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs new file mode 100644 index 00000000..f328e4d9 --- /dev/null +++ b/apps/solvecraft/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..4b6c79f5 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml new file mode 100644 index 00000000..44cd0852 --- /dev/null +++ b/crates/doc/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "solvecraft-doc" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 00000000..de800ca1 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "solvecraft-engine" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml new file mode 100644 index 00000000..498ce4e6 --- /dev/null +++ b/crates/geom/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "solvecraft-geom" +description = "SolveCraft geometry foundation: vectors, planes, frames, meshes and their measures" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs new file mode 100644 index 00000000..55c05ffd --- /dev/null +++ b/crates/geom/src/lib.rs @@ -0,0 +1,30 @@ +//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes and +//! triangle meshes with their measures (volume, area, centroid). Units are millimetres. +//! +//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate +//! builds on it. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod mesh; +mod plane; +mod profile; +mod vec; + +pub use mesh::{Aabb3, Mesh, MeshMeasure}; +pub use plane::Plane; +pub use profile::{Loop2, Region2, Seg2}; +pub use vec::{Vec2, Vec3}; + +/// Geometric tolerance for coincidence tests (mm). +pub const EPS: f64 = 1e-9; +/// Linear tolerance used for "same point" decisions in modelling (mm). +pub const LINEAR_TOL: f64 = 1e-6; + +/// `x` if finite, else `fallback`. +pub fn finite_or(x: f64, fallback: f64) -> f64 { + if x.is_finite() { x } else { fallback } +} + +#[cfg(test)] +mod tests; diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs new file mode 100644 index 00000000..33dcc5aa --- /dev/null +++ b/crates/geom/src/mesh.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; + +use crate::Vec3; + +/// Axis-aligned bounding box. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Aabb3 { + pub min: Vec3, + pub max: Vec3, +} + +impl Default for Aabb3 { + fn default() -> Self { + Aabb3::EMPTY + } +} + +impl Aabb3 { + pub const EMPTY: Aabb3 = Aabb3 { + min: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), + max: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), + }; + pub fn is_empty(&self) -> bool { + !(self.min.x <= self.max.x && self.min.y <= self.max.y && self.min.z <= self.max.z) + } + pub fn add(&mut self, p: Vec3) { + if p.is_finite() { + self.min = self.min.min(p); + self.max = self.max.max(p); + } + } + pub fn union(&self, o: &Aabb3) -> Aabb3 { + if self.is_empty() { + return *o; + } + if o.is_empty() { + return *self; + } + Aabb3 { min: self.min.min(o.min), max: self.max.max(o.max) } + } + pub fn center(&self) -> Vec3 { + if self.is_empty() { Vec3::ZERO } else { (self.min + self.max) * 0.5 } + } + pub fn size(&self) -> Vec3 { + if self.is_empty() { Vec3::ZERO } else { self.max - self.min } + } + pub fn diagonal(&self) -> f64 { + self.size().len() + } +} + +/// An indexed triangle mesh with per-vertex normals, a face id per triangle (the B-rep face it +/// came from) and edge polylines (the B-rep edges, for display and picking). +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Mesh { + pub positions: Vec, + pub normals: Vec, + /// Triangles as vertex index triples (counter-clockwise seen from outside). + pub triangles: Vec<[u32; 3]>, + /// B-rep face index per triangle. + pub tri_face: Vec, + /// B-rep edges as polylines. + pub edges: Vec>, +} + +/// Mass properties of a closed mesh. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MeshMeasure { + pub volume: f64, + pub area: f64, + pub centroid: Vec3, +} + +impl Mesh { + pub fn tri(&self, t: &[u32; 3]) -> Option<[Vec3; 3]> { + Some([*self.positions.get(t[0] as usize)?, *self.positions.get(t[1] as usize)?, *self.positions.get(t[2] as usize)?]) + } + + pub fn bounds(&self) -> Aabb3 { + let mut b = Aabb3::EMPTY; + for p in &self.positions { + b.add(*p); + } + b + } + + /// Signed volume (divergence theorem), surface area and volume centroid. + pub fn measure(&self) -> MeshMeasure { + let (mut v6, mut area, mut c) = (0.0, 0.0, Vec3::ZERO); + for t in &self.triangles { + let Some([a, b, cc]) = self.tri(t) else { continue }; + let d = a.dot(b.cross(cc)); + v6 += d; + c += (a + b + cc) * d; + area += (b - a).cross(cc - a).len() * 0.5; + } + let volume = v6 / 6.0; + let centroid = if v6.abs() > 1e-12 { c / (4.0 * v6) } else { self.bounds().center() }; + MeshMeasure { volume, area, centroid } + } + + /// Append another mesh (face ids offset by `face_offset`). + pub fn append(&mut self, o: &Mesh, face_offset: u32) { + let base = u32::try_from(self.positions.len()).unwrap_or(u32::MAX); + self.positions.extend_from_slice(&o.positions); + self.normals.extend_from_slice(&o.normals); + self.triangles.extend(o.triangles.iter().map(|t| [t[0].saturating_add(base), t[1].saturating_add(base), t[2].saturating_add(base)])); + self.tri_face.extend(o.tri_face.iter().map(|f| f.saturating_add(face_offset))); + self.edges.extend(o.edges.iter().cloned()); + } + + /// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index). + pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> { + let mut best: Option<(f64, usize)> = None; + for (i, t) in self.triangles.iter().enumerate() { + let Some([a, b, c]) = self.tri(t) else { continue }; + let (e1, e2) = (b - a, c - a); + let p = dir.cross(e2); + let det = e1.dot(p); + if det.abs() < 1e-14 { + continue; + } + let inv = 1.0 / det; + let s = o - a; + let u = s.dot(p) * inv; + if !(0.0..=1.0).contains(&u) { + continue; + } + let q = s.cross(e1); + let v = dir.dot(q) * inv; + if v < 0.0 || u + v > 1.0 { + continue; + } + let t = e2.dot(q) * inv; + if t > 1e-9 && best.is_none_or(|(bt, _)| t < bt) { + best = Some((t, i)); + } + } + best + } +} diff --git a/crates/geom/src/plane.rs b/crates/geom/src/plane.rs new file mode 100644 index 00000000..d644e390 --- /dev/null +++ b/crates/geom/src/plane.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Vec2, Vec3}; + +/// A sketch plane: an origin and two orthonormal in-plane axes. The normal is `x × y`. +/// +/// World point of sketch point `(u, v)` = `origin + u·x + v·y`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Plane { + pub origin: Vec3, + #[serde(rename = "x_dir")] + pub x: Vec3, + #[serde(rename = "y_dir")] + pub y: Vec3, +} + +impl Plane { + /// Top: sketch x = +X, y = +Y, normal +Z. + pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y }; + /// Front: sketch x = +X, y = +Z, normal −Y. + pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z }; + /// Right: sketch x = +Y, y = +Z, normal +X. + pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z }; + + /// Plane through `origin` with the given axes, re-orthonormalised. `None` if degenerate. + pub fn new(origin: Vec3, x: Vec3, y: Vec3) -> Option { + let x = x.normalized()?; + let n = x.cross(y).normalized()?; + let y = n.cross(x).normalized()?; + origin.is_finite().then_some(Plane { origin, x, y }) + } + + /// Plane through `origin` with normal `n` (in-plane axes chosen deterministically). + pub fn from_normal(origin: Vec3, n: Vec3) -> Option { + let n = n.normalized()?; + // Horizontal planes keep world X as sketch x; others use the horizontal direction Z × n. + let cand = if n.dot(Vec3::Z).abs() > 0.9 { Vec3::X } else { Vec3::Z.cross(n) }; + let x = (cand - n * cand.dot(n)).normalized()?; + Plane::new(origin, x, n.cross(x)) + } + + /// Named origin plane: `XY`, `XZ`, `YZ` (case-insensitive). + pub fn named(name: &str) -> Option { + match name.to_ascii_uppercase().as_str() { + "XY" | "TOP" => Some(Plane::XY), + "XZ" | "FRONT" => Some(Plane::XZ), + "YZ" | "RIGHT" => Some(Plane::YZ), + _ => None, + } + } + + pub fn normal(&self) -> Vec3 { + self.x.cross(self.y) + } + pub fn to_world(&self, p: Vec2) -> Vec3 { + self.origin + self.x * p.x + self.y * p.y + } + pub fn dir_to_world(&self, d: Vec2) -> Vec3 { + self.x * d.x + self.y * d.y + } + pub fn to_local(&self, p: Vec3) -> Vec2 { + let d = p - self.origin; + Vec2::new(d.dot(self.x), d.dot(self.y)) + } + /// Signed distance of `p` above the plane (along the normal). + pub fn height(&self, p: Vec3) -> f64 { + (p - self.origin).dot(self.normal()) + } + /// The plane moved along its normal by `d`. + pub fn offset(&self, d: f64) -> Plane { + Plane { origin: self.origin + self.normal() * d, ..*self } + } + /// Intersection of the ray `o + t·dir` with the plane (t may be negative). + pub fn intersect_ray(&self, o: Vec3, dir: Vec3) -> Option { + let n = self.normal(); + let den = dir.dot(n); + if den.abs() < 1e-12 { + return None; + } + let t = (self.origin - o).dot(n) / den; + t.is_finite().then(|| o + dir * t) + } +} diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs new file mode 100644 index 00000000..0b42cf09 --- /dev/null +++ b/crates/geom/src/profile.rs @@ -0,0 +1,263 @@ +//! Planar profiles: closed loops of lines and circular arcs, and regions (an outer loop minus +//! holes). Sketches produce them; the kernel turns them into faces. + +use std::f64::consts::TAU; + +use serde::{Deserialize, Serialize}; + +use crate::Vec2; + +/// One boundary segment in sketch coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Seg2 { + Line { + a: Vec2, + b: Vec2, + }, + /// Arc from `start` angle sweeping `sweep` radians (positive = counter-clockwise). + Arc { + center: Vec2, + radius: f64, + start: f64, + sweep: f64, + }, +} + +impl Seg2 { + pub fn start(&self) -> Vec2 { + match *self { + Seg2::Line { a, .. } => a, + Seg2::Arc { center, radius, start, .. } => center + Vec2::from_angle(start) * radius, + } + } + pub fn end(&self) -> Vec2 { + match *self { + Seg2::Line { b, .. } => b, + Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep) * radius, + } + } + pub fn point_at(&self, t: f64) -> Vec2 { + match *self { + Seg2::Line { a, b } => a.lerp(b, t), + Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep * t) * radius, + } + } + pub fn mid(&self) -> Vec2 { + self.point_at(0.5) + } + /// Unit tangent at the start (direction of travel). + pub fn start_tangent(&self) -> Vec2 { + match *self { + Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X), + Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start).perp() * sweep.signum(), + } + } + /// Unit tangent at the end (direction of travel). + pub fn end_tangent(&self) -> Vec2 { + match *self { + Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X), + Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start + sweep).perp() * sweep.signum(), + } + } + pub fn reversed(&self) -> Seg2 { + match *self { + Seg2::Line { a, b } => Seg2::Line { a: b, b: a }, + Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep, sweep: -sweep }, + } + } + pub fn length(&self) -> f64 { + match *self { + Seg2::Line { a, b } => a.dist(b), + Seg2::Arc { radius, sweep, .. } => (radius * sweep).abs(), + } + } + /// Contribution to the loop's signed area (shoelace integral ½∮(x dy − y dx)). + pub fn area_term(&self) -> f64 { + match *self { + Seg2::Line { a, b } => 0.5 * a.cross(b), + Seg2::Arc { radius, sweep, .. } => { + let (a, b) = (self.start(), self.end()); + // Chord term plus the circular segment between chord and arc. + 0.5 * a.cross(b) + 0.5 * radius * radius * (sweep - sweep.sin()) + } + } + } + /// Polyline approximation (including both end points) with at most `tol` chord error. + pub fn polyline(&self, tol: f64) -> Vec { + match *self { + Seg2::Line { a, b } => vec![a, b], + Seg2::Arc { radius, sweep, .. } => { + let tol = tol.max(radius.abs() * 1e-6).max(1e-9); + let step = if tol < radius { 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos() } else { TAU / 8.0 }; + let n = ((sweep.abs() / step.max(1e-6)).ceil() as usize).clamp(2, 4096); + (0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect() + } + } + } +} + +/// A closed loop of segments, each starting where the previous one ends. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Loop2 { + pub segs: Vec, +} + +impl Loop2 { + /// Full circle as two half arcs (counter-clockwise). + pub fn circle(center: Vec2, radius: f64) -> Loop2 { + Loop2 { + segs: vec![ + Seg2::Arc { center, radius, start: 0.0, sweep: std::f64::consts::PI }, + Seg2::Arc { center, radius, start: std::f64::consts::PI, sweep: std::f64::consts::PI }, + ], + } + } + pub fn polygon(pts: &[Vec2]) -> Loop2 { + let n = pts.len(); + Loop2 { segs: (0..n).filter_map(|i| Some(Seg2::Line { a: *pts.get(i)?, b: *pts.get((i + 1) % n)? })).collect() } + } + /// Signed area: positive when counter-clockwise. + pub fn signed_area(&self) -> f64 { + self.segs.iter().map(Seg2::area_term).sum() + } + pub fn reversed(&self) -> Loop2 { + Loop2 { segs: self.segs.iter().rev().map(Seg2::reversed).collect() } + } + /// Counter-clockwise copy. + pub fn ccw(&self) -> Loop2 { + if self.signed_area() < 0.0 { self.reversed() } else { self.clone() } + } + pub fn polyline(&self, tol: f64) -> Vec { + let mut out = Vec::new(); + for s in &self.segs { + let p = s.polyline(tol); + out.extend(p.iter().take(p.len().saturating_sub(1))); + } + out + } + /// Even-odd point containment against the polyline approximation. + pub fn contains(&self, p: Vec2) -> bool { + let poly = self.polyline(1e-3); + let n = poly.len(); + let mut inside = false; + for i in 0..n { + let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue }; + if (a.y > p.y) != (b.y > p.y) { + let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x); + if p.x < x { + inside = !inside; + } + } + } + inside + } + /// Area-weighted centroid (of the polyline approximation). + pub fn centroid(&self) -> Vec2 { + let poly = self.polyline(1e-3); + let n = poly.len(); + let (mut a2, mut c) = (0.0, Vec2::ZERO); + for i in 0..n { + let (Some(p), Some(q)) = (poly.get(i), poly.get((i + 1) % n)) else { continue }; + let k = p.cross(*q); + a2 += k; + c += (*p + *q) * k; + } + if a2.abs() < 1e-15 { poly.first().copied().unwrap_or_default() } else { c / (3.0 * a2) } + } + /// Closed within `tol`? + pub fn is_closed(&self, tol: f64) -> bool { + let n = self.segs.len(); + n > 0 + && (0..n).all(|i| match (self.segs.get(i), self.segs.get((i + 1) % n)) { + (Some(a), Some(b)) => a.end().dist(b.start()) <= tol, + _ => false, + }) + } +} + +/// A planar region: outer loop (counter-clockwise) minus holes (clockwise). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Region2 { + pub outer: Loop2, + pub holes: Vec, +} + +impl Region2 { + pub fn area(&self) -> f64 { + self.outer.signed_area().abs() - self.holes.iter().map(|h| h.signed_area().abs()).sum::() + } + /// A point strictly inside the region (for picking and labels). + pub fn interior_point(&self) -> Vec2 { + let c = self.centroid(); + if self.contains(c) { + return c; + } + // Scan a few horizontal lines for the midpoint of the widest inside span. + let poly = self.outer.polyline(1e-2); + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for p in &poly { + y0 = y0.min(p.y); + y1 = y1.max(p.y); + } + for k in 1..16 { + let y = y0 + (y1 - y0) * k as f64 / 16.0; + let mut xs: Vec = Vec::new(); + for l in std::iter::once(&self.outer).chain(&self.holes) { + let pl = l.polyline(1e-2); + let n = pl.len(); + for i in 0..n { + let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue }; + if (a.y > y) != (b.y > y) { + xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)); + } + } + } + xs.sort_by(f64::total_cmp); + for w in xs.chunks(2) { + if let [a, b] = w { + let p = Vec2::new((a + b) * 0.5, y); + if self.contains(p) { + return p; + } + } + } + } + c + } + pub fn contains(&self, p: Vec2) -> bool { + self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p)) + } + pub fn centroid(&self) -> Vec2 { + let ao = self.outer.signed_area().abs(); + let mut c = self.outer.centroid() * ao; + let mut a = ao; + for h in &self.holes { + let ah = h.signed_area().abs(); + c -= h.centroid() * ah; + a -= ah; + } + if a.abs() < 1e-15 { self.outer.centroid() } else { c / a } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn areas() { + let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]); + assert!((sq.signed_area() - 12.0).abs() < 1e-12); + assert!((sq.reversed().signed_area() + 12.0).abs() < 1e-12); + let c = Loop2::circle(Vec2::new(5.0, 5.0), 2.0); + assert!((c.signed_area() - std::f64::consts::PI * 4.0).abs() < 1e-9); + assert!(c.is_closed(1e-9)); + let r = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![Loop2::circle(Vec2::ZERO, 5.0).reversed()] }; + assert!((r.area() - std::f64::consts::PI * 75.0).abs() < 1e-9); + let ip = r.interior_point(); + assert!(r.contains(ip), "{ip:?}"); + assert!(sq.contains(Vec2::new(1.0, 1.0)) && !sq.contains(Vec2::new(5.0, 1.0))); + assert!(sq.centroid().dist(Vec2::new(2.0, 1.5)) < 1e-9); + } +} diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs new file mode 100644 index 00000000..8fcf4c75 --- /dev/null +++ b/crates/geom/src/tests.rs @@ -0,0 +1,64 @@ +use crate::*; + +fn cube_mesh(s: f64) -> Mesh { + let p = |x: f64, y: f64, z: f64| Vec3::new(x * s, y * s, z * s); + let positions = vec![p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.), p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.)]; + let triangles = + vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; + let n = triangles.len(); + Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![] } +} + +#[test] +fn cube_measure() { + let m = cube_mesh(2.0).measure(); + assert!((m.volume - 8.0).abs() < 1e-12); + assert!((m.area - 24.0).abs() < 1e-12); + assert!(m.centroid.dist(Vec3::new(1.0, 1.0, 1.0)) < 1e-12); +} + +#[test] +fn raycast_hits_front_face() { + let m = cube_mesh(2.0); + let (t, _) = m.raycast(Vec3::new(1.0, 1.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).unwrap(); + assert!((t - 8.0).abs() < 1e-9); + assert!(m.raycast(Vec3::new(5.0, 5.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).is_none()); +} + +#[test] +fn planes() { + assert_eq!(Plane::XY.normal(), Vec3::Z); + assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0)); + assert_eq!(Plane::YZ.normal(), Vec3::X); + let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0)); + assert_eq!(p, Vec3::new(3.0, 0.0, 4.0)); + assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0)); + let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap(); + assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12); + assert!(Plane::new(Vec3::ZERO, Vec3::X, Vec3::X).is_none()); + let f = Plane::from_normal(Vec3::ZERO, Vec3::new(0.0, 0.0, 3.0)).unwrap(); + assert!((f.normal() - Vec3::Z).len() < 1e-12); + assert!(Plane::from_normal(Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)).is_none()); +} + +#[test] +fn vec_ops_and_serde() { + let a = Vec3::new(1.0, 2.0, 3.0); + assert_eq!(a.cross(Vec3::X), Vec3::new(0.0, 3.0, -2.0)); + assert_eq!(Vec2::new(3.0, 4.0).len(), 5.0); + assert!(Vec2::ZERO.normalized().is_none()); + assert!(Vec3::new(0.5, 0.0, 0.0).dist_to_segment(Vec3::ZERO, Vec3::X) < 1e-12); + assert_eq!(serde_json::to_string(&a).unwrap(), "[1.0,2.0,3.0]"); + let b: Vec2 = serde_json::from_str("[1,2]").unwrap(); + assert_eq!(b, Vec2::new(1.0, 2.0)); +} + +#[test] +fn aabb() { + let mut b = Aabb3::EMPTY; + assert!(b.is_empty()); + b.add(Vec3::new(1.0, 2.0, 3.0)); + b.add(Vec3::new(f64::NAN, 0.0, 0.0)); + b.add(Vec3::new(-1.0, 0.0, 0.0)); + assert_eq!(b.size(), Vec3::new(2.0, 2.0, 3.0)); +} diff --git a/crates/geom/src/vec.rs b/crates/geom/src/vec.rs new file mode 100644 index 00000000..d18127b5 --- /dev/null +++ b/crates/geom/src/vec.rs @@ -0,0 +1,159 @@ +use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}; + +use serde::{Deserialize, Serialize}; + +/// 2D vector / point (sketch space). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(from = "[f64; 2]", into = "[f64; 2]")] +pub struct Vec2 { + pub x: f64, + pub y: f64, +} + +/// 3D vector / point (model space, Z up). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(from = "[f64; 3]", into = "[f64; 3]")] +pub struct Vec3 { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl From<[f64; 2]> for Vec2 { + fn from(a: [f64; 2]) -> Self { + Vec2::new(a[0], a[1]) + } +} +impl From for [f64; 2] { + fn from(v: Vec2) -> Self { + [v.x, v.y] + } +} +impl From<[f64; 3]> for Vec3 { + fn from(a: [f64; 3]) -> Self { + Vec3::new(a[0], a[1], a[2]) + } +} +impl From for [f64; 3] { + fn from(v: Vec3) -> Self { + [v.x, v.y, v.z] + } +} + +impl Vec2 { + pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 }; + pub const X: Vec2 = Vec2 { x: 1.0, y: 0.0 }; + pub const Y: Vec2 = Vec2 { x: 0.0, y: 1.0 }; + pub const fn new(x: f64, y: f64) -> Self { + Vec2 { x, y } + } + pub fn dot(self, o: Vec2) -> f64 { + self.x * o.x + self.y * o.y + } + /// z component of the 3D cross product. + pub fn cross(self, o: Vec2) -> f64 { + self.x * o.y - self.y * o.x + } + pub fn len(self) -> f64 { + self.x.hypot(self.y) + } + pub fn len2(self) -> f64 { + self.dot(self) + } + pub fn dist(self, o: Vec2) -> f64 { + (self - o).len() + } + pub fn normalized(self) -> Option { + let l = self.len(); + (l > crate::EPS && l.is_finite()).then(|| self / l) + } + /// Rotated +90°. + pub fn perp(self) -> Vec2 { + Vec2::new(-self.y, self.x) + } + pub fn angle(self) -> f64 { + self.y.atan2(self.x) + } + pub fn from_angle(a: f64) -> Vec2 { + Vec2::new(a.cos(), a.sin()) + } + pub fn lerp(self, o: Vec2, t: f64) -> Vec2 { + self + (o - self) * t + } + pub fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() + } + pub fn extend(self, z: f64) -> Vec3 { + Vec3::new(self.x, self.y, z) + } +} + +impl Vec3 { + pub const ZERO: Vec3 = Vec3 { x: 0.0, y: 0.0, z: 0.0 }; + pub const X: Vec3 = Vec3 { x: 1.0, y: 0.0, z: 0.0 }; + pub const Y: Vec3 = Vec3 { x: 0.0, y: 1.0, z: 0.0 }; + pub const Z: Vec3 = Vec3 { x: 0.0, y: 0.0, z: 1.0 }; + pub const fn new(x: f64, y: f64, z: f64) -> Self { + Vec3 { x, y, z } + } + pub fn dot(self, o: Vec3) -> f64 { + self.x * o.x + self.y * o.y + self.z * o.z + } + pub fn cross(self, o: Vec3) -> Vec3 { + Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x) + } + pub fn len(self) -> f64 { + self.len2().sqrt() + } + pub fn len2(self) -> f64 { + self.dot(self) + } + pub fn dist(self, o: Vec3) -> f64 { + (self - o).len() + } + pub fn normalized(self) -> Option { + let l = self.len(); + (l > crate::EPS && l.is_finite()).then(|| self / l) + } + pub fn lerp(self, o: Vec3, t: f64) -> Vec3 { + self + (o - self) * t + } + pub fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.z.is_finite() + } + pub fn min(self, o: Vec3) -> Vec3 { + Vec3::new(self.x.min(o.x), self.y.min(o.y), self.z.min(o.z)) + } + pub fn max(self, o: Vec3) -> Vec3 { + Vec3::new(self.x.max(o.x), self.y.max(o.y), self.z.max(o.z)) + } + /// Any unit vector perpendicular to `self` (which should be non-zero). + pub fn any_perp(self) -> Vec3 { + let a = if self.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }; + self.cross(a).normalized().unwrap_or(Vec3::Z) + } + pub fn to_f32(self) -> [f32; 3] { + [self.x as f32, self.y as f32, self.z as f32] + } + /// Distance from this point to the segment `a`–`b`. + pub fn dist_to_segment(self, a: Vec3, b: Vec3) -> f64 { + let ab = b - a; + let l2 = ab.len2(); + let t = if l2 > 0.0 { ((self - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 }; + self.dist(a + ab * t) + } +} + +macro_rules! ops { + ($t:ident, $($f:ident),+) => { + impl Add for $t { type Output = $t; fn add(self, o: $t) -> $t { $t { $($f: self.$f + o.$f),+ } } } + impl Sub for $t { type Output = $t; fn sub(self, o: $t) -> $t { $t { $($f: self.$f - o.$f),+ } } } + impl Mul for $t { type Output = $t; fn mul(self, k: f64) -> $t { $t { $($f: self.$f * k),+ } } } + impl Div for $t { type Output = $t; fn div(self, k: f64) -> $t { $t { $($f: self.$f / k),+ } } } + impl Neg for $t { type Output = $t; fn neg(self) -> $t { $t { $($f: -self.$f),+ } } } + impl AddAssign for $t { fn add_assign(&mut self, o: $t) { $(self.$f += o.$f;)+ } } + impl SubAssign for $t { fn sub_assign(&mut self, o: $t) { $(self.$f -= o.$f;)+ } } + }; +} +ops!(Vec2, x, y); +ops!(Vec3, x, y, z); diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml new file mode 100644 index 00000000..f8b045bf --- /dev/null +++ b/crates/io/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "solvecraft-io" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/io/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml new file mode 100644 index 00000000..da244fd1 --- /dev/null +++ b/crates/kernel/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "solvecraft-kernel" +description = "SolveCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +solvecraft-geom = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +monstertruck-modeling = { workspace = true } +monstertruck-solid = { workspace = true } +monstertruck-meshing = { workspace = true } +monstertruck-io = { workspace = true } diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs new file mode 100644 index 00000000..835d723b --- /dev/null +++ b/crates/kernel/src/body.rs @@ -0,0 +1,232 @@ +use monstertruck_meshing::prelude::*; +use monstertruck_modeling as mt; +use serde::Serialize; +use solvecraft_geom::{Mesh, Vec3}; + +use crate::{KernelError, Result, guard}; + +pub(crate) type Solid = mt::Solid; + +/// A solid body (one or more closed shells). Cheap to clone; never mutated in place. +#[derive(Clone, Debug)] +pub struct Body { + pub(crate) solid: std::sync::Arc, +} + +/// A B-rep edge as seen by the rest of the application. +#[derive(Clone, Debug, Serialize)] +pub struct EdgeInfo { + pub index: usize, + /// Polyline approximation. + pub points: Vec, + /// Point halfway along the polyline (used to re-find the edge). + pub mid: Vec3, + pub length: f64, +} + +/// A B-rep face. +#[derive(Clone, Debug, Serialize)] +pub struct FaceInfo { + pub index: usize, + pub area: f64, + pub centroid: Vec3, + /// Unit normal if the face is planar. + pub plane_normal: Option, +} + +pub(crate) fn p3(v: Vec3) -> mt::Point3 { + mt::Point3::new(v.x, v.y, v.z) +} +pub(crate) fn v3(v: Vec3) -> mt::Vector3 { + mt::Vector3::new(v.x, v.y, v.z) +} +pub(crate) fn from_p3(p: mt::Point3) -> Vec3 { + Vec3::new(p.x, p.y, p.z) +} + +fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) { + let len: f64 = pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum(); + let mut acc = 0.0; + for w in pts.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + let l = a.dist(*b); + if acc + l >= len * 0.5 && l > 0.0 { + return (a.lerp(*b, (len * 0.5 - acc) / l), len); + } + acc += l; + } + } + (pts.first().copied().unwrap_or_default(), len) +} + +impl Body { + pub(crate) fn new(mut solid: Solid) -> Result { + if solid.is_empty() { + return Err(KernelError::Failed("empty result".into())); + } + // Orient outward: a closed solid must have positive volume. + let v = guard("orient", || Ok(solid.triangulation(1.0).to_polygon().volume()))?; + if v < 0.0 { + solid.not(); + } + Ok(Body { solid: std::sync::Arc::new(solid) }) + } + + /// A deep copy of the solid that can be mutated without affecting other bodies. + pub(crate) fn deep_copy(&self) -> Solid { + mt::builder::clone(&*self.solid) + } + + /// Characteristic size (bounding box diagonal) for tolerances. + pub fn size(&self) -> f64 { + let mut b = solvecraft_geom::Aabb3::EMPTY; + for v in self.solid.vertex_iter() { + b.add(from_p3(v.point())); + } + let d = b.diagonal(); + if d.is_finite() && d > 0.0 { d } else { 1.0 } + } + + /// Distinct edges in a stable order (the order of `edges()` and `tessellate().edges`). + pub(crate) fn unique_edges(solid: &Solid) -> Vec { + let mut seen = std::collections::HashSet::new(); + solid.edge_iter().filter(|e| seen.insert(e.id())).collect() + } + + /// Triangulate with chord tolerance `tol` (mm). Triangles carry their face index. + pub fn tessellate(&self, tol: f64) -> Result { + let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 }; + let tol = tol.max(self.size() * 1e-6); + guard("tessellate", || { + let meshed = self.solid.triangulation(tol); + let mut out = Mesh::default(); + for (fi, face) in meshed.face_iter().enumerate() { + let Some(pm) = face.surface() else { continue }; + let flip = !face.orientation(); + let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX); + let pos = pm.positions(); + let nor = pm.normals(); + // Expand to per-corner vertices so normals stay per face (crisp edges). + for tri in pm.faces().triangle_iter() { + let mut idx = [0u32; 3]; + for (k, v) in tri.iter().enumerate() { + let Some(p) = pos.get(v.pos) else { continue }; + let n = v.nor.and_then(|ni| nor.get(ni)).map(|n| Vec3::new(n.x, n.y, n.z)).unwrap_or(Vec3::Z); + let n = if flip { -n } else { n }; + out.positions.push(Vec3::new(p.x, p.y, p.z)); + out.normals.push(n); + if let Some(slot) = idx.get_mut(k) { + *slot = u32::try_from(out.positions.len() - 1).unwrap_or(0); + } + } + let t = if flip { [idx[0], idx[2], idx[1]] } else { idx }; + out.triangles.push(t); + out.tri_face.push(u32::try_from(fi).unwrap_or(u32::MAX)); + } + let _ = base; + } + // Fill missing normals from geometry. + for t in &out.triangles { + let (Some(a), Some(b), Some(c)) = + (out.positions.get(t[0] as usize), out.positions.get(t[1] as usize), out.positions.get(t[2] as usize)) + else { + continue; + }; + let fnrm = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z); + for k in t { + if let Some(n) = out.normals.get_mut(*k as usize) + && (n.dot(fnrm) < -0.2 || n.len() < 0.5) + { + *n = fnrm; + } + } + } + let mut seen = std::collections::HashSet::new(); + for e in meshed.edge_iter() { + if !seen.insert(e.id()) { + continue; + } + let c = e.oriented_curve(); + out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect()); + } + Ok(out) + }) + } + + /// Edges with polylines (index = position in [`Body::tessellate`]'s `edges`). + pub fn edges(&self, tol: f64) -> Result> { + let m = self.tessellate(tol)?; + Ok(m.edges + .into_iter() + .enumerate() + .map(|(index, points)| { + let (mid, length) = polyline_mid(&points); + EdgeInfo { index, points, mid, length } + }) + .collect()) + } + + /// Faces with area and centroid. + pub fn faces(&self, tol: f64) -> Result> { + let m = self.tessellate(tol)?; + let nf = self.solid.face_iter().count(); + let mut acc: Vec<(f64, Vec3, Vec3, bool)> = vec![(0.0, Vec3::ZERO, Vec3::ZERO, true); nf]; + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some([a, b, c]) = m.tri(t) else { continue }; + let cr = (b - a).cross(c - a); + let ar = cr.len() * 0.5; + if let Some(slot) = acc.get_mut(*f as usize) { + slot.0 += ar; + slot.1 += (a + b + c) * (ar / 3.0); + if let Some(n) = cr.normalized() { + if slot.2 == Vec3::ZERO { + slot.2 = n; + } else if slot.2.dot(n) < 1.0 - 1e-6 { + slot.3 = false; + } + } + } + } + Ok(acc + .into_iter() + .enumerate() + .map(|(index, (area, c, n, planar))| FaceInfo { + index, + area, + centroid: if area > 0.0 { c / area } else { Vec3::ZERO }, + plane_normal: (planar && n != Vec3::ZERO).then_some(n), + }) + .collect()) + } + + /// Index of the edge closest to `p` (by polyline distance). + pub fn nearest_edge(&self, p: Vec3, tol: f64) -> Result> { + let edges = self.edges(tol)?; + let mut best: Option<(usize, f64)> = None; + for e in &edges { + for w in e.points.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + let d = p.dist_to_segment(*a, *b); + if best.is_none_or(|(_, bd)| d < bd) { + best = Some((e.index, d)); + } + } + } + } + Ok(best) + } + + pub fn face_count(&self) -> usize { + self.solid.face_iter().count() + } + pub fn edge_count(&self) -> usize { + Self::unique_edges(&self.solid).len() + } + pub fn vertex_count(&self) -> usize { + let mut seen = std::collections::HashSet::new(); + self.solid.vertex_iter().filter(|v| seen.insert(v.id())).count() + } + pub fn shell_count(&self) -> usize { + self.solid.boundaries().len() + } +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs new file mode 100644 index 00000000..d31acc08 --- /dev/null +++ b/crates/kernel/src/build.rs @@ -0,0 +1,202 @@ +//! Building bodies: extrude and revolve planar regions, primitives. + +use monstertruck_modeling as mt; +use mt::builder; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +use crate::body::{Body, Solid, p3, v3}; +use crate::{KernelError, Result, guard}; + +const MAX_SEGS: usize = 20_000; + +fn finite(x: f64, what: &str) -> Result { + if x.is_finite() && x.abs() < 1e7 { Ok(x) } else { Err(KernelError::Invalid(format!("{what} = {x}"))) } +} + +/// A closed wire in 3D from a sketch loop. +fn wire(plane: &Plane, lp: &Loop2) -> Result { + let n = lp.segs.len(); + if n == 0 || n > MAX_SEGS { + return Err(KernelError::Invalid(format!("loop with {n} segments"))); + } + if !lp.is_closed(1e-6) { + return Err(KernelError::Invalid("profile loop is not closed".into())); + } + let verts: Vec = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); + let mut edges = Vec::with_capacity(n); + for (i, s) in lp.segs.iter().enumerate() { + let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue }; + let e: mt::Edge = match *s { + Seg2::Line { .. } => builder::line(a, b), + Seg2::Arc { sweep, .. } => { + if sweep.abs() < 1e-9 { + return Err(KernelError::Invalid("zero-length arc".into())); + } + // Split big arcs so every piece is well conditioned. + let mid = plane.to_world(s.mid()); + if sweep.abs() > std::f64::consts::PI * 1.5 { + let vm = builder::vertex(p3(mid)); + let q1 = plane.to_world(s.point_at(0.25)); + let q3 = plane.to_world(s.point_at(0.75)); + edges.push( + builder::try_circle_arc(a, &vm, mt::builder::CircularArcConstraint::ThroughPoint(p3(q1))) + .map_err(|e| KernelError::Invalid(e.to_string()))?, + ); + builder::try_circle_arc(&vm, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(q3))) + .map_err(|e| KernelError::Invalid(e.to_string()))? + } else { + builder::try_circle_arc(a, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(mid))) + .map_err(|e| KernelError::Invalid(e.to_string()))? + } + } + }; + edges.push(e); + } + Ok(edges.into()) +} + +/// A planar face from a region (outer loop with holes). +pub(crate) fn face(plane: &Plane, r: &Region2) -> Result { + let mut wires = vec![wire(plane, &r.outer.ccw())?]; + for h in &r.holes { + wires.push(wire(plane, &h.ccw().reversed())?); + } + guard("face", || mt::profile::attach_plane_normalized(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}")))) +} + +fn region_ok(r: &Region2) -> Result<()> { + if r.area().abs() < 1e-9 { + return Err(KernelError::Invalid("profile has no area".into())); + } + for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) { + let ok = match *s { + Seg2::Line { a, b } => a.is_finite() && b.is_finite(), + Seg2::Arc { center, radius, start, sweep } => { + center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite() + } + }; + if !ok { + return Err(KernelError::Invalid("non-finite profile".into())); + } + } + Ok(()) +} + +/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`). +pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result> { + let (start, end) = (finite(start, "start")?, finite(end, "end")?); + let (lo, hi) = if end >= start { (start, end) } else { (end, start) }; + if hi - lo < 1e-6 { + return Err(KernelError::Invalid("extrude distance is zero".into())); + } + if regions.is_empty() { + return Err(KernelError::Invalid("no profile selected".into())); + } + let n = plane.normal(); + let mut out = Vec::new(); + for r in regions { + region_ok(r)?; + let base = plane.offset(lo); + let f = face(&base, r)?; + let solid: Solid = guard("extrude", || Ok(builder::extrude(&f, v3(n * (hi - lo)))))?; + out.push(Body::new(solid)?); + } + Ok(out) +} + +/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction +/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution). +pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result> { + let angle = finite(angle, "angle")?; + if angle.abs() < 1e-6 { + return Err(KernelError::Invalid("revolve angle is zero".into())); + } + let d = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?; + if regions.is_empty() { + return Err(KernelError::Invalid("no profile selected".into())); + } + // The profile must lie on one side of the axis. + let o = plane.to_world(axis_origin); + let axis = plane.dir_to_world(d); + let mut out = Vec::new(); + for r in regions { + region_ok(r)?; + let (mut neg, mut pos) = (false, false); + for p in r.outer.polyline(1e-3) { + let s = d.cross(p - axis_origin); + neg |= s < -1e-7; + pos |= s > 1e-7; + } + if neg && pos { + return Err(KernelError::Invalid("the profile crosses the revolve axis".into())); + } + let f = face(plane, r)?; + let full = angle.abs() >= std::f64::consts::TAU - 1e-9; + let solid: Solid = guard("revolve", || { + Ok(if full { + builder::revolve(&f, p3(o), v3(axis), builder::SweepAngle::Closed, 4) + } else { + let div = ((angle.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8); + builder::revolve(&f, p3(o), v3(axis), builder::SweepAngle::Partial(mt::Rad(angle)), div) + }) + })?; + out.push(Body::new(solid)?); + } + Ok(out) +} + +/// Axis-aligned box between two corners. +pub fn box_solid(a: Vec3, b: Vec3) -> Result { + let (lo, hi) = (a.min(b), a.max(b)); + let s = hi - lo; + if !(s.x > 1e-6 && s.y > 1e-6 && s.z > 1e-6 && s.is_finite() && s.len() < 1e7) { + return Err(KernelError::Invalid("box needs a positive size in x, y and z".into())); + } + let r = Region2 { + outer: Loop2::polygon(&[Vec2::new(lo.x, lo.y), Vec2::new(hi.x, lo.y), Vec2::new(hi.x, hi.y), Vec2::new(lo.x, hi.y)]), + holes: vec![], + }; + let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?; + v.pop().ok_or_else(|| KernelError::Failed("box".into())) +} + +/// Cylinder with base centre `base`, axis direction `axis`, radius and height. +pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result { + let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?; + let radius = finite(radius, "radius")?; + if radius <= 1e-6 { + return Err(KernelError::Invalid("radius must be positive".into())); + } + let r = Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] }; + let mut v = extrude(&plane, &[r], 0.0, finite(height, "height")?)?; + v.pop().ok_or_else(|| KernelError::Failed("cylinder".into())) +} + +/// Sphere: revolve a half disc. +pub fn sphere(center: Vec3, radius: f64) -> Result { + let radius = finite(radius, "radius")?; + if radius <= 1e-6 { + return Err(KernelError::Invalid("radius must be positive".into())); + } + let plane = Plane { origin: center, ..Plane::XZ }; + let half = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::ZERO, radius, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: Vec2::new(0.0, radius), b: Vec2::new(0.0, -radius) }, + ], + }; + let mut v = revolve(&plane, &[Region2 { outer: half, holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?; + v.pop().ok_or_else(|| KernelError::Failed("sphere".into())) +} + +/// Torus about the Z axis through `center`. +pub fn torus(center: Vec3, major: f64, minor: f64) -> Result { + let (major, minor) = (finite(major, "major radius")?, finite(minor, "minor radius")?); + if !(minor > 1e-6 && major > minor) { + return Err(KernelError::Invalid("torus needs major > minor > 0".into())); + } + let plane = Plane { origin: center, ..Plane::XZ }; + let r = Region2 { outer: Loop2::circle(Vec2::new(major, 0.0), minor), holes: vec![] }; + let mut v = revolve(&plane, &[r], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?; + v.pop().ok_or_else(|| KernelError::Failed("torus".into())) +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs new file mode 100644 index 00000000..05be595e --- /dev/null +++ b/crates/kernel/src/lib.rs @@ -0,0 +1,55 @@ +//! The B-rep kernel boundary. +//! +//! Everything SolveCraft does with solids goes through this crate: building bodies from planar +//! regions (extrude, revolve) and primitives, booleans, fillets and chamfers, tessellation, +//! measurement and STEP export. The implementation uses the `monstertruck` crates (Apache-2.0, +//! a maintained fork of `truck`; see `plan/adr/0001-geometry-kernel.md`), but no monstertruck +//! type appears in the public API, so the kernel can be replaced without touching the rest of +//! the workspace. +//! +//! Every call into the third-party kernel runs under [`guard`]: a panic inside it becomes a +//! [`KernelError::Internal`] instead of taking the application down. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod body; +mod build; +mod measure; +mod ops; +mod step; +mod topo; + +pub use body::{Body, EdgeInfo, FaceInfo}; +pub use build::{box_solid, cylinder, extrude, revolve, sphere, torus}; +pub use measure::{BodyMeasure, measure}; +pub use ops::{BoolOp, boolean, chamfer, fillet, transform}; +pub use step::step_export; +pub use topo::{TopoCounts, merged_topology}; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum KernelError { + #[error("invalid input: {0}")] + Invalid(String), + #[error("the operation failed: {0}")] + Failed(String), + /// The third-party kernel panicked; the model is unchanged. + #[error("kernel internal error: {0}")] + Internal(String), +} + +pub type Result = std::result::Result; + +/// Run third-party kernel code, converting a panic into an error. +pub fn guard(what: &str, f: impl FnOnce() -> Result) -> Result { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + Ok(r) => r, + Err(p) => { + let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + log::warn!("kernel panic in {what}: {msg}"); + Err(KernelError::Internal(format!("{what}: {msg}"))) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/kernel/src/measure.rs b/crates/kernel/src/measure.rs new file mode 100644 index 00000000..d7359791 --- /dev/null +++ b/crates/kernel/src/measure.rs @@ -0,0 +1,42 @@ +use serde::Serialize; +use solvecraft_geom::{Aabb3, Vec3}; + +use crate::Result; +use crate::body::Body; +use crate::topo::{TopoCounts, merged_topology}; + +/// Measured properties of a body. +#[derive(Clone, Debug, Serialize)] +pub struct BodyMeasure { + pub volume: f64, + pub area: f64, + pub centroid: Vec3, + pub bbox: Aabb3, + /// Raw kernel topology counts (closed curves and surfaces may be split in pieces). + pub faces: usize, + pub edges: usize, + pub vertices: usize, + pub shells: usize, + /// Counts after merging split pieces of the same analytic surface/curve (comparable with + /// systems that use periodic faces and edges). + pub merged: TopoCounts, +} + +/// Measure a body from a fine tessellation (chord tolerance 5e-5 of the body size). Curved +/// faces are slightly under-estimated (relative error typically below 2e-4). +pub fn measure(b: &Body) -> Result { + let size = b.size(); + let m2 = b.tessellate((size * 5e-5).max(1e-4))?; + let a2 = m2.measure(); + Ok(BodyMeasure { + volume: a2.volume, + area: a2.area, + centroid: a2.centroid, + bbox: m2.bounds(), + faces: b.face_count(), + edges: b.edge_count(), + vertices: b.vertex_count(), + shells: b.shell_count(), + merged: merged_topology(b, &m2)?, + }) +} diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs new file mode 100644 index 00000000..fa946bdf --- /dev/null +++ b/crates/kernel/src/ops.rs @@ -0,0 +1,112 @@ +//! Operations on bodies: booleans, fillets, chamfers, rigid transforms. + +use monstertruck_modeling as mt; +use serde::{Deserialize, Serialize}; +use solvecraft_geom::Vec3; + +use crate::body::{Body, Solid, v3}; +use crate::{KernelError, Result, guard}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BoolOp { + Union, + Cut, + Intersect, +} + +/// Boolean of two bodies. Retries with a few tolerances; the result may be empty (`Ok(None)`) +/// for a cut that removes everything or an intersection of disjoint bodies. +pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { + let size = a.size().max(b.size()); + let mut last = String::new(); + for k in [1e-4, 1e-3, 5e-5, 5e-3] { + let tol = (size * k).max(1e-6); + let r = guard("boolean", || { + let (sa, sb) = (a.deep_copy(), b.deep_copy()); + let res = match op { + BoolOp::Union => monstertruck_solid::or(&sa, &sb, tol), + BoolOp::Cut => monstertruck_solid::difference(&sa, &sb, tol), + BoolOp::Intersect => monstertruck_solid::and(&sa, &sb, tol), + }; + res.map_err(|e| KernelError::Failed(format!("{e:?}"))) + }); + match r { + Ok(s) if s.is_empty() => return Ok(None), + Ok(s) => { + let body = Body::new(s)?; + if body.tessellate(size * 1e-2).map(|m| m.measure().volume > 0.0).unwrap_or(false) { + return Ok(Some(body)); + } + last = "degenerate result".into(); + } + Err(e) => last = e.to_string(), + } + } + Err(KernelError::Failed(format!("boolean {op:?}: {last}"))) +} + +fn blend(body: &Body, edges: &[Vec3], radius: f64, profile: mt::FilletProfile, what: &str) -> Result { + if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) { + return Err(KernelError::Invalid(format!("{what} size must be positive"))); + } + if edges.is_empty() { + return Err(KernelError::Invalid("no edges selected".into())); + } + let tol = (body.size() * 2e-3).max(1e-3); + let mut cur = body.clone(); + // One edge at a time: re-find each edge on the current body by its reference point. + for p in edges { + let Some((idx, d)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("body has no edges".into())) }; + if d > cur.size() * 0.05 + 1e-3 { + return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z]))); + } + let mut solid = cur.deep_copy(); + let shape = profile.clone(); + let res = guard(what, || { + let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?; + let shell_i = solid + .boundaries() + .iter() + .position(|sh| sh.edge_iter().any(|e| e.id() == edge.id())) + .ok_or_else(|| KernelError::Failed("edge not in a shell".into()))?; + let mut shells = solid.boundaries().clone(); + let shell = shells.get_mut(shell_i).ok_or_else(|| KernelError::Failed("shell".into()))?; + let opts = mt::FilletOptions { radius: mt::RadiusSpec::Constant(radius), profile: shape, ..Default::default() }; + mt::fillet_edges(shell, &[edge], Some(&opts)).map_err(|e| KernelError::Failed(format!("{what}: {e:?}")))?; + solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("{what} left an open shell: {e:?}")))?; + Ok(()) + }); + res?; + cur = Body::new(solid)?; + } + Ok(cur) +} + +/// Constant-radius fillet of the edges nearest to the given points. +pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { + blend(body, edges, radius, mt::FilletProfile::Round, "fillet") +} + +/// Equal-distance chamfer of the edges nearest to the given points. +pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { + blend(body, edges, distance, mt::FilletProfile::Chamfer, "chamfer") +} + +/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate. +pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result { + if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) { + return Err(KernelError::Invalid("non-finite transform".into())); + } + guard("transform", || { + let mut s = body.deep_copy(); + if angle.abs() > 1e-12 { + let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))?; + s = mt::builder::rotated(&s, crate::body::p3(origin), v3(ax), mt::Rad(angle)); + } + if translate.len() > 0.0 { + s = mt::builder::translated(&s, v3(translate)); + } + Body::new(s) + }) +} diff --git a/crates/kernel/src/step.rs b/crates/kernel/src/step.rs new file mode 100644 index 00000000..c35cb337 --- /dev/null +++ b/crates/kernel/src/step.rs @@ -0,0 +1,18 @@ +use monstertruck_io::step::save; + +use crate::body::Body; +use crate::{KernelError, Result, guard}; + +/// STEP (ISO 10303-21, AP203-style geometry) text for the given bodies. +pub fn step_export(bodies: &[&Body], system: &str) -> Result { + if bodies.is_empty() { + return Err(KernelError::Invalid("nothing to export".into())); + } + guard("step export", || { + let cs: Vec<_> = bodies.iter().map(|b| b.solid.compress()).collect(); + let models = save::StepModels::from_iter(cs.iter()); + let header = save::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() }; + let out = save::CompleteStepDisplay::new(models, header).to_string(); + Ok(out) + }) +} diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs new file mode 100644 index 00000000..31429937 --- /dev/null +++ b/crates/kernel/src/tests.rs @@ -0,0 +1,137 @@ +use std::f64::consts::PI; + +use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3}; + +use crate::*; + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +fn rect(w: f64, h: f64) -> Region2 { + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]), holes: vec![] } +} + +#[test] +fn box_extrude_measures() { + let b = extrude(&Plane::XY, &[rect(40.0, 30.0)], 0.0, 20.0).unwrap().pop().unwrap(); + let m = measure(&b).unwrap(); + assert!(rel(m.volume, 24000.0) < 1e-9, "{}", m.volume); + assert!(rel(m.area, 5200.0) < 1e-9); + assert_eq!((m.faces, m.edges, m.vertices), (6, 12, 8)); + assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (6, 12, 8)); + assert!(m.centroid.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-6); +} + +#[test] +fn cylinder_measures_match_analytic() { + let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap(); + let m = measure(&b).unwrap(); + let v = PI * 225.0 * 50.0; + let a = 2.0 * PI * 225.0 + 2.0 * PI * 15.0 * 50.0; + assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume); + assert!(rel(m.area, a) < 5e-4, "{} vs {a}", m.area); + assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (3, 2, 2), "{:?}", m.merged); + assert_eq!(m.merged.face_types.get("cylinder"), Some(&1)); +} + +#[test] +fn extrude_on_other_planes_and_offsets() { + let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap(); + let m = measure(&b).unwrap(); + assert!(rel(m.volume, 250.0) < 1e-9); + // XZ normal is -Y: the body spans y in [-3, 2]. + assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox); +} + +#[test] +fn region_with_hole() { + let r = Region2 { outer: rect(40.0, 30.0).outer, holes: vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.0).reversed()] }; + let b = extrude(&Plane::XY, &[r], 0.0, 10.0).unwrap().pop().unwrap(); + let m = measure(&b).unwrap(); + assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume); +} + +#[test] +fn box_fillet_cut_volume() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0)], 3.0).unwrap(); + let mf = measure(&f).unwrap(); + let expect_f = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0; + assert!(rel(mf.volume, expect_f) < 1e-4, "{} vs {expect_f}", mf.volume); + assert_eq!(mf.merged.faces, 7); + let hole = cylinder(Vec3::new(20.0, 15.0, -5.0), Vec3::Z, 5.0, 30.0).unwrap(); + let cut = boolean(&f, &hole, BoolOp::Cut).unwrap().unwrap(); + let mc = measure(&cut).unwrap(); + let expect = expect_f - PI * 25.0 * 20.0; + assert!(rel(mc.volume, expect) < 1e-3, "{} vs {expect}", mc.volume); +} + +#[test] +fn union_and_intersect() { + let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let b = box_solid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0)).unwrap(); + let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap(); + assert!(rel(measure(&u).unwrap().volume, 2000.0 - 125.0) < 1e-6); + let i = boolean(&a, &b, BoolOp::Intersect).unwrap().unwrap(); + assert!(rel(measure(&i).unwrap().volume, 125.0) < 1e-6); +} + +#[test] +fn revolve_washer_and_sphere() { + let r = + Region2 { outer: Loop2::polygon(&[Vec2::new(10.0, 0.0), Vec2::new(15.0, 0.0), Vec2::new(15.0, 5.0), Vec2::new(10.0, 5.0)]), holes: vec![] }; + let b = revolve(&Plane::XZ, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, 2.0 * PI).unwrap().pop().unwrap(); + let m = measure(&b).unwrap(); + let v = PI * (225.0 - 100.0) * 5.0; + assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume); + let half = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, PI).unwrap().pop().unwrap(); + assert!(rel(measure(&half).unwrap().volume, v / 2.0) < 5e-4); + let s = sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap(); + let ms = measure(&s).unwrap(); + assert!(rel(ms.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{}", ms.volume); +} + +#[test] +fn chamfer_and_step() { + let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap(); + let c = chamfer(&b, &[Vec3::new(20.0, 10.0, 20.0)], 2.0).unwrap(); + assert!(rel(measure(&c).unwrap().volume, 8000.0 - 2.0 * 20.0) < 1e-4); + let s = step_export(&[&c], "SolveCraft").unwrap(); + assert!(s.starts_with("ISO-10303-21;")); + assert!(s.contains("MANIFOLD_SOLID_BREP") || s.contains("CLOSED_SHELL")); +} + +#[test] +fn hostile_inputs_are_errors() { + assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, 0.0).is_err()); + assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, f64::NAN).is_err()); + assert!(extrude(&Plane::XY, &[], 0.0, 1.0).is_err()); + assert!(extrude(&Plane::XY, &[rect(0.0, 1.0)], 0.0, 1.0).is_err()); + assert!(box_solid(Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0)).is_err()); + assert!(cylinder(Vec3::ZERO, Vec3::ZERO, 1.0, 1.0).is_err()); + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], -1.0).is_err()); + assert!(fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_err()); + assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], 100.0).is_err()); + let r = Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]), holes: vec![] }; + assert!(revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, 1.0).is_err(), "crosses axis"); + assert!(transform(&b, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).is_err()); +} + +#[test] +#[ignore] +fn debug_convergence() { + let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap(); + for t in [0.1, 0.05, 0.02, 0.01, 0.005, 0.002] { + let m = b.tessellate(t).unwrap(); + let mm = m.measure(); + println!("tol {t}: tris {} vol {} err {:.3e}", m.triangles.len(), mm.volume, mm.volume / (PI * 225.0 * 50.0) - 1.0); + } + let m = b.tessellate(0.05).unwrap(); + for f in 0..4u32 { + let ns: Vec = + m.triangles.iter().zip(&m.tri_face).filter(|(_, ff)| **ff == f).flat_map(|(t, _)| t.iter().map(|k| m.normals[*k as usize])).collect(); + println!("face {f}: {} normals, first {:?}, max |n.z| {}", ns.len(), ns.first(), ns.iter().map(|n| n.z.abs()).fold(0.0, f64::max)); + } +} diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs new file mode 100644 index 00000000..b0b982c2 --- /dev/null +++ b/crates/kernel/src/topo.rs @@ -0,0 +1,263 @@ +//! Topology counts comparable with kernels that use periodic faces and closed edges. +//! +//! Our kernel splits a full cylinder into two half faces and a circle into two arcs. Faces +//! are classified from their tessellation (plane, cylinder, sphere, other); adjacent faces on +//! the same analytic surface are merged, edges between merged faces disappear, and chains of +//! edges through vertices that only join two pieces of the same edge are counted once. + +use std::collections::{BTreeMap, HashMap}; + +use serde::Serialize; +use solvecraft_geom::{Mesh, Vec3}; + +use crate::Result; +use crate::body::{Body, from_p3}; + +#[derive(Clone, Debug, Default, Serialize, PartialEq)] +pub struct TopoCounts { + pub faces: usize, + pub edges: usize, + pub vertices: usize, + pub face_types: BTreeMap, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Surf { + Plane { n: Vec3, d: f64 }, + Cylinder { axis: Vec3, p: Vec3, r: f64 }, + Sphere { c: Vec3, r: f64 }, + Other(usize), +} + +impl Surf { + fn name(&self) -> &'static str { + match self { + Surf::Plane { .. } => "plane", + Surf::Cylinder { .. } => "cylinder", + Surf::Sphere { .. } => "sphere", + Surf::Other(_) => "other", + } + } + fn same(&self, o: &Surf, tol: f64) -> bool { + match (*self, *o) { + (Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-6 && (d - d2).abs() < tol, + (Surf::Cylinder { axis, p, r }, Surf::Cylinder { axis: a2, p: p2, r: r2 }) => { + let off = p2 - p; + axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol + } + (Surf::Sphere { c, r }, Surf::Sphere { c: c2, r: r2 }) => c.dist(c2) < tol && (r - r2).abs() < tol, + _ => false, + } + } +} + +/// Least-squares circle through 2D points (Kåsa fit): centre and radius. +fn fit_circle(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> { + let n = pts.len() as f64; + if pts.len() < 3 { + return None; + } + let (mx, my) = pts.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0 / n, a.1 + p.1 / n)); + let (mut suu, mut suv, mut svv, mut suuu, mut svvv, mut suvv, mut svuu) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for (x, y) in pts { + let (u, v) = (x - mx, y - my); + suu += u * u; + suv += u * v; + svv += v * v; + suuu += u * u * u; + svvv += v * v * v; + suvv += u * v * v; + svuu += v * u * u; + } + let det = suu * svv - suv * suv; + if det.abs() < 1e-18 { + return None; + } + let b1 = 0.5 * (suuu + suvv); + let b2 = 0.5 * (svvv + svuu); + let uc = (b1 * svv - b2 * suv) / det; + let vc = (suu * b2 - suv * b1) / det; + let r = (uc * uc + vc * vc + (suu + svv) / n).sqrt(); + Some((uc + mx, vc + my, r)) +} + +fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf { + let Some(&n0) = nrm.first() else { return Surf::Other(id) }; + let Some(&p0) = pts.first() else { return Surf::Other(id) }; + if nrm.iter().all(|n| n.dot(n0) > 1.0 - 1e-5) { + let n = n0.normalized().unwrap_or(Vec3::Z); + if pts.iter().all(|p| (p.dot(n) - p0.dot(n)).abs() < tol) { + return Surf::Plane { n, d: p0.dot(n) }; + } + } + // Cylinder: all normals perpendicular to one axis. + let far = nrm.iter().copied().min_by(|a, b| a.dot(n0).abs().total_cmp(&b.dot(n0).abs())).unwrap_or(n0); + if let Some(axis) = n0.cross(far).normalized() + && nrm.iter().all(|n| n.dot(axis).abs() < 2e-3) + { + let u = axis.any_perp(); + let v = axis.cross(u); + let pl: Vec<(f64, f64)> = pts.iter().map(|p| (p.dot(u), p.dot(v))).collect(); + if let Some((cu, cv, r)) = fit_circle(&pl) + && pl.iter().all(|(x, y)| ((x - cu).hypot(y - cv) - r).abs() < tol.max(r * 2e-3)) + { + let axis = if axis.x + axis.y * 1e-3 + axis.z * 1e-6 < 0.0 { -axis } else { axis }; + return Surf::Cylinder { axis, p: u * cu + v * cv, r }; + } + } + // Sphere: points equidistant from the point where normals meet. + let c_est = { + let mut acc = Vec3::ZERO; + let mut k = 0.0; + for (p, n) in pts.iter().zip(nrm) { + // Assume radius from the first pair. + let r0 = (p0 - *p).len() / ((n0 - *n).len().max(1e-12)); + if r0.is_finite() && r0 > 0.0 && (n0 - *n).len() > 0.1 { + acc += *p - *n * r0; + k += 1.0; + } + } + (k > 0.0).then(|| acc / k) + }; + if let Some(c) = c_est { + let r = pts.iter().map(|p| p.dist(c)).sum::() / pts.len().max(1) as f64; + if pts.iter().all(|p| (p.dist(c) - r).abs() < tol.max(r * 2e-3)) { + return Surf::Sphere { c, r }; + } + } + Surf::Other(id) +} + +fn find(p: &mut [usize], mut i: usize) -> usize { + while let Some(&q) = p.get(i) { + if q == i { + break; + } + i = q; + } + i +} + +/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`. +pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { + let solid = &*b.solid; + let nf = solid.face_iter().count(); + let tol = (b.size() * 1e-4).max(1e-6); + let mut pts: Vec> = vec![Vec::new(); nf]; + let mut nrm: Vec> = vec![Vec::new(); nf]; + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + for &k in t { + if let (Some(p), Some(n), Some(pv), Some(nv)) = + (mesh.positions.get(k as usize), mesh.normals.get(k as usize), pts.get_mut(*f as usize), nrm.get_mut(*f as usize)) + { + pv.push(*p); + nv.push(*n); + } + } + } + let surfs: Vec = (0..nf) + .map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i)) + .collect(); + + // Edge → faces, edge → vertices. + let mut edge_faces: HashMap> = HashMap::new(); + let mut edge_verts: HashMap = HashMap::new(); + let mut eid: HashMap = HashMap::new(); + let mut vid: HashMap = HashMap::new(); + let mut vpos: Vec = Vec::new(); + for (fi, f) in solid.face_iter().enumerate() { + for e in f.edge_iter() { + let key = format!("{:?}", e.id()); + let n = eid.len(); + let ei = *eid.entry(key).or_insert(n); + let faces = edge_faces.entry(ei).or_default(); + if !faces.contains(&fi) { + faces.push(fi); + } + let mut vix = |v: &monstertruck_modeling::Vertex| { + let k = format!("{:?}", v.id()); + let n = vid.len(); + let i = *vid.entry(k).or_insert(n); + if i == vpos.len() { + vpos.push(from_p3(v.point())); + } + i + }; + let (a, bb) = (vix(e.front()), vix(e.back())); + edge_verts.insert(ei, (a, bb)); + } + } + // Merge faces. + let mut fp: Vec = (0..nf).collect(); + for faces in edge_faces.values() { + if let [a, b] = faces[..] + && let (Some(sa), Some(sb)) = (surfs.get(a), surfs.get(b)) + && sa.same(sb, tol * 10.0) + { + let (ra, rb) = (find(&mut fp, a), find(&mut fp, b)); + if let Some(s) = fp.get_mut(ra) { + *s = rb; + } + } + } + let mut groups: BTreeMap = BTreeMap::new(); + let mut face_types: BTreeMap = BTreeMap::new(); + for f in 0..nf { + let r = find(&mut fp, f); + if groups.insert(r, f).is_none() + && let Some(s) = surfs.get(f) + { + *face_types.entry(s.name().into()).or_insert(0) += 1; + } + } + // Remaining edges, keyed by merged face pair. + let mut remaining: Vec<(usize, (usize, usize), (usize, usize))> = Vec::new(); // (edge, verts, face pair) + let mut eids: Vec<&usize> = edge_faces.keys().collect(); + eids.sort(); + for e in eids { + let faces = edge_faces.get(e).cloned().unwrap_or_default(); + let gs: Vec = faces.iter().map(|f| find(&mut fp, *f)).collect(); + let pair = match gs[..] { + [a, b] if a == b => continue, + [a, b] => (a.min(b), a.max(b)), + [a] => (a, a), + _ => (usize::MAX, usize::MAX), + }; + if let Some(v) = edge_verts.get(e) { + remaining.push((*e, *v, pair)); + } + } + // Vertex degree among remaining edges. + let mut deg: HashMap> = HashMap::new(); + for (i, (_, (a, b), _)) in remaining.iter().enumerate() { + deg.entry(*a).or_default().push(i); + if b != a { + deg.entry(*b).or_default().push(i); + } + } + let mut ep: Vec = (0..remaining.len()).collect(); + let mut merge_vertex: Vec = Vec::new(); + for (v, es) in ° { + if let [i, j] = es[..] + && let (Some(a), Some(b)) = (remaining.get(i), remaining.get(j)) + && a.2 == b.2 + { + let (ri, rj) = (find(&mut ep, i), find(&mut ep, j)); + if let Some(s) = ep.get_mut(ri) { + *s = rj; + } + merge_vertex.push(*v); + } + } + let mut edge_groups: BTreeMap = BTreeMap::new(); // root → has a real end vertex + for (i, (_, (a, b), _)) in remaining.iter().enumerate() { + let r = find(&mut ep, i); + let real = !merge_vertex.contains(a) || !merge_vertex.contains(b); + let e = edge_groups.entry(r).or_insert(false); + *e |= real; + } + let real_vertices = deg.keys().filter(|v| !merge_vertex.contains(v)).count(); + let closed_loops = edge_groups.values().filter(|r| !**r).count(); + let _ = vpos; + Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len(), vertices: real_vertices + closed_loops, face_types }) +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 00000000..41f84311 --- /dev/null +++ b/crates/render/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "solvecraft-render" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/render/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/sketch/Cargo.toml b/crates/sketch/Cargo.toml new file mode 100644 index 00000000..782c883b --- /dev/null +++ b/crates/sketch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "solvecraft-sketch" +description = "SolveCraft sketches: points, curves, geometric constraints and dimensions, the constraint solver, and profile detection" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +solvecraft-geom = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs new file mode 100644 index 00000000..70ac6c5b --- /dev/null +++ b/crates/sketch/src/lib.rs @@ -0,0 +1,21 @@ +//! Sketches: points and curves on a plane, geometric constraints and driving dimensions, a +//! Levenberg–Marquardt constraint solver with degree-of-freedom analysis, and closed-profile +//! (region) detection for features. +//! +//! The solver is our own clean-room implementation (residual functions per constraint, local +//! finite-difference Jacobians, damped Gauss–Newton per connected component, rank analysis by +//! Gaussian elimination). See `plan/architecture.md` §sketch. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod linalg; +mod model; +mod profiles; +mod solver; + +pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError}; +pub use profiles::{Profile, find_profiles}; +pub use solver::{SolveReport, SolveStatus, solve}; + +#[cfg(test)] +mod tests; diff --git a/crates/sketch/src/linalg.rs b/crates/sketch/src/linalg.rs new file mode 100644 index 00000000..3954c9b8 --- /dev/null +++ b/crates/sketch/src/linalg.rs @@ -0,0 +1,182 @@ +//! Small dense linear algebra for the solver: row-major matrices, Gaussian elimination with +//! partial pivoting, and reduced row echelon form for rank and null-space analysis. + +/// Row-major dense matrix. +#[derive(Clone, Debug)] +pub struct Mat { + pub rows: usize, + pub cols: usize, + pub data: Vec, +} + +impl Mat { + pub fn zeros(rows: usize, cols: usize) -> Mat { + Mat { rows, cols, data: vec![0.0; rows.saturating_mul(cols)] } + } + pub fn get(&self, r: usize, c: usize) -> f64 { + self.data.get(r * self.cols + c).copied().unwrap_or(0.0) + } + pub fn set(&mut self, r: usize, c: usize, v: f64) { + if let Some(x) = self.data.get_mut(r * self.cols + c) { + *x = v; + } + } + pub fn add(&mut self, r: usize, c: usize, v: f64) { + if let Some(x) = self.data.get_mut(r * self.cols + c) { + *x += v; + } + } + fn swap_rows(&mut self, a: usize, b: usize) { + if a == b { + return; + } + for c in 0..self.cols { + self.data.swap(a * self.cols + c, b * self.cols + c); + } + } +} + +/// Solve `A x = b` (A square). Returns `None` when singular. +pub fn solve(mut a: Mat, mut b: Vec) -> Option> { + let n = a.rows; + if a.cols != n || b.len() != n { + return None; + } + for k in 0..n { + let mut piv = k; + let mut best = a.get(k, k).abs(); + for r in k + 1..n { + let v = a.get(r, k).abs(); + if v > best { + best = v; + piv = r; + } + } + if !(best > 1e-300) || !best.is_finite() { + return None; + } + a.swap_rows(k, piv); + b.swap(k, piv); + let d = a.get(k, k); + for r in k + 1..n { + let f = a.get(r, k) / d; + if f == 0.0 { + continue; + } + for c in k..n { + let v = a.get(k, c); + a.add(r, c, -f * v); + } + let bk = *b.get(k)?; + *b.get_mut(r)? -= f * bk; + } + } + let mut x = vec![0.0; n]; + for k in (0..n).rev() { + let mut s = *b.get(k)?; + for c in k + 1..n { + s -= a.get(k, c) * x.get(c)?; + } + *x.get_mut(k)? = s / a.get(k, k); + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +/// Reduce to row echelon form in place; returns the pivot column of each pivot row. +pub fn rref(a: &mut Mat, tol: f64) -> Vec { + let mut pivots = Vec::new(); + let mut row = 0; + for col in 0..a.cols { + if row >= a.rows { + break; + } + let mut piv = row; + let mut best = a.get(row, col).abs(); + for r in row + 1..a.rows { + let v = a.get(r, col).abs(); + if v > best { + best = v; + piv = r; + } + } + if !(best > tol) { + continue; + } + a.swap_rows(row, piv); + let d = a.get(row, col); + for c in 0..a.cols { + let v = a.get(row, c) / d; + a.set(row, c, v); + } + for r in 0..a.rows { + if r == row { + continue; + } + let f = a.get(r, col); + if f == 0.0 { + continue; + } + for c in 0..a.cols { + let v = a.get(row, c); + a.add(r, c, -f * v); + } + } + pivots.push(col); + row += 1; + } + pivots +} + +/// For each column of `j`: is that variable fixed by the constraints (zero in every null-space +/// vector)? Also returns the rank. +pub fn determined_vars(j: &Mat, tol: f64) -> (Vec, usize) { + let mut a = j.clone(); + let pivots = rref(&mut a, tol); + let rank = pivots.len(); + let free: Vec = (0..a.cols).filter(|c| !pivots.contains(c)).collect(); + let mut det = vec![true; a.cols]; + for f in &free { + if let Some(d) = det.get_mut(*f) { + *d = false; + } + } + // Null vector for free column f: x_f = 1, x_pivot(r) = -a[r][f]. + for (r, pc) in pivots.iter().enumerate() { + if free.iter().any(|f| a.get(r, *f).abs() > tol) + && let Some(d) = det.get_mut(*pc) + { + *d = false; + } + } + (det, rank) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn solves_and_detects_singular() { + let mut a = Mat::zeros(2, 2); + a.set(0, 0, 2.0); + a.set(0, 1, 1.0); + a.set(1, 0, 1.0); + a.set(1, 1, 3.0); + let x = solve(a, vec![3.0, 5.0]).unwrap(); + assert!((x[0] - 0.8).abs() < 1e-12 && (x[1] - 1.4).abs() < 1e-12); + let s = Mat::zeros(2, 2); + assert!(solve(s, vec![1.0, 1.0]).is_none()); + } + + #[test] + fn rank_and_determined() { + // x0 + x1 = .., x2 = .. ; x0 and x1 not individually determined. + let mut j = Mat::zeros(2, 3); + j.set(0, 0, 1.0); + j.set(0, 1, 1.0); + j.set(1, 2, 1.0); + let (det, rank) = determined_vars(&j, 1e-9); + assert_eq!(rank, 2); + assert_eq!(det, vec![false, false, true]); + } +} diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs new file mode 100644 index 00000000..4774243b --- /dev/null +++ b/crates/sketch/src/model.rs @@ -0,0 +1,674 @@ +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Seg2, Vec2}; + +/// Hard caps that keep hostile input from exhausting memory or time. +pub const MAX_POINTS: usize = 20_000; +pub const MAX_CONSTRAINTS: usize = 40_000; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SketchError { + #[error("unknown sketch entity `{0}`")] + Unknown(String), + #[error("`{0}` is the wrong kind of entity for this: {1}")] + WrongKind(String, String), + #[error("duplicate id `{0}`")] + Duplicate(String), + #[error("invalid value: {0}")] + Invalid(String), + #[error("sketch is too large")] + TooLarge, +} + +type Result = std::result::Result; + +/// A sketch point. `fixed` points are constants for the solver. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SPoint { + pub id: String, + pub pos: Vec2, + #[serde(default)] + pub fixed: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CurveKind { + /// Segment between two points (indices into `Sketch::points`). + Line { a: usize, b: usize }, + /// Full circle: centre point and radius. + Circle { c: usize, r: f64 }, + /// Counter-clockwise arc around `c` from `a` to `b` (radius = |a − c|; the solver keeps + /// |b − c| equal to it). + Arc { c: usize, a: usize, b: usize }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Curve { + pub id: String, + #[serde(flatten)] + pub kind: CurveKind, + #[serde(default)] + pub construction: bool, +} + +/// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or +/// `Sketch::curves` (`l`, `c`, `a`, `b`). Dimension values are millimetres or radians. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ConstraintKind { + Coincident { + p: usize, + q: usize, + }, + PointOnCurve { + p: usize, + c: usize, + }, + Horizontal { + l: usize, + }, + Vertical { + l: usize, + }, + HorizontalPoints { + p: usize, + q: usize, + }, + VerticalPoints { + p: usize, + q: usize, + }, + Parallel { + a: usize, + b: usize, + }, + Perpendicular { + a: usize, + b: usize, + }, + Collinear { + a: usize, + b: usize, + }, + Tangent { + a: usize, + b: usize, + }, + Equal { + a: usize, + b: usize, + }, + Concentric { + a: usize, + b: usize, + }, + Midpoint { + p: usize, + l: usize, + }, + Symmetric { + p: usize, + q: usize, + l: usize, + }, + Fix { + p: usize, + }, + // Dimensions (driving). + Distance { + p: usize, + q: usize, + value: f64, + }, + DistanceX { + p: usize, + q: usize, + value: f64, + }, + DistanceY { + p: usize, + q: usize, + value: f64, + }, + PointLineDistance { + p: usize, + l: usize, + value: f64, + }, + Length { + l: usize, + value: f64, + }, + Radius { + c: usize, + value: f64, + }, + Diameter { + c: usize, + value: f64, + }, + /// Angle from line `a` to line `b`, radians, counter-clockwise. + Angle { + a: usize, + b: usize, + value: f64, + }, +} + +impl ConstraintKind { + pub fn is_dimension(&self) -> bool { + self.value().is_some() + } + pub fn value(&self) -> Option { + use ConstraintKind::*; + match *self { + Distance { value, .. } + | DistanceX { value, .. } + | DistanceY { value, .. } + | PointLineDistance { value, .. } + | Length { value, .. } + | Radius { value, .. } + | Diameter { value, .. } + | Angle { value, .. } => Some(value), + _ => None, + } + } + pub fn set_value(&mut self, v: f64) { + use ConstraintKind::*; + match self { + Distance { value, .. } + | DistanceX { value, .. } + | DistanceY { value, .. } + | PointLineDistance { value, .. } + | Length { value, .. } + | Radius { value, .. } + | Diameter { value, .. } + | Angle { value, .. } => *value = v, + _ => {} + } + } + pub fn is_angle(&self) -> bool { + matches!(self, ConstraintKind::Angle { .. }) + } + /// Short type name (Fusion-like) for listings. + pub fn name(&self) -> &'static str { + use ConstraintKind::*; + match self { + Coincident { .. } | PointOnCurve { .. } => "Coincident", + Horizontal { .. } | HorizontalPoints { .. } => "Horizontal", + Vertical { .. } | VerticalPoints { .. } => "Vertical", + Parallel { .. } => "Parallel", + Perpendicular { .. } => "Perpendicular", + Collinear { .. } => "Collinear", + Tangent { .. } => "Tangent", + Equal { .. } => "Equal", + Concentric { .. } => "Concentric", + Midpoint { .. } => "MidPoint", + Symmetric { .. } => "Symmetry", + Fix { .. } => "Fix", + Distance { .. } | DistanceX { .. } | DistanceY { .. } | PointLineDistance { .. } | Length { .. } => "LinearDimension", + Radius { .. } => "RadialDimension", + Diameter { .. } => "DiameterDimension", + Angle { .. } => "AngularDimension", + } + } + fn indices(&self) -> (Vec, Vec) { + use ConstraintKind::*; + match *self { + Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]), + Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]), + PointOnCurve { p, c } => (vec![p], vec![c]), + Horizontal { l } | Vertical { l } | Length { l, .. } => (vec![], vec![l]), + Radius { c, .. } | Diameter { c, .. } => (vec![], vec![c]), + Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => { + (vec![], vec![a, b]) + } + Angle { a, b, .. } => (vec![], vec![a, b]), + Midpoint { p, l } | PointLineDistance { p, l, .. } => (vec![p], vec![l]), + Symmetric { p, q, l } => (vec![p, q], vec![l]), + Fix { p } => (vec![p], vec![]), + } + } + fn remap(&mut self, pmap: &dyn Fn(usize) -> Option, cmap: &dyn Fn(usize) -> Option) -> bool { + use ConstraintKind::*; + let mut ok = true; + let mut fp = |i: &mut usize| match pmap(*i) { + Some(n) => *i = n, + None => ok = false, + }; + let mut ok2 = true; + let mut fc = |i: &mut usize| match cmap(*i) { + Some(n) => *i = n, + None => ok2 = false, + }; + match self { + Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => { + fp(p); + fp(q); + } + Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => { + fp(p); + fp(q); + } + PointOnCurve { p, c } => { + fp(p); + fc(c); + } + Horizontal { l } | Vertical { l } | Length { l, .. } => fc(l), + Radius { c, .. } | Diameter { c, .. } => fc(c), + Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => { + fc(a); + fc(b); + } + Angle { a, b, .. } => { + fc(a); + fc(b); + } + Midpoint { p, l } | PointLineDistance { p, l, .. } => { + fp(p); + fc(l); + } + Symmetric { p, q, l } => { + fp(p); + fp(q); + fc(l); + } + Fix { p } => fp(p), + } + ok && ok2 + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Constraint { + pub id: String, + #[serde(flatten)] + pub kind: ConstraintKind, + /// For dimensions: the model parameter that drives the value (e.g. `d1`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub param: Option, +} + +/// A 2D sketch. Point 0 is always the fixed sketch origin (id `origin`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Sketch { + pub points: Vec, + pub curves: Vec, + pub constraints: Vec, + #[serde(default)] + next_id: u64, +} + +impl Default for Sketch { + fn default() -> Self { + Sketch::new() + } +} + +impl Sketch { + pub fn new() -> Self { + Sketch { points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true }], curves: Vec::new(), constraints: Vec::new(), next_id: 1 } + } + + fn fresh(&mut self, prefix: &str) -> String { + loop { + let id = format!("{prefix}{}", self.next_id); + self.next_id += 1; + if !self.id_taken(&id) { + return id; + } + } + } + fn id_taken(&self, id: &str) -> bool { + self.points.iter().any(|p| p.id == id) || self.curves.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id) + } + + pub fn point_index(&self, id: &str) -> Option { + self.points.iter().position(|p| p.id == id) + } + pub fn curve_index(&self, id: &str) -> Option { + self.curves.iter().position(|c| c.id == id) + } + pub fn point(&self, i: usize) -> Option { + self.points.get(i).map(|p| p.pos) + } + + fn check_pos(p: Vec2) -> Result { + if p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) } + } + + /// Add a free point (or reuse an existing one within `merge_tol` of `p` when `merge_tol > 0`). + pub fn add_point(&mut self, p: Vec2, id: Option<&str>) -> Result { + let p = Self::check_pos(p)?; + if self.points.len() >= MAX_POINTS { + return Err(SketchError::TooLarge); + } + let id = match id { + Some(i) if self.id_taken(i) => return Err(SketchError::Duplicate(i.into())), + Some("") => return Err(SketchError::Invalid("empty id".into())), + Some(i) => i.to_string(), + None => self.fresh("p"), + }; + self.points.push(SPoint { id, pos: p, fixed: false }); + Ok(self.points.len() - 1) + } + + fn curve_id(&mut self, id: Option<&str>, prefix: &str) -> Result { + match id { + Some(i) if self.id_taken(i) => Err(SketchError::Duplicate(i.into())), + Some("") => Err(SketchError::Invalid("empty id".into())), + Some(i) => Ok(i.into()), + None => Ok(self.fresh(prefix)), + } + } + + /// Endpoint ids of a curve's own points: `.start`, `.end`, `.center`. + fn own_point(&mut self, curve: &str, role: &str, p: Vec2) -> Result { + let id = format!("{curve}.{role}"); + if self.id_taken(&id) { + return self.add_point(p, None); + } + self.add_point(p, Some(&id)) + } + + /// Line between existing points `a` and `b`. + pub fn add_line_pts(&mut self, a: usize, b: usize, id: Option<&str>) -> Result { + if a >= self.points.len() || b >= self.points.len() || a == b { + return Err(SketchError::Invalid("line needs two distinct points".into())); + } + let id = self.curve_id(id, "l")?; + self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false }); + Ok(self.curves.len() - 1) + } + + /// Line from `p0` to `p1`, reusing given point indices when provided. + pub fn add_line(&mut self, p0: Vec2, p1: Vec2, a: Option, b: Option, id: Option<&str>) -> Result { + Self::check_pos(p0)?; + Self::check_pos(p1)?; + let id = self.curve_id(id, "l")?; + let a = match a { + Some(i) if i < self.points.len() => i, + _ => self.own_point(&id, "start", p0)?, + }; + let b = match b { + Some(i) if i < self.points.len() && i != a => i, + _ => self.own_point(&id, "end", p1)?, + }; + self.add_line_pts(a, b, Some(&id)) + } + + pub fn add_circle(&mut self, center: Vec2, r: f64, c: Option, id: Option<&str>) -> Result { + if !(r.is_finite() && r > 1e-9 && r < 1e9) { + return Err(SketchError::Invalid(format!("radius {r}"))); + } + let id = self.curve_id(id, "c")?; + let c = match c { + Some(i) if i < self.points.len() => i, + _ => self.own_point(&id, "center", center)?, + }; + self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false }); + Ok(self.curves.len() - 1) + } + + /// Counter-clockwise arc around `center` from `p0` to `p1` (the end point is projected onto + /// the circle through `p0`). + pub fn add_arc(&mut self, center: Vec2, p0: Vec2, p1: Vec2, pts: [Option; 3], id: Option<&str>) -> Result { + let r = center.dist(p0); + if !(r.is_finite() && r > 1e-9 && r < 1e9) { + return Err(SketchError::Invalid("degenerate arc".into())); + } + let p1 = center + (p1 - center).normalized().ok_or_else(|| SketchError::Invalid("degenerate arc".into()))? * r; + let id = self.curve_id(id, "a")?; + let n = self.points.len(); + let c = match pts[0] { + Some(i) if i < n => i, + _ => self.own_point(&id, "center", center)?, + }; + let a = match pts[1] { + Some(i) if i < n => i, + _ => self.own_point(&id, "start", p0)?, + }; + let b = match pts[2] { + Some(i) if i < n && i != a => i, + _ => self.own_point(&id, "end", p1)?, + }; + self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false }); + Ok(self.curves.len() - 1) + } + + pub fn add_constraint(&mut self, kind: ConstraintKind, param: Option) -> Result { + if self.constraints.len() >= MAX_CONSTRAINTS { + return Err(SketchError::TooLarge); + } + self.validate(&kind)?; + let id = self.fresh("k"); + self.constraints.push(Constraint { id: id.clone(), kind, param }); + Ok(id) + } + + /// Check indices and entity kinds of a constraint. + pub fn validate(&self, k: &ConstraintKind) -> Result<()> { + use ConstraintKind::*; + let (ps, cs) = k.indices(); + for p in &ps { + if *p >= self.points.len() { + return Err(SketchError::Unknown(format!("point #{p}"))); + } + } + for c in &cs { + if *c >= self.curves.len() { + return Err(SketchError::Unknown(format!("curve #{c}"))); + } + } + let is_line = |i: usize| matches!(self.curves.get(i).map(|c| &c.kind), Some(CurveKind::Line { .. })); + let is_round = |i: usize| matches!(self.curves.get(i).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Arc { .. })); + let name = |i: usize| self.curves.get(i).map(|c| c.id.clone()).unwrap_or_default(); + let need_line = |i: usize| if is_line(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a line".into())) }; + let need_round = |i: usize| if is_round(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a circle or arc".into())) }; + match *k { + Horizontal { l } | Vertical { l } | Length { l, .. } | Midpoint { l, .. } | Symmetric { l, .. } | PointLineDistance { l, .. } => { + need_line(l)? + } + Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Angle { a, b, .. } => { + need_line(a)?; + need_line(b)?; + } + Radius { c, .. } | Diameter { c, .. } => need_round(c)?, + Concentric { a, b } => { + need_round(a)?; + need_round(b)?; + } + Tangent { a, b } => { + if !(is_round(a) || is_round(b)) { + return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into())); + } + } + Equal { a, b } => { + if is_line(a) != is_line(b) { + return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into())); + } + } + Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())), + _ => {} + } + if let Some(v) = k.value() + && (!v.is_finite() || v.abs() > 1e9 || (v < 0.0 && !k.is_angle())) + { + return Err(SketchError::Invalid(format!("dimension value {v}"))); + } + Ok(()) + } + + /// Radius of a circle or arc. + pub fn radius(&self, ci: usize) -> Option { + match self.curves.get(ci)?.kind { + CurveKind::Circle { r, .. } => Some(r), + CurveKind::Arc { c, a, .. } => Some(self.point(c)?.dist(self.point(a)?)), + CurveKind::Line { .. } => None, + } + } + pub fn center(&self, ci: usize) -> Option { + match self.curves.get(ci)?.kind { + CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => self.point(c), + CurveKind::Line { .. } => None, + } + } + + /// Boundary segments of a curve (a circle gives two half arcs). + pub fn segs(&self, ci: usize) -> Vec { + let Some(cu) = self.curves.get(ci) else { return Vec::new() }; + match cu.kind { + CurveKind::Line { a, b } => match (self.point(a), self.point(b)) { + (Some(a), Some(b)) => vec![Seg2::Line { a, b }], + _ => Vec::new(), + }, + CurveKind::Circle { c, r } => match self.point(c) { + Some(c) => solvecraft_geom::Loop2::circle(c, r).segs, + None => Vec::new(), + }, + CurveKind::Arc { c, a, b } => match (self.point(c), self.point(a), self.point(b)) { + (Some(c), Some(pa), Some(pb)) => { + let start = (pa - c).angle(); + let mut sweep = (pb - c).angle() - start; + while sweep <= 1e-12 { + sweep += std::f64::consts::TAU; + } + vec![Seg2::Arc { center: c, radius: c.dist(pa), start, sweep }] + } + _ => Vec::new(), + }, + } + } + + /// Remove curves (by index) and then any points no longer used by a curve (except the + /// origin and free points), and constraints that referred to removed entities. + pub fn remove_curves(&mut self, which: &[usize]) { + let mut owned: Vec = Vec::new(); + for (i, c) in self.curves.iter().enumerate() { + if which.contains(&i) { + match c.kind { + CurveKind::Line { a, b } => owned.extend([a, b]), + CurveKind::Circle { c, .. } => owned.push(c), + CurveKind::Arc { c, a, b } => owned.extend([c, a, b]), + } + } + } + self.drop_curves(which); + let mut orphans: Vec = owned.into_iter().filter(|p| *p != 0 && !self.point_used_by_curve(*p)).collect(); + orphans.sort_unstable(); + orphans.dedup(); + self.remove_points(&orphans); + } + + fn point_used_by_curve(&self, p: usize) -> bool { + self.curves.iter().any(|c| match c.kind { + CurveKind::Line { a, b } => a == p || b == p, + CurveKind::Circle { c, .. } => c == p, + CurveKind::Arc { c, a, b } => c == p || a == p || b == p, + }) + } + + /// Remove curves only (points stay), remapping constraints. + fn drop_curves(&mut self, which: &[usize]) { + let mut cmap = vec![None; self.curves.len()]; + let mut n = 0; + for (i, slot) in cmap.iter_mut().enumerate() { + if !which.contains(&i) { + *slot = Some(n); + n += 1; + } + } + let mut idx = 0; + self.curves.retain(|_| { + let k = !which.contains(&idx); + idx += 1; + k + }); + let cm = |i: usize| cmap.get(i).copied().flatten(); + let pm = |i: usize| Some(i); + self.constraints.retain_mut(|c| c.kind.remap(&pm, &cm)); + } + + /// Remove points (never the origin) and every curve/constraint using them. + pub fn remove_points(&mut self, which: &[usize]) { + if which.is_empty() { + return; + } + let gone = |p: usize| p != 0 && which.contains(&p); + let dead_curves: Vec = self + .curves + .iter() + .enumerate() + .filter(|(_, c)| match c.kind { + CurveKind::Line { a, b } => gone(a) || gone(b), + CurveKind::Circle { c, .. } => gone(c), + CurveKind::Arc { c, a, b } => gone(c) || gone(a) || gone(b), + }) + .map(|(i, _)| i) + .collect(); + self.drop_curves(&dead_curves); + let mut pmap = vec![None; self.points.len()]; + let mut n = 0; + for (i, slot) in pmap.iter_mut().enumerate() { + if !gone(i) { + *slot = Some(n); + n += 1; + } + } + let pm = |i: usize| pmap.get(i).copied().flatten(); + for c in &mut self.curves { + match &mut c.kind { + CurveKind::Line { a, b } => { + *a = pm(*a).unwrap_or(0); + *b = pm(*b).unwrap_or(0); + } + CurveKind::Circle { c, .. } => *c = pm(*c).unwrap_or(0), + CurveKind::Arc { c, a, b } => { + *c = pm(*c).unwrap_or(0); + *a = pm(*a).unwrap_or(0); + *b = pm(*b).unwrap_or(0); + } + } + } + let cm = |i: usize| Some(i); + self.constraints.retain_mut(|c| c.kind.remap(&pm, &cm)); + let mut idx = 0; + self.points.retain(|_| { + let k = !gone(idx); + idx += 1; + k + }); + } + + /// Resolve a point reference: a point id (`origin`, `l1.end`, `p3`) or `.start|end|center`. + pub fn resolve_point(&self, r: &str) -> Option { + if let Some(i) = self.point_index(r) { + return Some(i); + } + let (cid, role) = r.rsplit_once('.')?; + let c = self.curves.get(self.curve_index(cid)?)?; + match (&c.kind, role) { + (CurveKind::Line { a, .. }, "start") | (CurveKind::Arc { a, .. }, "start") => Some(*a), + (CurveKind::Line { b, .. }, "end") | (CurveKind::Arc { b, .. }, "end") => Some(*b), + (CurveKind::Circle { c, .. }, "center") | (CurveKind::Arc { c, .. }, "center") => Some(*c), + _ => None, + } + } + + /// Axis-aligned bounds of all geometry (for views). + pub fn bounds(&self) -> Option<(Vec2, Vec2)> { + let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY); + let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); + let mut any = false; + for i in 0..self.curves.len() { + for s in self.segs(i) { + for p in s.polyline(0.5) { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + any = true; + } + } + } + any.then_some((lo, hi)) + } +} diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs new file mode 100644 index 00000000..20d32cff --- /dev/null +++ b/crates/sketch/src/profiles.rs @@ -0,0 +1,273 @@ +//! Closed-profile detection. +//! +//! Non-construction lines and arcs form a planar graph (end points within a small tolerance are +//! merged). Dangling edges are pruned, then faces are traced with the "first clockwise turn" +//! rule, which yields every bounded region counter-clockwise. Circles are loops of their own. +//! A loop that lies inside another loop of a different connected component becomes a hole of +//! the innermost loop containing it, so a circle inside a rectangle gives two profiles: the +//! rectangle with a hole, and the disc. + +use std::f64::consts::TAU; + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Loop2, Region2, Seg2, Vec2}; + +use crate::model::{CurveKind, Sketch}; + +const MERGE_TOL: f64 = 1e-6; +const MAX_EDGES: usize = 50_000; + +/// A closed region usable by features, with the ids of the curves bounding it. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Profile { + pub region: Region2, + /// Curve ids of the outer loop. + pub outer_curves: Vec, + /// Curve ids of each hole loop. + pub hole_curves: Vec>, + pub area: f64, + pub centroid: Vec2, +} + +impl Profile { + /// All curve ids bounding the profile. + pub fn curve_ids(&self) -> Vec { + let mut v = self.outer_curves.clone(); + for h in &self.hole_curves { + v.extend(h.iter().cloned()); + } + v + } +} + +struct Edge { + curve: usize, + seg: Seg2, + u: usize, + v: usize, +} + +fn find(p: &mut [usize], mut i: usize) -> usize { + while let Some(&q) = p.get(i) { + if q == i { + break; + } + i = q; + } + i +} + +/// Find all closed profiles of a sketch. +pub fn find_profiles(sk: &Sketch) -> Vec { + // Node ids: point indices merged by position. + let n = sk.points.len(); + let mut parent: Vec = (0..n).collect(); + let mut order: Vec = (0..n).collect(); + order.sort_by(|a, b| { + let (pa, pb) = (sk.points.get(*a).map(|p| p.pos.x).unwrap_or(0.0), sk.points.get(*b).map(|p| p.pos.x).unwrap_or(0.0)); + pa.total_cmp(&pb) + }); + for (k, &i) in order.iter().enumerate() { + let Some(pi) = sk.points.get(i).map(|p| p.pos) else { continue }; + for &j in order.iter().skip(k + 1) { + let Some(pj) = sk.points.get(j).map(|p| p.pos) else { continue }; + if pj.x - pi.x > MERGE_TOL { + break; + } + if pi.dist(pj) <= MERGE_TOL { + let (ri, rj) = (find(&mut parent, i), find(&mut parent, j)); + if ri != rj + && let Some(s) = parent.get_mut(rj) + { + *s = ri; + } + } + } + } + + let mut loops: Vec<(Loop2, Vec, usize)> = Vec::new(); // (ccw loop, curve ids, component) + let mut edges: Vec = Vec::new(); + for (ci, c) in sk.curves.iter().enumerate() { + if c.construction { + continue; + } + match c.kind { + CurveKind::Circle { .. } => { + let segs = sk.segs(ci); + if !segs.is_empty() { + loops.push((Loop2 { segs }, vec![c.id.clone()], usize::MAX - ci)); + } + } + CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } => { + let (u, v) = (find(&mut parent, a), find(&mut parent, b)); + if u == v { + continue; + } + if let Some(seg) = sk.segs(ci).into_iter().next() + && seg.length() > MERGE_TOL + { + edges.push(Edge { curve: ci, seg, u, v }); + } + } + } + if edges.len() > MAX_EDGES { + break; + } + } + + // Prune dangling edges. + let mut alive = vec![true; edges.len()]; + loop { + let mut deg = vec![0usize; n]; + for (e, a) in edges.iter().zip(&alive) { + if *a { + if let Some(d) = deg.get_mut(e.u) { + *d += 1; + } + if let Some(d) = deg.get_mut(e.v) { + *d += 1; + } + } + } + let mut changed = false; + for (e, a) in edges.iter().zip(alive.iter_mut()) { + if *a && (deg.get(e.u).copied().unwrap_or(0) < 2 || deg.get(e.v).copied().unwrap_or(0) < 2) { + *a = false; + changed = true; + } + } + if !changed { + break; + } + } + + // Components over nodes for nesting. + let mut comp: Vec = (0..n).collect(); + for (e, a) in edges.iter().zip(&alive) { + if *a { + let (ru, rv) = (find(&mut comp, e.u), find(&mut comp, e.v)); + if ru != rv + && let Some(s) = comp.get_mut(rv) + { + *s = ru; + } + } + } + + // Half-edges: 2e = forward (u→v), 2e+1 = backward. + let half = |h: usize| -> Option<(Seg2, usize, usize)> { + let e = edges.get(h / 2)?; + Some(if h.is_multiple_of(2) { (e.seg, e.u, e.v) } else { (e.seg.reversed(), e.v, e.u) }) + }; + let mut outgoing: Vec> = vec![Vec::new(); n]; + for (ei, a) in alive.iter().enumerate() { + if !*a { + continue; + } + for h in [2 * ei, 2 * ei + 1] { + if let Some((_, from, _)) = half(h) + && let Some(o) = outgoing.get_mut(from) + { + o.push(h); + } + } + } + let mut visited = vec![false; edges.len() * 2]; + for start in 0..edges.len() * 2 { + if !alive.get(start / 2).copied().unwrap_or(false) || visited.get(start).copied().unwrap_or(true) { + continue; + } + let mut segs = Vec::new(); + let mut ids = Vec::new(); + let mut h = start; + let mut ok = true; + for _ in 0..=edges.len() * 2 { + if let Some(v) = visited.get_mut(h) { + *v = true; + } + let Some((seg, _, to)) = half(h) else { + ok = false; + break; + }; + segs.push(seg); + if let Some(e) = edges.get(h / 2) + && let Some(c) = sk.curves.get(e.curve) + { + ids.push(c.id.clone()); + } + let back = (-seg.end_tangent()).angle(); + let twin = h ^ 1; + let mut best: Option<(f64, usize)> = None; + for &o in outgoing.get(to).map(Vec::as_slice).unwrap_or(&[]) { + if o == twin { + continue; + } + let Some((os, _, _)) = half(o) else { continue }; + let mut d = (back - os.start_tangent().angle()) % TAU; + if d <= 1e-12 { + d += TAU; + } + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, o)); + } + } + let Some((_, next)) = best else { + ok = false; + break; + }; + h = next; + if h == start { + break; + } + if visited.get(h).copied().unwrap_or(true) { + ok = false; + break; + } + } + if !ok || h != start { + continue; + } + let lp = Loop2 { segs }; + if lp.signed_area() > 1e-9 { + let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0); + loops.push((lp, ids, c)); + } + } + + // Nesting: parent = smallest containing loop from another component. + let areas: Vec = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect(); + let mut parent_of: Vec> = vec![None; loops.len()]; + for (i, (li, _, ci)) in loops.iter().enumerate() { + let probe = li.segs.first().map(Seg2::mid).unwrap_or_default(); + let ai = areas.get(i).copied().unwrap_or(0.0); + let mut best: Option<(f64, usize)> = None; + for (j, (lj, _, cj)) in loops.iter().enumerate() { + let aj = areas.get(j).copied().unwrap_or(0.0); + if i == j || ci == cj || aj <= ai { + continue; + } + if lj.contains(probe) && best.is_none_or(|(ba, _)| aj < ba) { + best = Some((aj, j)); + } + } + if let Some(p) = parent_of.get_mut(i) { + *p = best.map(|(_, j)| j); + } + } + let mut out: Vec = loops + .iter() + .enumerate() + .map(|(i, (l, ids, _))| { + let kids: Vec = (0..loops.len()).filter(|k| parent_of.get(*k).copied().flatten() == Some(i)).collect(); + let holes: Vec = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect(); + let hole_curves: Vec> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect(); + let region = Region2 { outer: l.ccw(), holes }; + let area = region.area(); + let centroid = region.centroid(); + Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid } + }) + .collect(); + let first_curve = |p: &Profile| p.outer_curves.iter().filter_map(|id| sk.curve_index(id)).min().unwrap_or(usize::MAX); + out.sort_by(|a, b| first_curve(a).cmp(&first_curve(b)).then(b.area.total_cmp(&a.area))); + out +} diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs new file mode 100644 index 00000000..33a52f78 --- /dev/null +++ b/crates/sketch/src/solver.rs @@ -0,0 +1,636 @@ +//! The constraint solver. +//! +//! Unknowns are the coordinates of every non-fixed point and the radius of every circle. Each +//! constraint contributes residuals (zero when satisfied, in millimetres so that all rows have a +//! comparable scale). The system is split into connected components and each is solved with a +//! damped Gauss–Newton (Levenberg–Marquardt) iteration on a dense Jacobian built from local +//! central differences. Small steps from the current positions are preferred, so +//! under-constrained sketches move as little as possible. Afterwards the rank of the Jacobian +//! gives the remaining degrees of freedom and which points are fully determined. + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::Vec2; + +use crate::linalg::{Mat, determined_vars, solve as lin_solve}; +use crate::model::{ConstraintKind, CurveKind, Sketch}; + +const MAX_ITERS: usize = 200; +/// Converged when every residual is below this (mm). +const TOL: f64 = 1e-9; +/// A solution is accepted when every residual is below this (mm). +const ACCEPT: f64 = 1e-6; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SolveStatus { + /// All constraints satisfied. + Solved, + /// The constraints conflict (or the solver could not satisfy them); geometry was left as it + /// was before solving. + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SolveReport { + pub status: SolveStatus, + /// Remaining degrees of freedom (0 = fully constrained). + pub dof: usize, + pub iterations: usize, + pub max_residual: f64, + /// Constraints still violated after a failed solve. + pub failing: Vec, + /// Per point: fully determined by constraints (fixed points count as determined). + pub point_determined: Vec, + /// Per curve: all of its defining values determined. + pub curve_determined: Vec, +} + +impl SolveReport { + pub fn ok(&self) -> bool { + self.status == SolveStatus::Solved + } + pub fn fully_constrained(&self) -> bool { + self.dof == 0 + } +} + +/// Variable layout: point → (x var, y var), circle → radius var. +struct Layout { + pvar: Vec>, + rvar: Vec>, + n: usize, +} + +fn layout(sk: &Sketch) -> Layout { + let mut fixed: Vec = sk.points.iter().map(|p| p.fixed).collect(); + for c in &sk.constraints { + if let ConstraintKind::Fix { p } = c.kind + && let Some(f) = fixed.get_mut(p) + { + *f = true; + } + } + let mut n = 0; + let pvar = fixed + .iter() + .map(|f| { + if *f { + None + } else { + n += 2; + Some(n - 2) + } + }) + .collect(); + let rvar = sk + .curves + .iter() + .map(|c| match c.kind { + CurveKind::Circle { .. } => { + n += 1; + Some(n - 1) + } + _ => None, + }) + .collect(); + Layout { pvar, rvar, n } +} + +/// Read-only view of the sketch with variable values substituted. +struct State<'a> { + sk: &'a Sketch, + lay: &'a Layout, + x: &'a [f64], +} + +impl State<'_> { + fn p(&self, i: usize) -> Vec2 { + match self.lay.pvar.get(i).copied().flatten() { + Some(v) => Vec2::new(self.x.get(v).copied().unwrap_or(0.0), self.x.get(v + 1).copied().unwrap_or(0.0)), + None => self.sk.points.get(i).map(|p| p.pos).unwrap_or_default(), + } + } + fn line(&self, l: usize) -> (Vec2, Vec2) { + match self.sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => (self.p(*a), self.p(*b)), + _ => (Vec2::ZERO, Vec2::X), + } + } + /// Centre and radius of a circle or arc. + fn round(&self, c: usize) -> (Vec2, f64) { + match self.sk.curves.get(c).map(|c| &c.kind) { + Some(CurveKind::Circle { c: ci, r }) => { + let r = match self.lay.rvar.get(c).copied().flatten() { + Some(v) => self.x.get(v).copied().unwrap_or(*r), + None => *r, + }; + (self.p(*ci), r) + } + Some(CurveKind::Arc { c: ci, a, .. }) => { + let cc = self.p(*ci); + (cc, cc.dist(self.p(*a))) + } + _ => (Vec2::ZERO, 0.0), + } + } + fn is_line(&self, c: usize) -> bool { + matches!(self.sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) + } +} + +fn wrap_angle(a: f64) -> f64 { + let t = std::f64::consts::TAU; + let mut a = a % t; + if a > std::f64::consts::PI { + a -= t; + } else if a <= -std::f64::consts::PI { + a += t; + } + a +} + +/// Distance from `p` to the infinite line through `a`, `b` (signed by side). +fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 { + let d = b - a; + let l = d.len().max(1e-12); + d.cross(p - a) / l +} + +/// Residual rows of one constraint. +fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { + use ConstraintKind::*; + match *k { + Coincident { p, q } => { + let d = s.p(p) - s.p(q); + out.extend([d.x, d.y]); + } + PointOnCurve { p, c } => { + let pp = s.p(p); + if s.is_line(c) { + let (a, b) = s.line(c); + out.push(line_dist(pp, a, b)); + } else { + let (cc, r) = s.round(c); + out.push(pp.dist(cc) - r); + } + } + Horizontal { l } => { + let (a, b) = s.line(l); + out.push(b.y - a.y); + } + Vertical { l } => { + let (a, b) = s.line(l); + out.push(b.x - a.x); + } + HorizontalPoints { p, q } => out.push(s.p(q).y - s.p(p).y), + VerticalPoints { p, q } => out.push(s.p(q).x - s.p(p).x), + Parallel { a, b } | Perpendicular { a, b } => { + let (a0, a1) = s.line(a); + let (b0, b1) = s.line(b); + let (da, db) = (a1 - a0, b1 - b0); + let scale = (da.len() + db.len()).max(1e-12); + let v = if matches!(k, Parallel { .. }) { da.cross(db) } else { da.dot(db) }; + out.push(v / scale); + } + Collinear { a, b } => { + let (a0, a1) = s.line(a); + let (b0, b1) = s.line(b); + out.push(line_dist(b0, a0, a1)); + out.push(line_dist(b1, a0, a1)); + } + Tangent { a, b } => { + let (la, lb) = (s.is_line(a), s.is_line(b)); + if la || lb { + let (l, c) = if la { (a, b) } else { (b, a) }; + let (p0, p1) = s.line(l); + let (cc, r) = s.round(c); + out.push(line_dist(cc, p0, p1).abs() - r); + } else { + let (c1, r1) = s.round(a); + let (c2, r2) = s.round(b); + let d = c1.dist(c2); + let ext = d - (r1 + r2); + let int = d - (r1 - r2).abs(); + out.push(if ext.abs() <= int.abs() { ext } else { int }); + } + } + Equal { a, b } => { + if s.is_line(a) { + let (a0, a1) = s.line(a); + let (b0, b1) = s.line(b); + out.push(a0.dist(a1) - b0.dist(b1)); + } else { + out.push(s.round(a).1 - s.round(b).1); + } + } + Concentric { a, b } => { + let d = s.round(a).0 - s.round(b).0; + out.extend([d.x, d.y]); + } + Midpoint { p, l } => { + let (a, b) = s.line(l); + let d = s.p(p) - (a + b) * 0.5; + out.extend([d.x, d.y]); + } + Symmetric { p, q, l } => { + let (a, b) = s.line(l); + let (pp, qq) = (s.p(p), s.p(q)); + let dir = b - a; + let len = dir.len().max(1e-12); + out.push(line_dist((pp + qq) * 0.5, a, b)); + out.push((qq - pp).dot(dir) / len); + } + Fix { .. } => {} + Distance { p, q, value } => out.push(s.p(p).dist(s.p(q)) - value), + DistanceX { p, q, value } => out.push((s.p(q).x - s.p(p).x).abs() - value), + DistanceY { p, q, value } => out.push((s.p(q).y - s.p(p).y).abs() - value), + PointLineDistance { p, l, value } => { + let (a, b) = s.line(l); + out.push(line_dist(s.p(p), a, b).abs() - value); + } + Length { l, value } => { + let (a, b) = s.line(l); + out.push(a.dist(b) - value); + } + Radius { c, value } => out.push(s.round(c).1 - value), + Diameter { c, value } => out.push(2.0 * s.round(c).1 - value), + Angle { a, b, value } => { + let (a0, a1) = s.line(a); + let (b0, b1) = s.line(b); + let (da, db) = (a1 - a0, b1 - b0); + let ang = da.cross(db).atan2(da.dot(db)); + let scale = 0.5 * (da.len() + db.len()); + out.push(wrap_angle(ang - value) * scale.max(1e-6)); + } + } +} + +/// One residual block: implicit arc radius equality or a user constraint. +enum Block<'a> { + Arc { c: usize, a: usize, b: usize }, + User(usize, &'a ConstraintKind), +} + +fn block_residuals(s: &State, b: &Block, out: &mut Vec) { + match b { + Block::Arc { c, a, b } => { + let cc = s.p(*c); + out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a))); + } + Block::User(_, k) => residuals(s, k, out), + } +} + +/// Variables a block depends on. +fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { + let mut v = Vec::new(); + let pt = |v: &mut Vec, p: usize| { + if let Some(x) = lay.pvar.get(p).copied().flatten() { + v.extend([x, x + 1]); + } + }; + let curve = |v: &mut Vec, c: usize| match sk.curves.get(c).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => { + pt(v, *a); + pt(v, *b); + } + Some(CurveKind::Circle { c: ci, .. }) => { + pt(v, *ci); + if let Some(r) = lay.rvar.get(c).copied().flatten() { + v.push(r); + } + } + Some(CurveKind::Arc { c, a, b }) => { + pt(v, *c); + pt(v, *a); + pt(v, *b); + } + None => {} + }; + use ConstraintKind::*; + match b { + Block::Arc { c, a, b } => { + pt(&mut v, *c); + pt(&mut v, *a); + pt(&mut v, *b); + } + Block::User(_, k) => match **k { + Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => { + pt(&mut v, p); + pt(&mut v, q); + } + Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => { + pt(&mut v, p); + pt(&mut v, q); + } + PointOnCurve { p, c } | Midpoint { p, l: c } | PointLineDistance { p, l: c, .. } => { + pt(&mut v, p); + curve(&mut v, c); + } + Horizontal { l } | Vertical { l } | Length { l, .. } | Radius { c: l, .. } | Diameter { c: l, .. } => curve(&mut v, l), + Parallel { a, b } + | Perpendicular { a, b } + | Collinear { a, b } + | Tangent { a, b } + | Equal { a, b } + | Concentric { a, b } + | Angle { a, b, .. } => { + curve(&mut v, a); + curve(&mut v, b); + } + Symmetric { p, q, l } => { + pt(&mut v, p); + pt(&mut v, q); + curve(&mut v, l); + } + Fix { .. } => {} + }, + } + v.sort_unstable(); + v.dedup(); + v +} + +fn find(parent: &mut [usize], mut i: usize) -> usize { + while let Some(&p) = parent.get(i) { + if p == i { + break; + } + let gp = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(i) { + *slot = gp; + } + i = p; + } + i +} + +/// Residual vector and Jacobian (rows × all n vars) for the given blocks. +fn system(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], x: &mut [f64], with_j: bool) -> (Vec, Option, Vec) { + let mut r = Vec::new(); + let mut owner = Vec::new(); + for (bi, (b, _)) in blocks.iter().enumerate() { + let before = r.len(); + block_residuals(&State { sk, lay, x }, b, &mut r); + owner.extend(std::iter::repeat_n(bi, r.len() - before)); + } + if !with_j { + return (r, None, owner); + } + let mut j = Mat::zeros(r.len(), lay.n); + let mut row = 0; + let mut plus = Vec::new(); + let mut minus = Vec::new(); + for (b, vars) in blocks { + plus.clear(); + block_residuals(&State { sk, lay, x }, b, &mut plus); + let rows = plus.len(); + for &v in vars { + let Some(x0) = x.get(v).copied() else { continue }; + let h = 1e-7 * x0.abs().max(1.0); + if let Some(xv) = x.get_mut(v) { + *xv = x0 + h; + } + plus.clear(); + block_residuals(&State { sk, lay, x }, b, &mut plus); + if let Some(xv) = x.get_mut(v) { + *xv = x0 - h; + } + minus.clear(); + block_residuals(&State { sk, lay, x }, b, &mut minus); + if let Some(xv) = x.get_mut(v) { + *xv = x0; + } + for k in 0..rows { + let d = (plus.get(k).copied().unwrap_or(0.0) - minus.get(k).copied().unwrap_or(0.0)) / (2.0 * h); + j.set(row + k, v, d); + } + } + row += rows; + } + (r, Some(j), owner) +} + +fn max_abs(v: &[f64]) -> f64 { + v.iter().fold(0.0_f64, |m, x| if x.is_finite() { m.max(x.abs()) } else { f64::INFINITY }) +} +fn norm2(v: &[f64]) -> f64 { + v.iter().map(|x| x * x).sum() +} + +/// Levenberg–Marquardt on one component (variables `vars`, blocks `blocks`). Returns iterations. +fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], vars: &[usize], x: &mut Vec) -> usize { + let n = vars.len(); + let mut lambda = 1e-3; + let (mut r, _, _) = system(sk, lay, blocks, x, false); + let mut f = norm2(&r); + for it in 0..MAX_ITERS { + if max_abs(&r) < TOL { + return it; + } + let (_, Some(j), _) = system(sk, lay, blocks, x, true) else { return it }; + // Normal equations restricted to this component's variables. + let mut a = Mat::zeros(n, n); + let mut g = vec![0.0; n]; + for row in 0..j.rows { + let ri = r.get(row).copied().unwrap_or(0.0); + let nz: Vec<(usize, f64)> = vars.iter().enumerate().map(|(li, &v)| (li, j.get(row, v))).filter(|(_, d)| *d != 0.0).collect(); + for &(li, di) in &nz { + if let Some(gi) = g.get_mut(li) { + *gi += di * ri; + } + for &(lk, dk) in &nz { + a.add(li, lk, di * dk); + } + } + } + let mut improved = false; + for _ in 0..30 { + let mut m = a.clone(); + for i in 0..n { + let d = a.get(i, i); + m.add(i, i, lambda * (d + 1e-9) + 1e-12); + } + let rhs: Vec = g.iter().map(|v| -v).collect(); + let Some(step) = lin_solve(m, rhs) else { + lambda *= 10.0; + continue; + }; + let mut trial = x.clone(); + for (li, &v) in vars.iter().enumerate() { + if let (Some(t), Some(s)) = (trial.get_mut(v), step.get(li)) { + *t += s; + } + } + let (rt, _, _) = system(sk, lay, blocks, &mut trial, false); + let ft = norm2(&rt); + if ft.is_finite() && ft < f { + *x = trial; + r = rt; + f = ft; + lambda = (lambda / 3.0).max(1e-12); + improved = true; + break; + } + lambda *= 4.0; + if lambda > 1e12 { + break; + } + } + if !improved { + return it; + } + } + MAX_ITERS +} + +/// Solve the sketch in place. On failure the geometry is restored and the report lists the +/// violated constraints. +pub fn solve(sk: &mut Sketch) -> SolveReport { + let lay = layout(sk); + let mut x = vec![0.0; lay.n]; + for (i, p) in sk.points.iter().enumerate() { + if let Some(v) = lay.pvar.get(i).copied().flatten() { + if let Some(s) = x.get_mut(v) { + *s = p.pos.x; + } + if let Some(s) = x.get_mut(v + 1) { + *s = p.pos.y; + } + } + } + for (i, c) in sk.curves.iter().enumerate() { + if let (CurveKind::Circle { r, .. }, Some(v)) = (&c.kind, lay.rvar.get(i).copied().flatten()) + && let Some(s) = x.get_mut(v) + { + *s = *r; + } + } + let x0 = x.clone(); + + let mut blocks: Vec<(Block, Vec)> = Vec::new(); + for c in &sk.curves { + if let CurveKind::Arc { c, a, b } = c.kind { + let bl = Block::Arc { c, a, b }; + let vars = block_vars(sk, &lay, &bl); + blocks.push((bl, vars)); + } + } + for (i, c) in sk.constraints.iter().enumerate() { + let bl = Block::User(i, &c.kind); + let vars = block_vars(sk, &lay, &bl); + blocks.push((bl, vars)); + } + + // Connected components over variables. + let mut parent: Vec = (0..lay.n).collect(); + for (_, vars) in &blocks { + if let Some(&first) = vars.first() { + for &v in vars.iter().skip(1) { + let (ra, rb) = (find(&mut parent, first), find(&mut parent, v)); + if let Some(p) = parent.get_mut(ra) { + *p = rb; + } + } + } + } + let mut comps: Vec<(usize, Vec, Vec)> = Vec::new(); // (root, vars, block idx) + for v in 0..lay.n { + let r = find(&mut parent, v); + match comps.iter_mut().find(|c| c.0 == r) { + Some(c) => c.1.push(v), + None => comps.push((r, vec![v], Vec::new())), + } + } + let mut constant_blocks = Vec::new(); + for (bi, (_, vars)) in blocks.iter().enumerate() { + match vars.first() { + Some(&v) => { + let r = find(&mut parent, v); + if let Some(c) = comps.iter_mut().find(|c| c.0 == r) { + c.2.push(bi); + } + } + None => constant_blocks.push(bi), + } + } + + let mut iterations = 0; + // Solve each component; `blocks` is consumed by index so rebuild per component. + let all_blocks = blocks; + for (_, vars, bis) in &comps { + if bis.is_empty() { + continue; + } + let sub: Vec<(Block, Vec)> = bis + .iter() + .filter_map(|&bi| { + all_blocks.get(bi).map(|(b, v)| { + let b2 = match b { + Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b }, + Block::User(i, k) => Block::User(*i, k), + }; + (b2, v.clone()) + }) + }) + .collect(); + iterations += lm(sk, &lay, &sub, vars, &mut x); + } + + // Final residuals, Jacobian and analysis over everything. + let (r, j, owner) = system(sk, &lay, &all_blocks, &mut x, true); + let max_residual = max_abs(&r); + let mut failing: Vec = Vec::new(); + for (row, v) in r.iter().enumerate() { + if !(v.abs() < ACCEPT) + && let Some((Block::User(ci, _), _)) = owner.get(row).and_then(|bi| all_blocks.get(*bi)) + && let Some(c) = sk.constraints.get(*ci) + && !failing.contains(&c.id) + { + failing.push(c.id.clone()); + } + } + let _ = constant_blocks; + let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed }; + let use_x = if status == SolveStatus::Solved { &x } else { &x0 }; + let (det, rank) = match &j { + Some(j) if j.rows > 0 => determined_vars(j, 1e-7), + _ => (vec![false; lay.n], 0), + }; + // Write back. + for (i, p) in sk.points.iter_mut().enumerate() { + if let Some(v) = lay.pvar.get(i).copied().flatten() { + p.pos = Vec2::new(use_x.get(v).copied().unwrap_or(p.pos.x), use_x.get(v + 1).copied().unwrap_or(p.pos.y)); + } + } + for (i, c) in sk.curves.iter_mut().enumerate() { + if let (CurveKind::Circle { r, .. }, Some(v)) = (&mut c.kind, lay.rvar.get(i).copied().flatten()) { + *r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9); + } + } + let vdet = |v: Option| v.is_none_or(|v| det.get(v).copied().unwrap_or(false)); + let pdet = |p: usize| match lay.pvar.get(p).copied().flatten() { + Some(v) => vdet(Some(v)) && vdet(Some(v + 1)), + None => true, + }; + let point_determined: Vec = (0..sk.points.len()).map(pdet).collect(); + let curve_determined = sk + .curves + .iter() + .enumerate() + .map(|(i, c)| match c.kind { + CurveKind::Line { a, b } => pdet(a) && pdet(b), + CurveKind::Circle { c, .. } => pdet(c) && vdet(lay.rvar.get(i).copied().flatten()), + CurveKind::Arc { c, a, b } => pdet(c) && pdet(a) && pdet(b), + }) + .collect(); + SolveReport { + status, + dof: lay.n.saturating_sub(rank), + iterations, + max_residual: if max_residual.is_finite() { max_residual } else { f64::MAX }, + failing, + point_determined, + curve_determined, + } +} diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs new file mode 100644 index 00000000..947c4b67 --- /dev/null +++ b/crates/sketch/src/tests.rs @@ -0,0 +1,252 @@ +use std::f64::consts::{FRAC_PI_2, PI}; + +use solvecraft_geom::Vec2; + +use crate::*; + +fn v(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) +} + +/// Rough rectangle from four lines sharing corner points. +fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] { + let a = sk.add_point(p[0], None).unwrap(); + let b = sk.add_point(p[1], None).unwrap(); + let c = sk.add_point(p[2], None).unwrap(); + let d = sk.add_point(p[3], None).unwrap(); + [ + sk.add_line_pts(a, b, None).unwrap(), + sk.add_line_pts(b, c, None).unwrap(), + sk.add_line_pts(c, d, None).unwrap(), + sk.add_line_pts(d, a, None).unwrap(), + ] +} + +fn line_pts(sk: &Sketch, l: usize) -> (Vec2, Vec2) { + match sk.curves[l].kind { + CurveKind::Line { a, b } => (sk.points[a].pos, sk.points[b].pos), + _ => panic!(), + } +} + +#[test] +fn fully_constrained_rectangle() { + let mut sk = Sketch::new(); + let l = rect(&mut sk, [v(0.3, -0.2), v(38.0, 1.0), v(41.0, 29.0), v(-1.0, 31.0)]); + use ConstraintKind::*; + sk.add_constraint(Horizontal { l: l[0] }, None).unwrap(); + sk.add_constraint(Horizontal { l: l[2] }, None).unwrap(); + sk.add_constraint(Vertical { l: l[1] }, None).unwrap(); + sk.add_constraint(Vertical { l: l[3] }, None).unwrap(); + let (a, _) = match sk.curves[l[0]].kind { + CurveKind::Line { a, b } => (a, b), + _ => panic!(), + }; + sk.add_constraint(Coincident { p: a, q: 0 }, None).unwrap(); + sk.add_constraint(Length { l: l[0], value: 40.0 }, Some("d1".into())).unwrap(); + sk.add_constraint(Length { l: l[1], value: 30.0 }, Some("d2".into())).unwrap(); + let rep = solve(&mut sk); + assert!(rep.ok(), "{rep:?}"); + assert_eq!(rep.dof, 0, "{rep:?}"); + assert!(rep.point_determined.iter().all(|d| *d)); + let (p0, p1) = line_pts(&sk, l[0]); + assert!(p0.dist(v(0.0, 0.0)) < 1e-7, "{p0:?}"); + assert!(p1.dist(v(40.0, 0.0)) < 1e-7, "{p1:?}"); + let (_, p2) = line_pts(&sk, l[1]); + assert!(p2.dist(v(40.0, 30.0)) < 1e-7, "{p2:?}"); + // Change a dimension and re-solve. + sk.constraints[5].kind.set_value(55.0); + assert!(solve(&mut sk).ok()); + let (_, p1) = line_pts(&sk, l[0]); + assert!(p1.dist(v(55.0, 0.0)) < 1e-7); +} + +#[test] +fn under_constrained_reports_dof() { + let mut sk = Sketch::new(); + let l = sk.add_line(v(0.0, 0.0), v(10.0, 1.0), None, None, None).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); + let rep = solve(&mut sk); + assert!(rep.ok()); + assert_eq!(rep.dof, 3); + let (a, b) = line_pts(&sk, l); + assert!((a.y - b.y).abs() < 1e-9); + // Minimal movement: the line moved by about half a unit each. + assert!((a.y - 0.5).abs() < 0.05 && (b.y - 0.5).abs() < 0.05, "{a:?} {b:?}"); +} + +#[test] +fn conflicting_constraints_fail_and_restore() { + let mut sk = Sketch::new(); + let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); + sk.add_constraint(ConstraintKind::Vertical { l }, None).unwrap(); + sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap(); + let before = sk.clone(); + let rep = solve(&mut sk); + assert_eq!(rep.status, SolveStatus::Failed); + assert!(!rep.failing.is_empty()); + assert_eq!(sk.points, before.points); +} + +#[test] +fn circles_tangent_equal_concentric() { + let mut sk = Sketch::new(); + let c1 = sk.add_circle(v(0.0, 0.0), 5.0, Some(0), None).unwrap(); + let c2 = sk.add_circle(v(12.0, 1.0), 3.0, None, None).unwrap(); + use ConstraintKind::*; + sk.add_constraint(Radius { c: c1, value: 10.0 }, None).unwrap(); + sk.add_constraint(Equal { a: c1, b: c2 }, None).unwrap(); + sk.add_constraint(Tangent { a: c1, b: c2 }, None).unwrap(); + let rep = solve(&mut sk); + assert!(rep.ok(), "{rep:?}"); + assert!((sk.radius(c2).unwrap() - 10.0).abs() < 1e-7); + assert!((sk.center(c2).unwrap().len() - 20.0).abs() < 1e-7); + let c3 = sk.add_circle(v(3.0, 3.0), 2.0, None, None).unwrap(); + sk.add_constraint(Concentric { a: c3, b: c2 }, None).unwrap(); + assert!(solve(&mut sk).ok()); + assert!(sk.center(c3).unwrap().dist(sk.center(c2).unwrap()) < 1e-7); +} + +#[test] +fn line_tangent_to_circle_and_angles() { + let mut sk = Sketch::new(); + let c = sk.add_circle(v(0.0, 0.0), 10.0, Some(0), None).unwrap(); + use ConstraintKind::*; + sk.add_constraint(Radius { c, value: 10.0 }, None).unwrap(); + let l = sk.add_line(v(-20.0, 12.0), v(20.0, 11.0), None, None, None).unwrap(); + sk.add_constraint(Tangent { a: l, b: c }, None).unwrap(); + sk.add_constraint(Horizontal { l }, None).unwrap(); + assert!(solve(&mut sk).ok()); + let (a, _) = line_pts(&sk, l); + assert!((a.y.abs() - 10.0).abs() < 1e-7, "{a:?}"); + let m = sk.add_line(v(0.0, 0.0), v(10.0, 3.0), Some(0), None, None).unwrap(); + sk.add_constraint(Angle { a: l, b: m, value: PI / 6.0 }, None).unwrap(); + sk.add_constraint(Length { l: m, value: 8.0 }, None).unwrap(); + assert!(solve(&mut sk).ok()); + let (p, q) = line_pts(&sk, m); + let (la, lb) = line_pts(&sk, l); + assert!(((q - p).angle() - (lb - la).angle() - PI / 6.0).abs() < 1e-7, "{la:?} {lb:?} {p:?} {q:?}"); + let n2 = sk.add_line(v(1.0, 1.0), v(2.0, 5.0), None, None, None).unwrap(); + sk.add_constraint(Perpendicular { a: m, b: n2 }, None).unwrap(); + sk.add_constraint(Parallel { a: n2, b: n2 }, None).unwrap(); + assert!(solve(&mut sk).ok()); + let (p2, q2) = line_pts(&sk, n2); + assert!(((q2 - p2).angle() - (PI / 6.0 + FRAC_PI_2)).abs() < 1e-6 || ((q2 - p2).angle() - (PI / 6.0 - FRAC_PI_2)).abs() < 1e-6); +} + +#[test] +fn midpoint_symmetry_point_on_curve_arc() { + let mut sk = Sketch::new(); + use ConstraintKind::*; + let l = sk.add_line(v(-5.0, 0.0), v(5.0, 0.0), None, None, None).unwrap(); + let p = sk.add_point(v(1.0, 1.0), None).unwrap(); + sk.add_constraint(Midpoint { p, l }, None).unwrap(); + let a = sk.add_arc(v(0.0, 0.0), v(4.0, 0.0), v(0.0, 4.0), [None, None, None], None).unwrap(); + sk.add_constraint(Radius { c: a, value: 6.0 }, None).unwrap(); + let q = sk.add_point(v(3.0, 3.0), None).unwrap(); + sk.add_constraint(PointOnCurve { p: q, c: a }, None).unwrap(); + let s1 = sk.add_point(v(-2.0, 1.0), None).unwrap(); + let s2 = sk.add_point(v(3.0, -1.5), None).unwrap(); + let axis = sk.add_line(v(0.0, -10.0), v(0.0, 10.0), None, None, None).unwrap(); + sk.add_constraint(Vertical { l: axis }, None).unwrap(); + sk.add_constraint(Symmetric { p: s1, q: s2, l: axis }, None).unwrap(); + let rep = solve(&mut sk); + assert!(rep.ok(), "{rep:?}"); + let (la, lb) = line_pts(&sk, l); + assert!(sk.points[p].pos.dist((la + lb) * 0.5) < 1e-7); + assert!((sk.radius(a).unwrap() - 6.0).abs() < 1e-7); + let c = sk.center(a).unwrap(); + assert!((sk.points[q].pos.dist(c) - 6.0).abs() < 1e-7); + let (ax, _) = line_pts(&sk, axis); + assert!((sk.points[s1].pos.x + sk.points[s2].pos.x - 2.0 * ax.x).abs() < 1e-7); + assert!((sk.points[s1].pos.y - sk.points[s2].pos.y).abs() < 1e-7); +} + +#[test] +fn profiles_rectangle_with_hole_and_split() { + let mut sk = Sketch::new(); + rect(&mut sk, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]); + sk.add_circle(v(20.0, 15.0), 5.0, None, None).unwrap(); + let ps = find_profiles(&sk); + assert_eq!(ps.len(), 2, "{ps:?}"); + let big = ps.iter().find(|p| p.region.holes.len() == 1).unwrap(); + assert!((big.area - (1200.0 - PI * 25.0)).abs() < 1e-6); + let disc = ps.iter().find(|p| p.region.holes.is_empty()).unwrap(); + assert!((disc.area - PI * 25.0).abs() < 1e-6); + assert_eq!(big.hole_curves.len(), 1); + + // A line across the rectangle splits it into two regions (with the hole in one). + let mut sk2 = Sketch::new(); + let l = rect(&mut sk2, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]); + let (b0, _) = line_pts(&sk2, l[0]); + let _ = b0; + let pa = sk2.add_point(v(10.0, 0.0), None).unwrap(); + let pb = sk2.add_point(v(10.0, 30.0), None).unwrap(); + // Re-split bottom and top edges at x = 10 by rebuilding the sketch as 7 lines. + let mut s3 = Sketch::new(); + let pts = [v(0.0, 0.0), v(10.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(10.0, 30.0), v(0.0, 30.0)]; + let ids: Vec = pts.iter().map(|p| s3.add_point(*p, None).unwrap()).collect(); + for i in 0..6 { + s3.add_line_pts(ids[i], ids[(i + 1) % 6], None).unwrap(); + } + s3.add_line_pts(ids[1], ids[4], None).unwrap(); + let _ = (pa, pb); + let ps = find_profiles(&s3); + assert_eq!(ps.len(), 2); + let mut areas: Vec = ps.iter().map(|p| p.area).collect(); + areas.sort_by(f64::total_cmp); + assert!((areas[0] - 300.0).abs() < 1e-9 && (areas[1] - 900.0).abs() < 1e-9, "{areas:?}"); +} + +#[test] +fn profile_with_arcs_and_construction_ignored() { + // Slot: two lines and two half arcs. + let mut sk = Sketch::new(); + let p = [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(0.0, 10.0)]; + let ids: Vec = p.iter().map(|q| sk.add_point(*q, None).unwrap()).collect(); + sk.add_line_pts(ids[0], ids[1], None).unwrap(); + sk.add_arc(v(20.0, 5.0), p[1], p[2], [None, Some(ids[1]), Some(ids[2])], None).unwrap(); + sk.add_line_pts(ids[2], ids[3], None).unwrap(); + sk.add_arc(v(0.0, 5.0), p[3], p[0], [None, Some(ids[3]), Some(ids[0])], None).unwrap(); + let c = sk.add_line(v(-5.0, -5.0), v(30.0, 20.0), None, None, None).unwrap(); + sk.curves[c].construction = true; + let ps = find_profiles(&sk); + assert_eq!(ps.len(), 1, "{ps:?}"); + assert!((ps[0].area - (200.0 + PI * 25.0)).abs() < 1e-6, "{}", ps[0].area); + assert_eq!(ps[0].outer_curves.len(), 4); +} + +#[test] +fn removal_and_references() { + let mut sk = Sketch::new(); + let l1 = sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).unwrap(); + let e = sk.resolve_point("l1.end").unwrap(); + let l2 = sk.add_line(v(1.0, 0.0), v(1.0, 1.0), Some(e), None, Some("l2")).unwrap(); + sk.add_constraint(ConstraintKind::Horizontal { l: l1 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Vertical { l: l2 }, None).unwrap(); + assert!(sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).is_err()); + sk.remove_curves(&[l1]); + assert_eq!(sk.curves.len(), 1); + assert_eq!(sk.constraints.len(), 1); + assert!(sk.resolve_point("l2.start").is_some()); + assert!(sk.resolve_point("l1.start").is_none()); + assert_eq!(sk.points.len(), 3, "origin + l2's two points"); + assert!(solve(&mut sk).ok()); + let json = serde_json::to_string(&sk).unwrap(); + let back: Sketch = serde_json::from_str(&json).unwrap(); + assert_eq!(back, sk); +} + +#[test] +fn hostile_values_rejected() { + let mut sk = Sketch::new(); + assert!(sk.add_point(v(f64::NAN, 0.0), None).is_err()); + assert!(sk.add_circle(v(0.0, 0.0), -1.0, None, None).is_err()); + assert!(sk.add_circle(v(0.0, 0.0), f64::INFINITY, None, None).is_err()); + assert!(sk.add_constraint(ConstraintKind::Horizontal { l: 99 }, None).is_err()); + let c = sk.add_circle(v(0.0, 0.0), 1.0, None, None).unwrap(); + assert!(sk.add_constraint(ConstraintKind::Horizontal { l: c }, None).is_err()); + assert!(sk.add_constraint(ConstraintKind::Radius { c, value: f64::NAN }, None).is_err()); + assert!(sk.add_constraint(ConstraintKind::Radius { c, value: -2.0 }, None).is_err()); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 00000000..cc50ca19 --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "solvecraft-ui-egui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1 @@ + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..3121924d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 00000000..df7dfa87 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde_json = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 00000000..f328e4d9 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1 @@ +fn main() {}