From a97af44979cd6b4bcd0b63be4f46de0badd9c1b4 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 17:18:16 +0000 Subject: [PATCH] M0: truck-based kernel with local fillets and chamfers, parametric document timeline The kernel moved from monstertruck to truck: truck's booleans handle blind holes, pockets and bosses that monstertruck's did not (see the ADR). Fillets and chamfers on straight convex edges between planar faces are our own exact local B-rep operation. The document holds parameters with unit-aware expressions and a timeline that re-evaluates incrementally. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 841 +++++++++++++++---------------------- Cargo.toml | 9 +- crates/doc/Cargo.toml | 10 + crates/doc/src/document.rs | 583 +++++++++++++++++++++++++ crates/doc/src/eval.rs | 545 ++++++++++++++++++++++++ crates/doc/src/expr.rs | 506 ++++++++++++++++++++++ crates/doc/src/lib.rs | 33 ++ crates/doc/src/tests.rs | 183 ++++++++ crates/geom/src/mesh.rs | 35 ++ crates/kernel/Cargo.toml | 11 +- crates/kernel/src/blend.rs | 307 ++++++++++++++ crates/kernel/src/body.rs | 11 +- crates/kernel/src/build.rs | 50 +-- crates/kernel/src/lib.rs | 12 +- crates/kernel/src/ops.rs | 135 +++--- crates/kernel/src/step.rs | 11 +- crates/kernel/src/tests.rs | 81 ++++ crates/kernel/src/topo.rs | 2 +- 18 files changed, 2740 insertions(+), 625 deletions(-) create mode 100644 crates/doc/src/document.rs create mode 100644 crates/doc/src/eval.rs create mode 100644 crates/doc/src/expr.rs create mode 100644 crates/doc/src/tests.rs create mode 100644 crates/kernel/src/blend.rs diff --git a/Cargo.lock b/Cargo.lock index 9c69f3e9..411c4a89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,36 +3,10 @@ version = 4 [[package]] -name = "Inflector" -version = "0.11.4" +name = "adler2" +version = "2.0.1" 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", -] +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "allocator-api2" @@ -70,6 +44,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "bitflags" version = "2.13.2" @@ -78,9 +58,9 @@ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "branches" -version = "0.4.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" +checksum = "f11502672c5570f77f6bdf573332483f8475bab6a7fda00f1fae8ddb5a6245c0" dependencies = [ "rustc_version", ] @@ -111,6 +91,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cc" version = "1.6.0" @@ -138,17 +124,6 @@ dependencies = [ "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" @@ -164,12 +139,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.10.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] name = "core-foundation-sys" @@ -178,12 +150,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "cpufeatures" -version = "0.3.1" +name = "crc32fast" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ - "libc", + "cfg-if", ] [[package]] @@ -211,38 +183,17 @@ 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" +version = "0.99.20" 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" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.119", - "unicode-xid", ] [[package]] @@ -263,6 +214,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -295,29 +257,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" 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", + "wasi", "wasm-bindgen", ] @@ -332,12 +279,6 @@ dependencies = [ "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" @@ -362,30 +303,11 @@ 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" +version = "0.13.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" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -418,12 +340,6 @@ dependencies = [ "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" @@ -445,6 +361,23 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lz4_flex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05304f8e67dfc93d1b4b990137fd1a7a4c6ad44b60a9c486c8c4486f9d2027ae" + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "matext4cgmath" version = "0.1.0" @@ -456,6 +389,15 @@ dependencies = [ "num-complex", ] +[[package]] +name = "memchr" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "148fab2e51b4f1cfc66da2a7c32981d1d3c083a803978268bb11fe4b86925e7a" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.3" @@ -463,220 +405,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "monstertruck-assembly" -version = "0.4.1" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1041107e797756c1176344c81461d7bb7298ee00c3e9ee5adf093d14f627d56" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" 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", + "adler2", + "simd-adler32", ] [[package]] name = "nom" -version = "8.0.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +checksum = "05aec50c70fd288702bcd93284a8444607f3292dbdf2a30de5ea5dcdbe72287b" dependencies = [ - "memchr", -] - -[[package]] -name = "nom-language" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" -dependencies = [ - "nom", + "memchr 1.0.2", ] [[package]] @@ -688,6 +432,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -733,12 +488,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "pkg-config" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "toml_edit", + "zerocopy", ] [[package]] @@ -765,28 +526,6 @@ dependencies = [ "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" @@ -796,6 +535,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" +dependencies = [ + "memchr 2.8.3", + "serde", +] + [[package]] name = "quote" version = "1.0.47" @@ -805,34 +554,35 @@ 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" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ - "chacha20", - "getrandom 0.4.3", + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" -version = "0.10.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] [[package]] name = "rayon" @@ -856,9 +606,9 @@ dependencies = [ [[package]] name = "rclite" -version = "0.4.1" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6716a637a8a17bce72da6b6ddab8548619cc748658c41ab6ee5c4a3c1001385" +checksum = "e09665c494a9de2bd230e1aedc0c2fb20481a51f28f7db84d0c9bcfe2fe537b2" dependencies = [ "branches", ] @@ -872,35 +622,6 @@ 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" @@ -977,7 +698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", - "memchr", + "memchr 2.8.3", "serde", "serde_core", "zmij", @@ -989,6 +710,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1012,6 +739,15 @@ version = "0.1.0" [[package]] name = "solvecraft-doc" version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "solvecraft-geom", + "solvecraft-kernel", + "solvecraft-sketch", + "thiserror 2.0.21", +] [[package]] name = "solvecraft-engine" @@ -1034,13 +770,14 @@ name = "solvecraft-kernel" version = "0.1.0" dependencies = [ "log", - "monstertruck-io", - "monstertruck-meshing", - "monstertruck-modeling", - "monstertruck-solid", "serde", "solvecraft-geom", - "thiserror", + "thiserror 2.0.21", + "truck-meshalgo", + "truck-modeling", + "truck-shapeops", + "truck-stepio", + "truck-topology", ] [[package]] @@ -1054,7 +791,7 @@ dependencies = [ "serde", "serde_json", "solvecraft-geom", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -1067,43 +804,12 @@ version = "2.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" dependencies = [ - "hashbrown 0.16.1", + "hashbrown", "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" @@ -1111,6 +817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -1136,13 +843,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1157,33 +884,149 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "truck-base" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "4c279de9e92e5dc20a188deb0bb9a4bd421a6a185e57e03e19025e84a36c5a05" dependencies = [ - "serde_core", + "cgmath", + "matext4cgmath", + "rustc-hash", + "serde", ] [[package]] -name = "toml_edit" -version = "0.25.15+spec-1.1.0" +name = "truck-derivers" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "421ff1c5a303aed64e295d9b30e8424ee7a2fa251617a59ae4acfd078496009f" dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" +name = "truck-geometry" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "4d9d85f203fdbc2d206a38b8e7c4417a05a2eb589be8b81ecfe9f98faafc425d" dependencies = [ - "winnow", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "truck-geotrait" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a31fe98d5a0589ad20576260e214af23dea21a8d125da81ad3bbcb666f411f" +dependencies = [ + "getrandom", + "rand", + "thiserror 1.0.69", + "truck-base", + "truck-derivers", +] + +[[package]] +name = "truck-meshalgo" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e18f325738e822c79bb1a48897ec789cbf50cbeeb0841105295f9da85bdf3613" +dependencies = [ + "array-macro", + "derive_more", + "itertools", + "rayon", + "rustc-hash", + "spade", + "truck-base", + "truck-geometry", + "truck-polymesh", + "truck-topology", + "vtkio", +] + +[[package]] +name = "truck-modeling" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8082c2dfac8c2732a014d81807fb5d24dbac0d72ee4de2a705b7fbc05fa05b18" +dependencies = [ + "derive_more", + "rustc-hash", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geometry", + "truck-geotrait", + "truck-polymesh", + "truck-topology", +] + +[[package]] +name = "truck-polymesh" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f22def310af4a89c37beb4839bbf702b302d2c112ea29553759f0e16622fbf" +dependencies = [ + "array-macro", + "bytemuck", + "itertools", + "rustc-hash", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "truck-shapeops" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6e83ea4d36d00229d38ab985a991e32f58c3ed2971083011ac66fb57e26d5c" +dependencies = [ + "derive_more", + "itertools", + "rustc-hash", + "truck-base", + "truck-geometry", + "truck-geotrait", + "truck-meshalgo", + "truck-topology", +] + +[[package]] +name = "truck-stepio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d29cf5b0dec4b927e80b5dca26ce3ca69601c77ea5b9c953e30bfc3e123d189" +dependencies = [ + "chrono", + "truck-geometry", + "truck-modeling", + "truck-polymesh", + "truck-topology", +] + +[[package]] +name = "truck-topology" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf62a1610405c5e39ab5eb6948f326fc1641689734a6b8207a80792133fe0e78" +dependencies = [ + "parking_lot", + "rayon", + "rclite", + "rustc-hash", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", ] [[package]] @@ -1192,18 +1035,6 @@ 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" @@ -1211,14 +1042,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "vtkio" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "abbe89e5b97b472d57abeb02755a06d75b28d2df7d1fe3df5baf032281a65c16" dependencies = [ - "wit-bindgen", + "base64", + "bytemuck", + "byteorder", + "flate2", + "lz4_flex", + "nom", + "num-derive", + "num-traits", + "quick-xml", + "serde", + "xz2", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -1264,16 +1111,6 @@ 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" @@ -1333,21 +1170,6 @@ 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" @@ -1356,10 +1178,13 @@ dependencies = [ ] [[package]] -name = "xxhash-rust" -version = "0.8.19" +name = "xz2" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] [[package]] name = "zerocopy" @@ -1381,6 +1206,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index f69b017f..0defcac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,10 +28,11 @@ 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" +truck-modeling = "0.6" +truck-shapeops = { version = "0.4", default-features = false } +truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] } +truck-stepio = { version = "0.3", default-features = false } +truck-topology = "0.6" # images image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 44cd0852..90edb501 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -1,8 +1,18 @@ [package] name = "solvecraft-doc" +description = "SolveCraft document model: parameters and expressions, sketches and features on a parametric timeline, incremental evaluation" version.workspace = true edition.workspace = true license.workspace = true [lints] workspace = true + +[dependencies] +solvecraft-geom = { workspace = true } +solvecraft-sketch = { workspace = true } +solvecraft-kernel = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs new file mode 100644 index 00000000..b97335da --- /dev/null +++ b/crates/doc/src/document.rs @@ -0,0 +1,583 @@ +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Plane, Vec2, Vec3}; +use solvecraft_sketch::Sketch; + +use crate::expr::{self, Kind}; +use crate::{DocError, Result}; + +pub const MAX_FEATURES: usize = 10_000; +pub const MAX_PARAMS: usize = 10_000; + +/// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…) +/// are created by dimensions and features. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Param { + pub name: String, + pub expr: String, + /// `mm`, `deg` or empty (unit-less). + pub unit: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub comment: String, + /// Created by a dimension or feature (not by the user). + #[serde(default)] + pub model: bool, +} + +impl Param { + pub fn kind(&self) -> Kind { + Kind::from_unit(&self.unit) + } +} + +/// Where a sketch lies. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PlaneRef { + /// `XY`, `XZ` or `YZ`. + Origin { name: String }, + /// An explicit plane (e.g. picked from a planar face). + Custom { plane: Plane }, + /// Another plane offset along its normal by an expression. + Offset { base: Box, distance: String }, +} + +/// Which closed profiles of a sketch a feature uses. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProfileSel { + /// Every profile. + All, + /// Profiles by index in the solved sketch's profile list. + Indices { indices: Vec }, + /// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable + /// across parameter changes. + Curves { loops: Vec> }, + /// The profile containing each sketch point. + Points { points: Vec }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + #[default] + NewBody, + Join, + Cut, + Intersect, +} + +impl Operation { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "new" | "newbody" | "newcomponent" => Operation::NewBody, + "join" | "union" | "add" => Operation::Join, + "cut" | "subtract" | "difference" => Operation::Cut, + "intersect" | "intersection" => Operation::Intersect, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + /// Along the sketch normal. + #[default] + Positive, + Negative, + /// Both sides; `distance` per side. + Symmetric, +} + +/// Extent of an extrude. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Extent { + pub distance: String, + #[serde(default)] + pub direction: Direction, + /// Optional second side distance (two-sided extrude), along the negative normal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub distance2: Option, + /// Start offset from the sketch plane (expression). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_offset: Option, +} + +/// Revolve axis. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AxisRef { + /// A line of the profile's sketch. + SketchLine { curve: String }, + /// Sketch x or y axis through the sketch origin (`x` / `y`). + SketchAxis { axis: String }, + /// World X / Y / Z axis through the origin. + World { axis: String }, +} + +/// Feature definitions. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FeatureKind { + Sketch { + plane: PlaneRef, + sketch: Sketch, + }, + Extrude { + /// Feature id of the sketch. + sketch: u64, + profiles: ProfileSel, + extent: Extent, + #[serde(default)] + operation: Operation, + /// Target body names for join/cut/intersect (empty = all bodies). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + targets: Vec, + }, + Revolve { + sketch: u64, + profiles: ProfileSel, + axis: AxisRef, + angle: String, + #[serde(default)] + operation: Operation, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + targets: Vec, + }, + Fillet { + /// Reference points on the edges to round (each re-finds the nearest edge). + edges: Vec, + radius: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + Chamfer { + edges: Vec, + distance: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + Box { + corner: Vec3, + length: String, + width: String, + height: String, + #[serde(default)] + operation: Operation, + }, + Cylinder { + base: Vec3, + #[serde(default = "z_axis")] + axis: Vec3, + radius: String, + height: String, + #[serde(default)] + operation: Operation, + }, + Sphere { + center: Vec3, + radius: String, + #[serde(default)] + operation: Operation, + }, + Torus { + center: Vec3, + major: String, + minor: String, + #[serde(default)] + operation: Operation, + }, + Combine { + target: String, + tools: Vec, + operation: Operation, + #[serde(default)] + keep_tools: bool, + }, + Move { + bodies: Vec, + translate: [String; 3], + #[serde(default, skip_serializing_if = "Option::is_none")] + rotate_axis: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + angle: Option, + }, +} + +fn z_axis() -> Vec3 { + Vec3::Z +} + +impl FeatureKind { + /// Fusion-like type name (timeline tooltip, `inspect`). + pub fn type_name(&self) -> &'static str { + match self { + FeatureKind::Sketch { .. } => "Sketch", + FeatureKind::Extrude { .. } => "ExtrudeFeature", + FeatureKind::Revolve { .. } => "RevolveFeature", + FeatureKind::Fillet { .. } => "FilletFeature", + FeatureKind::Chamfer { .. } => "ChamferFeature", + FeatureKind::Box { .. } => "BoxFeature", + FeatureKind::Cylinder { .. } => "CylinderFeature", + FeatureKind::Sphere { .. } => "SphereFeature", + FeatureKind::Torus { .. } => "TorusFeature", + FeatureKind::Combine { .. } => "CombineFeature", + FeatureKind::Move { .. } => "MoveFeature", + } + } + /// Default name prefix (`Extrude` → `Extrude1`). + pub fn base_name(&self) -> &'static str { + match self { + FeatureKind::Sketch { .. } => "Sketch", + FeatureKind::Extrude { .. } => "Extrude", + FeatureKind::Revolve { .. } => "Revolve", + FeatureKind::Fillet { .. } => "Fillet", + FeatureKind::Chamfer { .. } => "Chamfer", + FeatureKind::Box { .. } => "Box", + FeatureKind::Cylinder { .. } => "Cylinder", + FeatureKind::Sphere { .. } => "Sphere", + FeatureKind::Torus { .. } => "Torus", + FeatureKind::Combine { .. } => "Combine", + FeatureKind::Move { .. } => "Move", + } + } + /// Every expression the feature uses. + pub fn expressions(&self) -> Vec<&str> { + let mut v: Vec<&str> = Vec::new(); + match self { + FeatureKind::Sketch { plane, .. } => { + let mut p = plane; + while let PlaneRef::Offset { base, distance } = p { + v.push(distance); + p = base; + } + } + FeatureKind::Extrude { extent, .. } => { + v.push(&extent.distance); + if let Some(d) = &extent.distance2 { + v.push(d); + } + if let Some(d) = &extent.start_offset { + v.push(d); + } + } + FeatureKind::Revolve { angle, .. } => v.push(angle), + FeatureKind::Fillet { radius, .. } => v.push(radius), + FeatureKind::Chamfer { distance, .. } => v.push(distance), + FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]), + FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]), + FeatureKind::Sphere { radius, .. } => v.push(radius), + FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]), + FeatureKind::Combine { .. } => {} + FeatureKind::Move { translate, angle, .. } => { + v.extend(translate.iter().map(String::as_str)); + if let Some(a) = angle { + v.push(a); + } + } + } + v + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Feature { + pub id: u64, + pub name: String, + #[serde(default)] + pub suppressed: bool, + /// Names for the bodies this feature creates (in order); missing ones get `BodyN`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub body_names: Vec, + #[serde(flatten)] + pub kind: FeatureKind, +} + +/// The document. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Document { + #[serde(default = "default_format")] + pub format: String, + pub name: String, + #[serde(default = "default_units")] + pub units: String, + pub params: Vec, + pub features: Vec, + /// Timeline marker: features at or after this index are rolled back (not evaluated). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marker: Option, + #[serde(default)] + pub next_id: u64, +} + +fn default_format() -> String { + "solvecraft/1".into() +} +fn default_units() -> String { + "mm".into() +} + +impl Default for Document { + fn default() -> Self { + Document::new("Untitled") + } +} + +impl Document { + pub fn new(name: &str) -> Self { + Document { + format: default_format(), + name: name.into(), + units: default_units(), + params: Vec::new(), + features: Vec::new(), + marker: None, + next_id: 1, + } + } + + pub fn from_json(s: &str) -> Result { + let d: Document = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("document: {e}")))?; + if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS { + return Err(DocError::Invalid("document too large".into())); + } + Ok(d) + } + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_default() + } + + pub fn feature(&self, id: u64) -> Option<&Feature> { + self.features.iter().find(|f| f.id == id) + } + pub fn feature_mut(&mut self, id: u64) -> Option<&mut Feature> { + self.features.iter_mut().find(|f| f.id == id) + } + pub fn feature_index(&self, id: u64) -> Option { + self.features.iter().position(|f| f.id == id) + } + /// Find a feature by id (number) or name. + pub fn find_feature(&self, r: &str) -> Option<&Feature> { + if let Ok(id) = r.parse::() + && let Some(f) = self.feature(id) + { + return Some(f); + } + self.features.iter().find(|f| f.name.eq_ignore_ascii_case(r)) + } + + fn unique_name(&self, base: &str) -> String { + let mut n = 1; + loop { + let name = format!("{base}{n}"); + if !self.features.iter().any(|f| f.name == name) { + return name; + } + n += 1; + } + } + + /// Insert a feature at the marker (or the end) and return its id. + pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result { + if self.features.len() >= MAX_FEATURES { + return Err(DocError::Invalid("too many features".into())); + } + let id = self.next_id.max(1); + self.next_id = id + 1; + let name = match name { + Some(n) if !n.trim().is_empty() => n.trim().to_string(), + _ => self.unique_name(kind.base_name()), + }; + let f = Feature { id, name, suppressed: false, body_names: Vec::new(), kind }; + match self.marker { + Some(m) if m < self.features.len() => { + self.features.insert(m, f); + self.marker = Some(m + 1); + } + _ => self.features.push(f), + } + Ok(id) + } + + /// Delete a feature (and, for a sketch, the features that use it). + pub fn delete_feature(&mut self, id: u64) -> Result> { + let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?; + let mut gone = vec![id]; + for f in &self.features { + match &f.kind { + FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id), + _ => {} + } + } + self.features.retain(|f| !gone.contains(&f.id)); + if let Some(m) = self.marker + && m > idx + { + self.marker = Some(m.saturating_sub(gone.len()).max(idx)); + } + // Drop model parameters nobody references any more. + self.prune_model_params(); + Ok(gone) + } + + /// Remove model parameters not referenced by any dimension, feature or other parameter. + pub fn prune_model_params(&mut self) { + let used = self.referenced_names(); + self.params.retain(|p| !p.model || used.contains(&p.name)); + } + + fn referenced_names(&self) -> Vec { + let mut used: Vec = Vec::new(); + for f in &self.features { + for e in f.kind.expressions() { + used.extend(expr::references(e)); + } + if let FeatureKind::Sketch { sketch, .. } = &f.kind { + used.extend(sketch.constraints.iter().filter_map(|c| c.param.clone())); + } + } + for p in &self.params { + used.extend(expr::references(&p.expr)); + } + used + } + + pub fn param(&self, name: &str) -> Option<&Param> { + self.params.iter().find(|p| p.name == name) + } + + fn valid_name(name: &str) -> bool { + let mut cs = name.chars(); + matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_') + && cs.all(|c| c.is_alphanumeric() || c == '_') + && !matches!(name, "mm" | "cm" | "m" | "um" | "in" | "ft" | "deg" | "rad" | "pi" | "PI") + && name.len() <= 64 + } + + /// Add or change a parameter. Fails if the result would not evaluate. + pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> { + if !Self::valid_name(name) { + return Err(DocError::Invalid(format!("`{name}` is not a valid parameter name"))); + } + if expr_s.len() > 4096 { + return Err(DocError::Expr("expression too long".into())); + } + let mut next = self.clone(); + match next.params.iter_mut().find(|p| p.name == name) { + Some(p) => { + p.expr = expr_s.to_string(); + if let Some(u) = unit { + p.unit = u.to_string(); + } + if let Some(c) = comment { + p.comment = c.to_string(); + } + } + None => { + if next.params.len() >= MAX_PARAMS { + return Err(DocError::Invalid("too many parameters".into())); + } + next.params.push(Param { + name: name.into(), + expr: expr_s.into(), + unit: unit.unwrap_or("mm").into(), + comment: comment.unwrap_or_default().into(), + model: false, + }) + } + } + let (_, errors) = next.param_values(); + if let Some(e) = errors.get(name) { + return Err(DocError::Expr(e.clone())); + } + *self = next; + Ok(()) + } + + /// Create the next free model parameter `dN` with the given expression and unit. + pub fn new_model_param(&mut self, expr_s: &str, unit: &str) -> String { + let mut n = 1; + let name = loop { + let c = format!("d{n}"); + if self.param(&c).is_none() { + break c; + } + n += 1; + }; + self.params.push(Param { name: name.clone(), expr: expr_s.into(), unit: unit.into(), comment: String::new(), model: true }); + name + } + + /// Remove a user parameter (fails while something references it). + pub fn remove_param(&mut self, name: &str) -> Result<()> { + if self.param(name).is_none() { + return Err(DocError::Unknown(format!("parameter `{name}`"))); + } + let mut probe = self.clone(); + probe.params.retain(|p| p.name != name); + if probe.referenced_names().iter().any(|n| n == name) { + return Err(DocError::Invalid(format!("`{name}` is still used"))); + } + *self = probe; + Ok(()) + } + + /// Evaluate all parameters: values (mm / rad / unit-less) and errors by name. + pub fn param_values(&self) -> (std::collections::BTreeMap, std::collections::BTreeMap) { + let list: Vec<(String, String, Kind)> = self.params.iter().map(|p| (p.name.clone(), p.expr.clone(), p.kind())).collect(); + expr::eval_params(&list) + } + + /// Evaluate an expression against the document's parameters. + pub fn eval(&self, e: &str, kind: Kind) -> Result { + let (vals, _) = self.param_values(); + Self::eval_in(&vals, e, kind) + } + + pub fn eval_in(vals: &std::collections::BTreeMap, e: &str, kind: Kind) -> Result { + expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?.to_kind(kind) + } + + /// Resolve a plane reference. + pub fn resolve_plane(&self, vals: &std::collections::BTreeMap, p: &PlaneRef, depth: usize) -> Result { + if depth > 32 { + return Err(DocError::Invalid("plane offsets nested too deeply".into())); + } + match p { + PlaneRef::Origin { name } => Plane::named(name).ok_or_else(|| DocError::Unknown(format!("plane `{name}`"))), + PlaneRef::Custom { plane } => Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into())), + PlaneRef::Offset { base, distance } => { + let b = self.resolve_plane(vals, base, depth + 1)?; + Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?)) + } + } + } + + /// Mutable access to a sketch feature's sketch. + pub fn sketch_mut(&mut self, id: u64) -> Result<&mut Sketch> { + match self.feature_mut(id).map(|f| &mut f.kind) { + Some(FeatureKind::Sketch { sketch, .. }) => Ok(sketch), + Some(_) => Err(DocError::Invalid(format!("feature {id} is not a sketch"))), + None => Err(DocError::Unknown(format!("sketch {id}"))), + } + } + pub fn sketch(&self, id: u64) -> Result<&Sketch> { + match self.feature(id).map(|f| &f.kind) { + Some(FeatureKind::Sketch { sketch, .. }) => Ok(sketch), + Some(_) => Err(DocError::Invalid(format!("feature {id} is not a sketch"))), + None => Err(DocError::Unknown(format!("sketch {id}"))), + } + } + + /// Apply parameter values to every dimension in a sketch (in place). + pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap, sk: &mut Sketch) -> Result<()> { + for c in &mut sk.constraints { + if let Some(p) = &c.param { + let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length }; + let v = vals.get(p).copied().ok_or_else(|| DocError::Unknown(format!("parameter `{p}`")))?.to_kind(kind)?; + c.kind.set_value(v); + } + } + Ok(()) + } +} diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs new file mode 100644 index 00000000..b26a8aad --- /dev/null +++ b/crates/doc/src/eval.rs @@ -0,0 +1,545 @@ +//! Timeline evaluation. + +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, OnceLock}; + +use serde::Serialize; +use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3}; +use solvecraft_kernel::{self as kernel, Body, BoolOp}; +use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve}; + +use crate::expr::{self, Kind, Value}; +use crate::{AxisRef, DocError, Document, Feature, FeatureKind, Operation, ProfileSel, Result}; + +/// A body in the evaluated model. +#[derive(Clone, Debug)] +pub struct ModelBody { + pub name: String, + pub body: Body, + /// Feature that created it. + pub feature: u64, + mesh: OnceLock>, +} + +impl ModelBody { + pub fn new(name: String, body: Body, feature: u64) -> Self { + ModelBody { name, body, feature, mesh: OnceLock::new() } + } + /// Display mesh (chord tolerance 1/1000 of the body size), computed once. + pub fn mesh(&self) -> Arc { + self.mesh + .get_or_init(|| { + let tol = (self.body.size() * 1e-3).max(1e-3); + Arc::new(self.body.tessellate(tol).unwrap_or_default()) + }) + .clone() + } +} + +/// A solved sketch in the evaluated model. +#[derive(Clone, Debug)] +pub struct SolvedSketch { + pub feature: u64, + pub name: String, + pub plane: Plane, + pub sketch: solvecraft_sketch::Sketch, + pub report: SolveReport, + pub profiles: Vec, +} + +/// Model after some prefix of the timeline. +#[derive(Clone, Debug, Default)] +pub struct ModelState { + pub bodies: Vec, + pub sketches: Vec, + pub body_counter: usize, +} + +impl ModelState { + pub fn body(&self, name: &str) -> Option<&ModelBody> { + self.bodies.iter().find(|b| b.name == name) + } + pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> { + self.sketches.iter().find(|s| s.feature == feature) + } +} + +/// Evaluation result of one feature. +#[derive(Clone, Debug, Serialize)] +pub struct FeatureResult { + pub id: u64, + pub name: String, + pub error: Option, + pub warning: Option, + pub ms: f64, + pub skipped: bool, + #[serde(skip)] + fingerprint: u64, + #[serde(skip)] + pub state: Arc, +} + +/// Incremental evaluator: holds the state after every feature. +#[derive(Clone, Debug, Default)] +pub struct Model { + pub results: Vec, + pub param_errors: BTreeMap, + empty: Arc, + /// Features recomputed by the last `evaluate`. + pub last_recomputed: usize, +} + +fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + prev.hash(&mut h); + rolled_back.hash(&mut h); + serde_json::to_string(f).unwrap_or_default().hash(&mut h); + let mut names: Vec = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect(); + if let FeatureKind::Sketch { sketch, .. } = &f.kind { + names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone())); + } + names.sort(); + names.dedup(); + for n in names { + n.hash(&mut h); + match vals.get(&n) { + Some(v) => { + v.v.to_bits().hash(&mut h); + v.len.hash(&mut h); + v.ang.hash(&mut h); + } + None => 0u8.hash(&mut h), + } + } + h.finish() +} + +fn now() -> std::time::Instant { + std::time::Instant::now() +} + +impl Model { + pub fn new() -> Self { + Model::default() + } + + /// The final model state. + pub fn state(&self) -> Arc { + self.results.iter().rev().find(|r| !r.skipped).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone()) + } + + /// State just before the feature with this id (for editing a sketch in context). + pub fn state_before(&self, id: u64) -> Arc { + let Some(i) = self.results.iter().position(|r| r.id == id) else { return self.state() }; + self.results.get(..i).and_then(|s| s.iter().rev().find(|r| !r.skipped)).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone()) + } + + pub fn result(&self, id: u64) -> Option<&FeatureResult> { + self.results.iter().find(|r| r.id == id) + } + + /// Re-evaluate the document, reusing results up to the first changed feature. + pub fn evaluate(&mut self, doc: &Document) { + let (vals, perr) = doc.param_values(); + self.param_errors = perr; + let mut prev_fp = 0u64; + let mut state = self.empty.clone(); + let mut out: Vec = Vec::with_capacity(doc.features.len()); + let mut reuse = true; + let mut recomputed = 0; + let marker = doc.marker.unwrap_or(usize::MAX); + for (i, f) in doc.features.iter().enumerate() { + let rolled_back = i >= marker; + let fp = fingerprint(prev_fp, f, &vals, rolled_back); + prev_fp = fp; + if reuse + && let Some(old) = self.results.get(i) + && old.id == f.id + && old.fingerprint == fp + { + if !old.skipped { + state = old.state.clone(); + } + out.push(old.clone()); + continue; + } + reuse = false; + if f.suppressed || rolled_back { + out.push(FeatureResult { + id: f.id, + name: f.name.clone(), + error: None, + warning: None, + ms: 0.0, + skipped: true, + fingerprint: fp, + state: state.clone(), + }); + continue; + } + recomputed += 1; + let t0 = now(); + let mut next = (*state).clone(); + let mut warning = None; + let r = kernel::guard(&f.name, || { + eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string())) + }); + let ms = t0.elapsed().as_secs_f64() * 1000.0; + let error = match r { + Ok(()) => { + state = Arc::new(next); + None + } + Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()), + }; + out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() }); + } + self.results = out; + self.last_recomputed = recomputed; + } +} + +fn val(vals: &BTreeMap, e: &str, k: Kind) -> Result { + Document::eval_in(vals, e, k) +} + +fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result> { + let ps = &ss.profiles; + if ps.is_empty() { + return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name))); + } + let out: Vec = match sel { + ProfileSel::All => ps.iter().map(|p| p.region.clone()).collect(), + ProfileSel::Indices { indices } => { + let mut v = Vec::new(); + for i in indices { + v.push(ps.get(*i).ok_or_else(|| DocError::Unknown(format!("profile {i} of {}", ss.name)))?.region.clone()); + } + v + } + ProfileSel::Curves { loops } => { + let mut v = Vec::new(); + for l in loops { + let mut want: Vec<&String> = l.iter().collect(); + want.sort(); + let found = ps.iter().find(|p| { + let mut have: Vec<&String> = p.outer_curves.iter().collect(); + have.sort(); + have == want + }); + v.push(found.ok_or_else(|| DocError::Unknown(format!("profile bounded by {l:?} in {}", ss.name)))?.region.clone()); + } + v + } + ProfileSel::Points { points } => { + let mut v = Vec::new(); + for p in points { + // Innermost profile containing the point. + let found = ps.iter().filter(|q| q.region.contains(*p)).min_by(|a, b| a.area.total_cmp(&b.area)); + v.push(found.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [p.x, p.y], ss.name)))?.region.clone()); + } + v + } + }; + if out.is_empty() { + return Err(DocError::Invalid("no profile selected".into())); + } + Ok(out) +} + +fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String { + if let Some(n) = f.body_names.get(k) + && !n.is_empty() + && state.body(n).is_none() + { + return n.clone(); + } + loop { + state.body_counter += 1; + let n = format!("Body{}", state.body_counter); + if state.body(&n).is_none() { + return n; + } + } +} + +/// Add tool bodies to the model according to the operation. +fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String]) -> Result<()> { + let target_idx: Vec = if targets.is_empty() { + (0..state.bodies.len()).collect() + } else { + let mut v = Vec::new(); + for t in targets { + v.push(state.bodies.iter().position(|b| &b.name == t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?); + } + v + }; + if op == Operation::NewBody || target_idx.is_empty() { + if op == Operation::Cut || op == Operation::Intersect { + return Err(DocError::Invalid("there is no body to cut or intersect".into())); + } + for (k, b) in tools.into_iter().enumerate() { + let name = new_name(state, f, k); + state.bodies.push(ModelBody::new(name, b, f.id)); + } + return Ok(()); + } + match op { + Operation::Join => { + let Some(&first) = target_idx.first() else { return Ok(()) }; + let Some(mut acc) = state.bodies.get(first).map(|b| b.body.clone()) else { return Ok(()) }; + for t in &tools { + acc = kernel::boolean(&acc, t, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?; + } + if let Some(mb) = state.bodies.get_mut(first) { + *mb = ModelBody::new(mb.name.clone(), acc, mb.feature); + } + } + Operation::Cut | Operation::Intersect => { + let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect }; + let mut remove = Vec::new(); + for &ti in &target_idx { + let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue }; + for t in &tools { + acc = match acc { + Some(a) => kernel::boolean(&a, t, bop)?, + None => None, + }; + } + match acc { + Some(b) => { + if let Some(mb) = state.bodies.get_mut(ti) { + *mb = ModelBody::new(mb.name.clone(), b, mb.feature); + } + } + None => remove.push(ti), + } + } + let mut i = 0; + state.bodies.retain(|_| { + let keep = !remove.contains(&i); + i += 1; + keep + }); + } + Operation::NewBody => {} + } + Ok(()) +} + +/// Sample points inside a region (sketch coordinates). +fn region_samples(r: &Region2) -> Vec { + let mut out = vec![r.interior_point()]; + let pts = r.outer.polyline(1e-2); + let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)); + for p in &pts { + 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)); + } + for i in 0..5 { + for j in 0..5 { + let p = Vec2::new(lo.x + (hi.x - lo.x) * (0.1 + 0.2 * i as f64), lo.y + (hi.y - lo.y) * (0.1 + 0.2 * j as f64)); + if r.contains(p) { + out.push(p); + } + } + } + out +} + +/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss +/// starting on the face it joins). Extending the tool past such an end changes nothing when the +/// space beyond is empty (cut) or already material (join), so do exactly that. +fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) { + if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() { + return (lo, hi); + } + let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect(); + if bodies.is_empty() { + return (lo, hi); + } + let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); + let delta = (size * 0.01).max(1e-3).min((hi - lo).abs().max(1e-3)); + let meshes: Vec> = bodies.iter().map(|b| b.mesh()).collect(); + let samples = region_samples(r); + let n = plane.normal(); + let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + let ok_beyond = |h: f64, dir: f64| { + samples.iter().all(|s| { + let base = plane.to_world(*s); + [0.5, 1.0].iter().all(|k| { + let p = base + n * (h + dir * delta * k); + let inside = inside_any(p); + if op == Operation::Cut { !inside } else { inside } + }) + }) + }; + let lo2 = if ok_beyond(lo, -1.0) { lo - delta } else { lo }; + let hi2 = if ok_beyond(hi, 1.0) { hi + delta } else { hi }; + (lo2, hi2) +} + +/// The body a fillet/chamfer applies to: by name, or the one closest to the first edge point. +fn blend_target(state: &ModelState, body: &Option, edges: &[Vec3]) -> Result { + if let Some(n) = body { + return state.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`"))); + } + let p = edges.first().ok_or_else(|| DocError::Invalid("no edges selected".into()))?; + let mut best: Option<(usize, f64)> = None; + for (i, b) in state.bodies.iter().enumerate() { + let tol = (b.body.size() * 2e-3).max(1e-3); + if let Ok(Some((_, d))) = b.body.nearest_edge(*p, tol) + && best.is_none_or(|(_, bd)| d < bd) + { + best = Some((i, d)); + } + } + best.map(|(i, _)| i).ok_or_else(|| DocError::Invalid("there is no body to modify".into())) +} + +fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> { + match axis { + AxisRef::SketchLine { curve } => { + let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("sketch line `{curve}`")))?; + match ss.sketch.curves.get(ci).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => { + let (pa, pb) = (ss.sketch.point(*a).unwrap_or_default(), ss.sketch.point(*b).unwrap_or_default()); + Ok((pa, pb - pa)) + } + _ => Err(DocError::Invalid(format!("`{curve}` is not a line"))), + } + } + AxisRef::SketchAxis { axis } => match axis.to_ascii_lowercase().as_str() { + "x" => Ok((Vec2::ZERO, Vec2::X)), + "y" => Ok((Vec2::ZERO, Vec2::Y)), + other => Err(DocError::Unknown(format!("sketch axis `{other}`"))), + }, + AxisRef::World { axis } => { + let d = match axis.to_ascii_uppercase().as_str() { + "X" => Vec3::X, + "Y" => Vec3::Y, + "Z" => Vec3::Z, + other => return Err(DocError::Unknown(format!("axis `{other}`"))), + }; + let pl = &ss.plane; + if d.dot(pl.normal()).abs() > 1e-9 || pl.height(Vec3::ZERO).abs() > 1e-9 { + return Err(DocError::Invalid(format!("the {axis} axis does not lie in the sketch plane"))); + } + Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y)))) + } + } +} + +fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { + match &f.kind { + FeatureKind::Sketch { plane, sketch } => { + let plane = doc.resolve_plane(vals, plane, 0)?; + let mut sk = sketch.clone(); + doc.apply_dimension_values(vals, &mut sk)?; + let report = solve(&mut sk); + if !report.ok() { + *warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", "))); + } + let profiles = find_profiles(&sk); + st.sketches.retain(|s| s.feature != f.id); + st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }); + Ok(()) + } + FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); + let regions = select_profiles(&ss, profiles)?; + let d = val(vals, &extent.distance, Kind::Length)?; + let off = match &extent.start_offset { + Some(e) => val(vals, e, Kind::Length)?, + None => 0.0, + }; + let (a, b) = match (&extent.distance2, extent.direction) { + (Some(d2), _) => (-val(vals, d2, Kind::Length)?, d), + (None, crate::Direction::Positive) => (0.0, d), + (None, crate::Direction::Negative) => (-d, 0.0), + (None, crate::Direction::Symmetric) => (-d, d), + }; + // A negative distance flips the side. + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let mut tools = Vec::new(); + for r in ®ions { + let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets); + tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(r), l2, h2)?); + } + apply_op(st, f, tools, *operation, targets) + } + FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); + let regions = select_profiles(&ss, profiles)?; + let (o, d) = revolve_axis(&ss, axis)?; + let ang = val(vals, angle, Kind::Angle)?; + let tools = kernel::revolve(&ss.plane, ®ions, o, d, ang)?; + apply_op(st, f, tools, *operation, targets) + } + FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body } => { + let r = val(vals, radius, Kind::Length)?; + let ti = blend_target(st, body, edges)?; + let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) }; + let nb = + if matches!(f.kind, FeatureKind::Fillet { .. }) { kernel::fillet(&mb.body, edges, r)? } else { kernel::chamfer(&mb.body, edges, r)? }; + let (name, feat) = (mb.name.clone(), mb.feature); + if let Some(slot) = st.bodies.get_mut(ti) { + *slot = ModelBody::new(name, nb, feat); + } + Ok(()) + } + FeatureKind::Box { corner, length, width, height, operation } => { + let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); + let b = kernel::box_solid(*corner, *corner + s)?; + apply_op(st, f, vec![b], *operation, &[]) + } + FeatureKind::Cylinder { base, axis, radius, height, operation } => { + let b = kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?; + apply_op(st, f, vec![b], *operation, &[]) + } + FeatureKind::Sphere { center, radius, operation } => { + let b = kernel::sphere(*center, val(vals, radius, Kind::Length)?)?; + apply_op(st, f, vec![b], *operation, &[]) + } + FeatureKind::Torus { center, major, minor, operation } => { + let b = kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?; + apply_op(st, f, vec![b], *operation, &[]) + } + FeatureKind::Combine { target, tools, operation, keep_tools } => { + if tools.iter().any(|t| t == target) { + return Err(DocError::Invalid("a body cannot be combined with itself".into())); + } + let mut tb = Vec::new(); + for t in tools { + tb.push(st.body(t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?.body.clone()); + } + if st.body(target).is_none() { + return Err(DocError::Unknown(format!("body `{target}`"))); + } + let op = if *operation == Operation::NewBody { Operation::Join } else { *operation }; + apply_op(st, f, tb, op, std::slice::from_ref(target))?; + if !keep_tools { + st.bodies.retain(|b| !tools.contains(&b.name)); + } + Ok(()) + } + FeatureKind::Move { bodies, translate, rotate_axis, angle } => { + let t = + Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?); + let ang = match angle { + Some(a) => val(vals, a, Kind::Angle)?, + None => 0.0, + }; + let axis = rotate_axis.unwrap_or(Vec3::Z); + for n in bodies { + let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; + if let Some(mb) = st.bodies.get_mut(i) { + let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?; + *mb = ModelBody::new(mb.name.clone(), nb, mb.feature); + } + } + Ok(()) + } + } +} diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs new file mode 100644 index 00000000..cb4a72e2 --- /dev/null +++ b/crates/doc/src/expr.rs @@ -0,0 +1,506 @@ +//! Parameter expressions: `2 * width + 5 mm`, `angle / 2`, `sqrt(a^2 + b^2)`. +//! +//! Values carry a simple dimension (powers of length and angle). Lengths are millimetres and +//! angles radians internally. A unit-less number added to a dimensioned value takes that value's +//! unit (`width + 5` adds 5 mm); a unit-less final result takes the expected unit's default +//! (mm for lengths, degrees for angles). Trigonometric functions take an angle; a unit-less +//! argument is read as degrees. + +use std::collections::BTreeMap; + +use crate::DocError; + +const MAX_LEN: usize = 4096; +const MAX_DEPTH: usize = 64; + +/// What an expression must evaluate to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Length, + Angle, + Unitless, +} + +impl Kind { + pub fn from_unit(u: &str) -> Kind { + match u.trim() { + "deg" | "rad" | "°" => Kind::Angle, + "" | "none" | "unitless" => Kind::Unitless, + _ => Kind::Length, + } + } +} + +/// A dimensioned value. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Value { + pub v: f64, + pub len: i8, + pub ang: i8, +} + +impl Value { + pub fn num(v: f64) -> Value { + Value { v, len: 0, ang: 0 } + } + pub fn length(mm: f64) -> Value { + Value { v: mm, len: 1, ang: 0 } + } + pub fn angle(rad: f64) -> Value { + Value { v: rad, len: 0, ang: 1 } + } + fn unitless(&self) -> bool { + self.len == 0 && self.ang == 0 + } + /// Convert to the expected kind (mm or radians). + pub fn to_kind(self, k: Kind) -> Result { + let out = match k { + Kind::Length if self.len == 1 && self.ang == 0 => self.v, + Kind::Length if self.unitless() => self.v, + Kind::Angle if self.ang == 1 && self.len == 0 => self.v, + Kind::Angle if self.unitless() => self.v.to_radians(), + Kind::Unitless if self.unitless() => self.v, + _ => return Err(DocError::Expr(format!("wrong units for a {k:?} value"))), + }; + if out.is_finite() { Ok(out) } else { Err(DocError::Expr("result is not a finite number".into())) } + } +} + +fn unit(name: &str) -> Option { + Some(match name { + "mm" => Value::length(1.0), + "cm" => Value::length(10.0), + "m" => Value::length(1000.0), + "um" => Value::length(0.001), + "in" => Value::length(25.4), + "ft" => Value::length(304.8), + "deg" => Value::angle(std::f64::consts::PI / 180.0), + "rad" => Value::angle(1.0), + _ => return None, + }) +} + +#[derive(Clone, Debug, PartialEq)] +enum Tok { + Num(f64), + Id(String), + Op(char), +} + +fn lex(s: &str) -> Result, DocError> { + if s.len() > MAX_LEN { + return Err(DocError::Expr("expression too long".into())); + } + let cs: Vec = s.chars().collect(); + let mut i = 0; + let mut out = Vec::new(); + while let Some(&c) = cs.get(i) { + if c.is_whitespace() { + i += 1; + } else if c.is_ascii_digit() || c == '.' { + let st = i; + while cs.get(i).is_some_and(|c| c.is_ascii_digit() || *c == '.') { + i += 1; + } + // Exponent: 1e-3 + if cs.get(i).is_some_and(|c| *c == 'e' || *c == 'E') && cs.get(i + 1).is_some_and(|c| c.is_ascii_digit() || *c == '-' || *c == '+') { + i += 2; + while cs.get(i).is_some_and(char::is_ascii_digit) { + i += 1; + } + } + let t: String = cs.get(st..i).map(|s| s.iter().collect()).unwrap_or_default(); + out.push(Tok::Num(t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?)); + } else if c.is_alphabetic() || c == '_' { + let st = i; + while cs.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') { + i += 1; + } + out.push(Tok::Id(cs.get(st..i).map(|s| s.iter().collect()).unwrap_or_default())); + } else if c == '°' { + out.push(Tok::Id("deg".into())); + i += 1; + } else if "+-*/^(),".contains(c) { + out.push(Tok::Op(c)); + i += 1; + } else { + return Err(DocError::Expr(format!("unexpected `{c}`"))); + } + } + Ok(out) +} + +/// Identifiers an expression refers to (excluding units and functions). +pub fn references(s: &str) -> Vec { + let Ok(toks) = lex(s) else { return Vec::new() }; + let mut out: Vec = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if let Tok::Id(id) = t { + let is_call = matches!(toks.get(i + 1), Some(Tok::Op('('))); + if !is_call && unit(id).is_none() && id != "pi" && id != "PI" && !out.contains(id) { + out.push(id.clone()); + } + } + } + out +} + +struct Parser<'a> { + toks: Vec, + pos: usize, + lookup: &'a dyn Fn(&str) -> Result, + depth: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option<&Tok> { + self.toks.get(self.pos) + } + fn next(&mut self) -> Option { + let t = self.toks.get(self.pos).cloned(); + self.pos += 1; + t + } + fn enter(&mut self) -> Result<(), DocError> { + self.depth += 1; + if self.depth > MAX_DEPTH { Err(DocError::Expr("expression nested too deeply".into())) } else { Ok(()) } + } + fn expr(&mut self) -> Result { + self.enter()?; + let mut a = self.term()?; + while let Some(Tok::Op(c @ ('+' | '-'))) = self.peek().cloned() { + self.pos += 1; + let b = self.term()?; + a = add(a, b, c == '-')?; + } + self.depth -= 1; + Ok(a) + } + fn term(&mut self) -> Result { + let mut a = self.unary()?; + loop { + match self.peek().cloned() { + Some(Tok::Op('*')) => { + self.pos += 1; + let b = self.unary()?; + a = mul(a, b, false); + } + Some(Tok::Op('/')) => { + self.pos += 1; + let b = self.unary()?; + a = mul(a, b, true); + } + // Juxtaposition: `10 mm`, `2 width`. + Some(Tok::Id(_) | Tok::Num(_)) | Some(Tok::Op('(')) => { + let b = self.unary()?; + a = mul(a, b, false); + } + _ => return Ok(a), + } + } + } + fn unary(&mut self) -> Result { + match self.peek() { + Some(Tok::Op('-')) => { + self.pos += 1; + self.enter()?; + let v = self.unary()?; + self.depth -= 1; + Ok(Value { v: -v.v, ..v }) + } + Some(Tok::Op('+')) => { + self.pos += 1; + self.unary() + } + _ => self.power(), + } + } + fn power(&mut self) -> Result { + let base = self.atom()?; + if let Some(Tok::Op('^')) = self.peek() { + self.pos += 1; + self.enter()?; + let e = self.unary()?; + self.depth -= 1; + if !e.unitless() { + return Err(DocError::Expr("exponent must be unit-less".into())); + } + let n = e.v; + let k = if (n - n.round()).abs() < 1e-12 && n.abs() < 16.0 { n.round() as i8 } else { 0 }; + if !base.unitless() && k == 0 { + return Err(DocError::Expr("dimensioned values need a small integer exponent".into())); + } + return Ok(Value { v: base.v.powf(n), len: base.len.saturating_mul(k), ang: base.ang.saturating_mul(k) }); + } + Ok(base) + } + fn atom(&mut self) -> Result { + match self.next() { + Some(Tok::Num(v)) => Ok(Value::num(v)), + Some(Tok::Op('(')) => { + let v = self.expr()?; + match self.next() { + Some(Tok::Op(')')) => Ok(v), + _ => Err(DocError::Expr("missing `)`".into())), + } + } + Some(Tok::Id(id)) => { + if let Some(Tok::Op('(')) = self.peek() { + self.pos += 1; + let mut args = Vec::new(); + if !matches!(self.peek(), Some(Tok::Op(')'))) { + loop { + args.push(self.expr()?); + match self.next() { + Some(Tok::Op(',')) => continue, + Some(Tok::Op(')')) => break, + _ => return Err(DocError::Expr("bad function arguments".into())), + } + } + } else { + self.pos += 1; + } + return call(&id, &args); + } + if id == "pi" || id == "PI" { + return Ok(Value::num(std::f64::consts::PI)); + } + if let Some(u) = unit(&id) { + return Ok(u); + } + (self.lookup)(&id) + } + Some(Tok::Op(c)) => Err(DocError::Expr(format!("unexpected `{c}`"))), + None => Err(DocError::Expr("unexpected end of expression".into())), + } + } +} + +fn add(a: Value, b: Value, sub: bool) -> Result { + let (len, ang) = if a.unitless() { + (b.len, b.ang) + } else if b.unitless() || (a.len == b.len && a.ang == b.ang) { + (a.len, a.ang) + } else { + return Err(DocError::Expr("cannot add values with different units".into())); + }; + // A unit-less operand next to an angle is in degrees. + let conv = |x: Value| if x.unitless() && ang == 1 && len == 0 { x.v.to_radians() } else { x.v }; + let (x, y) = (conv(a), conv(b)); + Ok(Value { v: if sub { x - y } else { x + y }, len, ang }) +} + +fn mul(a: Value, b: Value, div: bool) -> Value { + if div { + Value { v: a.v / b.v, len: a.len.saturating_sub(b.len), ang: a.ang.saturating_sub(b.ang) } + } else { + Value { v: a.v * b.v, len: a.len.saturating_add(b.len), ang: a.ang.saturating_add(b.ang) } + } +} + +fn call(f: &str, args: &[Value]) -> Result { + let one = || match args { + [a] => Ok(*a), + _ => Err(DocError::Expr(format!("`{f}` takes one argument"))), + }; + let rad = |a: Value| { + if a.ang == 1 && a.len == 0 { + Ok(a.v) + } else if a.unitless() { + Ok(a.v.to_radians()) + } else { + Err(DocError::Expr(format!("`{f}` needs an angle"))) + } + }; + let num = |a: Value| if a.unitless() { Ok(a.v) } else { Err(DocError::Expr(format!("`{f}` needs a unit-less value"))) }; + Ok(match f { + "sin" => Value::num(rad(one()?)?.sin()), + "cos" => Value::num(rad(one()?)?.cos()), + "tan" => Value::num(rad(one()?)?.tan()), + "asin" => Value::angle(num(one()?)?.asin()), + "acos" => Value::angle(num(one()?)?.acos()), + "atan" => Value::angle(num(one()?)?.atan()), + "sqrt" => { + let a = one()?; + if a.len % 2 != 0 || a.ang % 2 != 0 { + return Err(DocError::Expr("sqrt of an odd power of a unit".into())); + } + Value { v: a.v.sqrt(), len: a.len / 2, ang: a.ang / 2 } + } + "abs" => { + let a = one()?; + Value { v: a.v.abs(), ..a } + } + "floor" | "ceil" | "round" => { + let a = one()?; + let v = match f { + "floor" => a.v.floor(), + "ceil" => a.v.ceil(), + _ => a.v.round(), + }; + Value { v, ..a } + } + "min" | "max" => { + let Some(first) = args.first().copied() else { return Err(DocError::Expr(format!("`{f}` needs arguments"))) }; + let mut acc = first; + for a in args.iter().skip(1) { + let d = add(*a, Value { v: 0.0, ..acc }, false)?; + if (f == "min" && d.v < acc.v) || (f == "max" && d.v > acc.v) { + acc = d; + } + } + acc + } + _ => return Err(DocError::Expr(format!("unknown function `{f}`"))), + }) +} + +/// Evaluate an expression; `lookup` resolves parameter names. +pub fn eval_with(s: &str, lookup: &dyn Fn(&str) -> Result) -> Result { + let toks = lex(s)?; + if toks.is_empty() { + return Err(DocError::Expr("empty expression".into())); + } + let mut p = Parser { toks, pos: 0, lookup, depth: 0 }; + let v = p.expr()?; + if p.pos < p.toks.len() { + return Err(DocError::Expr(format!("unexpected input in `{s}`"))); + } + if !v.v.is_finite() { + return Err(DocError::Expr(format!("`{s}` is not a finite number"))); + } + Ok(v) +} + +/// Evaluate all parameters (name → (expression, kind)) in dependency order. +pub fn eval_params(params: &[(String, String, Kind)]) -> (BTreeMap, BTreeMap) { + let mut done: BTreeMap = BTreeMap::new(); + let mut errors: BTreeMap = BTreeMap::new(); + fn visit( + name: &str, + params: &[(String, String, Kind)], + done: &mut BTreeMap, + errors: &mut BTreeMap, + stack: &mut Vec, + ) -> Result { + if let Some(v) = done.get(name) { + return Ok(*v); + } + if let Some(e) = errors.get(name) { + return Err(DocError::Expr(e.clone())); + } + let Some((_, expr, kind)) = params.iter().find(|(n, _, _)| n == name) else { + return Err(DocError::Expr(format!("unknown parameter `{name}`"))); + }; + if stack.iter().any(|s| s == name) || stack.len() > 256 { + return Err(DocError::Expr(format!("circular reference through `{name}`"))); + } + stack.push(name.to_string()); + let r = { + let deps = references(expr); + let mut vals: BTreeMap = BTreeMap::new(); + let mut err = None; + for d in deps { + match visit(&d, params, done, errors, stack) { + Ok(v) => { + vals.insert(d, v); + } + Err(e) => { + err = Some(e); + break; + } + } + } + match err { + Some(e) => Err(e), + None => eval_with(expr, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")))).and_then(|v| { + let x = v.to_kind(*kind)?; + Ok(match kind { + Kind::Length => Value::length(x), + Kind::Angle => Value::angle(x), + Kind::Unitless => Value::num(x), + }) + }), + } + }; + stack.pop(); + match &r { + Ok(v) => { + done.insert(name.to_string(), *v); + } + Err(e) => { + errors.insert(name.to_string(), e.to_string()); + } + } + r + } + for (n, _, _) in params { + let mut stack = Vec::new(); + let _ = visit(n, params, &mut done, &mut errors, &mut stack); + } + (done, errors) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(s: &str, k: Kind) -> Result { + let look = |n: &str| match n { + "width" => Ok(Value::length(40.0)), + "ang" => Ok(Value::angle(std::f64::consts::FRAC_PI_2)), + "count" => Ok(Value::num(3.0)), + _ => Err(DocError::Expr(format!("unknown {n}"))), + }; + eval_with(s, &look)?.to_kind(k) + } + + #[test] + fn arithmetic_and_units() { + assert_eq!(ev("10", Kind::Length).unwrap(), 10.0); + assert!((ev("90 deg", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + assert_eq!(ev("10 mm", Kind::Length).unwrap(), 10.0); + assert_eq!(ev("1 in", Kind::Length).unwrap(), 25.4); + assert_eq!(ev("2 cm + 5", Kind::Length).unwrap(), 25.0); + assert_eq!(ev("width / 2 + 1", Kind::Length).unwrap(), 21.0); + assert_eq!(ev("width * count", Kind::Length).unwrap(), 120.0); + assert_eq!(ev("-(2+3)*2^2", Kind::Unitless).unwrap(), -20.0); + assert!((ev("45", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12); + assert!((ev("ang / 2", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12); + assert!((ev("ang + 90", Kind::Angle).unwrap() - std::f64::consts::PI).abs() < 1e-12); + assert!((ev("sqrt((3 mm) ^ 2 + (4 mm) ^ 2)", Kind::Length).unwrap() - 5.0).abs() < 1e-12); + assert!((ev("sin(30)", Kind::Unitless).unwrap() - 0.5).abs() < 1e-12); + assert!((ev("2*pi", Kind::Unitless).unwrap() - std::f64::consts::TAU).abs() < 1e-12); + assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0); + assert_eq!(ev("1e-3 m", Kind::Length).unwrap(), 1.0); + } + + #[test] + fn errors() { + assert!(ev("width + ang", Kind::Length).is_err()); + assert!(ev("width * width", Kind::Length).is_err()); + assert!(ev("nope", Kind::Length).is_err()); + assert!(ev("(1", Kind::Length).is_err()); + assert!(ev("1 / 0", Kind::Length).is_err()); + assert!(ev("", Kind::Length).is_err()); + assert!(ev("1 $ 2", Kind::Length).is_err()); + let deep = "(".repeat(500) + "1" + &")".repeat(500); + assert!(ev(&deep, Kind::Length).is_err()); + assert!(ev(&"-".repeat(500), Kind::Length).is_err()); + } + + #[test] + fn params_in_order_with_cycles() { + let p = vec![ + ("b".to_string(), "a * 2".to_string(), Kind::Length), + ("a".to_string(), "10 mm".to_string(), Kind::Length), + ("x".to_string(), "y".to_string(), Kind::Length), + ("y".to_string(), "x + 1".to_string(), Kind::Length), + ]; + let (v, e) = eval_params(&p); + assert_eq!(v.get("b").unwrap().v, 20.0); + assert!(e.contains_key("x") && e.contains_key("y")); + assert_eq!(references("2*width + sin(ang) + 3 mm"), vec!["width".to_string(), "ang".to_string()]); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8b137891..c9a6b936 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1 +1,34 @@ +//! The SolveCraft document: user and model parameters, and a parametric timeline of features +//! (sketches, extrudes, revolves, fillets, primitives, combines…). Evaluating the timeline with +//! the kernel produces the model (bodies and solved sketches). Evaluation is incremental: a +//! feature is recomputed only when it, a parameter it uses, or an earlier feature changed. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] +mod document; +mod eval; +pub mod expr; + +pub use document::*; +pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch}; +pub use solvecraft_kernel as kernel; +pub use solvecraft_sketch as sketch; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum DocError { + #[error("expression: {0}")] + Expr(String), + #[error("{0}")] + Invalid(String), + #[error("unknown {0}")] + Unknown(String), + #[error(transparent)] + Sketch(#[from] solvecraft_sketch::SketchError), + #[error(transparent)] + Kernel(#[from] solvecraft_kernel::KernelError), +} + +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs new file mode 100644 index 00000000..91725947 --- /dev/null +++ b/crates/doc/src/tests.rs @@ -0,0 +1,183 @@ +use solvecraft_geom::{Vec2, Vec3}; +use solvecraft_kernel::measure; +use solvecraft_sketch::{ConstraintKind, Sketch}; + +use crate::*; + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +/// 40 x 30 rectangle with its width and height driven by `width` / d2. +fn plate_doc() -> (Document, u64) { + let mut doc = Document::new("plate"); + doc.set_param("width", "40 mm", Some("mm"), None).unwrap(); + let mut sk = Sketch::new(); + let pts = [Vec2::new(0.0, 0.0), Vec2::new(38.0, 0.5), Vec2::new(39.0, 31.0), Vec2::new(0.5, 29.0)]; + let ids: Vec = pts.iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); + let l: Vec = (0..4).map(|i| sk.add_line_pts(ids[i], ids[(i + 1) % 4], None).unwrap()).collect(); + use ConstraintKind::*; + sk.add_constraint(Coincident { p: ids[0], q: 0 }, None).unwrap(); + 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 d1 = doc.new_model_param("width", "mm"); + sk.add_constraint(Length { l: l[0], value: 40.0 }, Some(d1)).unwrap(); + let d2 = doc.new_model_param("30 mm", "mm"); + sk.add_constraint(Length { l: l[1], value: 30.0 }, Some(d2)).unwrap(); + let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap(); + doc.add_feature( + FeatureKind::Extrude { + sketch: s, + profiles: ProfileSel::All, + extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None }, + operation: Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + (doc, s) +} + +#[test] +fn parametric_plate_reevaluates() { + let (mut doc, _) = plate_doc(); + let mut m = Model::new(); + m.evaluate(&doc); + assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results); + let st = m.state(); + assert_eq!(st.bodies.len(), 1); + assert_eq!(st.bodies[0].name, "Body1"); + assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 24000.0) < 1e-9); + assert!(st.sketches[0].report.fully_constrained()); + + // No change: nothing recomputed. + m.evaluate(&doc); + assert_eq!(m.last_recomputed, 0); + + doc.set_param("width", "50 mm", None, None).unwrap(); + m.evaluate(&doc); + assert_eq!(m.last_recomputed, 2); + assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9); + + // Changing only the extrude distance re-evaluates only the extrude. + if let Some(FeatureKind::Extrude { extent, .. }) = doc.features.get_mut(1).map(|f| &mut f.kind) { + extent.distance = "width / 5".into(); + } + m.evaluate(&doc); + assert_eq!(m.last_recomputed, 1); + assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9); +} + +#[test] +fn box_fillet_cut_timeline() { + let (mut doc, _) = plate_doc(); + doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None }, None).unwrap(); + let mut sk = Sketch::new(); + sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap(); + let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap(); + doc.add_feature( + FeatureKind::Extrude { + sketch: s2, + profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] }, + extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None }, + operation: Operation::Cut, + targets: vec![], + }, + None, + ) + .unwrap(); + let mut m = Model::new(); + m.evaluate(&doc); + assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::>()); + let st = m.state(); + let v = measure(&st.bodies[0].body).unwrap().volume; + let expect = 24000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 20.0 - std::f64::consts::PI * 25.0 * 20.0; + assert!(rel(v, expect) < 1e-3, "{v} vs {expect}"); + // Roll back to before the fillet. + doc.marker = Some(2); + m.evaluate(&doc); + assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9); + assert!(m.results[3].skipped); +} + +#[test] +fn errors_are_reported_not_fatal() { + let (mut doc, s) = plate_doc(); + doc.add_feature( + FeatureKind::Revolve { + sketch: s, + profiles: ProfileSel::All, + axis: AxisRef::SketchAxis { axis: "y".into() }, + angle: "90 deg".into(), + operation: Operation::NewBody, + targets: vec![], + }, + None, + ) + .unwrap(); + doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None }, None).unwrap(); + doc.add_feature( + FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody }, + None, + ) + .unwrap(); + let mut m = Model::new(); + m.evaluate(&doc); + assert!(m.results[2].error.is_none(), "{:?}", m.results[2]); + assert!(m.results[3].error.is_some()); + assert!(m.results[4].error.as_deref().unwrap_or("").contains("nope")); + assert_eq!(m.state().bodies.len(), 2); + let quarter = measure(&m.state().bodies[1].body).unwrap().volume; + // Revolving the 40x30 rectangle (x in [0,40]) 90° about sketch y: π/4 · 40² · 30. + assert!(rel(quarter, std::f64::consts::FRAC_PI_4 * 1600.0 * 30.0) < 1e-3, "{quarter}"); +} + +#[test] +fn params_and_serialization() { + let (mut doc, _) = plate_doc(); + assert!(doc.set_param("bad name", "1", None, None).is_err()); + assert!(doc.set_param("mm", "1", None, None).is_err()); + assert!(doc.set_param("loop", "loop + 1", None, None).is_err()); + assert!(doc.remove_param("width").is_err(), "used by d1"); + doc.set_param("spare", "3", None, None).unwrap(); + doc.remove_param("spare").unwrap(); + let json = doc.to_json(); + let back = Document::from_json(&json).unwrap(); + assert_eq!(back, doc); + assert!(Document::from_json("{").is_err()); + let gone = doc.delete_feature(doc.features[0].id).unwrap(); + assert_eq!(gone.len(), 2, "the extrude goes with its sketch"); + assert!(doc.param("d1").is_none(), "unused model parameters are pruned"); +} + +#[test] +fn primitives_and_combine() { + let mut doc = Document::new("p"); + doc.add_feature( + FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody }, + None, + ) + .unwrap(); + doc.add_feature( + FeatureKind::Cylinder { + base: Vec3::new(5.0, 5.0, 0.0), + axis: Vec3::Z, + radius: "2".into(), + height: "20".into(), + operation: Operation::NewBody, + }, + None, + ) + .unwrap(); + doc.add_feature(FeatureKind::Combine { target: "Body1".into(), tools: vec!["Body2".into()], operation: Operation::Cut, keep_tools: false }, None) + .unwrap(); + let mut m = Model::new(); + m.evaluate(&doc); + assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results); + let st = m.state(); + assert_eq!(st.bodies.len(), 1); + assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 1000.0 - std::f64::consts::PI * 40.0) < 1e-3); +} diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 33dcc5aa..3e36b6d7 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -109,6 +109,41 @@ impl Mesh { self.edges.extend(o.edges.iter().cloned()); } + /// Number of triangles a ray crosses (for inside/outside tests on closed meshes). + pub fn ray_crossings(&self, o: Vec3, dir: Vec3) -> usize { + let mut n = 0; + for t in &self.triangles { + 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; + } + if e2.dot(q) * inv > 0.0 { + n += 1; + } + } + n + } + + /// Point inside a closed mesh (majority vote of three skewed rays). + pub fn contains(&self, p: Vec3) -> bool { + let dirs = [Vec3::new(0.5773, 0.5774, 0.5776), Vec3::new(-0.6123, 0.3141, 0.7254), Vec3::new(0.2718, -0.8414, 0.4673)]; + dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2 + } + /// 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; diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index da244fd1..bdb77447 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solvecraft-kernel" -description = "SolveCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API" +description = "SolveCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API (implemented with truck)" version.workspace = true edition.workspace = true license.workspace = true @@ -13,7 +13,8 @@ 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 } +truck-modeling = { workspace = true } +truck-shapeops = { workspace = true } +truck-meshalgo = { workspace = true } +truck-stepio = { workspace = true } +truck-topology = { workspace = true } diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs new file mode 100644 index 00000000..7cd4102c --- /dev/null +++ b/crates/kernel/src/blend.rs @@ -0,0 +1,307 @@ +//! Edge fillets and chamfers as a local B-rep operation. +//! +//! Supported: a straight, convex edge between two planar faces whose two end vertices each +//! touch exactly one more planar face perpendicular to the edge (the common case for edges of +//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc +//! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted. +//! The result is exact. Edges meeting at an already blended corner, curved edges and other +//! configurations are reported as not supported yet. + +use std::collections::HashMap; + +use solvecraft_geom::Vec3; +use truck_modeling::{self as mt, builder}; + +use crate::body::{Body, Solid, from_p3, p3, v3}; +use crate::{KernelError, Result, guard}; + +#[derive(Clone, Copy, PartialEq)] +enum Shape { + Round, + Flat, +} + +fn unsupported(msg: &str) -> KernelError { + KernelError::Failed(format!("not supported yet: {msg}")) +} + +fn vtx(v: &mt::Vertex) -> Vec3 { + from_p3(v.point()) +} + +/// Outward unit normal of a planar face (from its oriented surface), if planar. +fn plane_normal(f: &mt::Face) -> Option { + match f.oriented_surface() { + mt::Surface::Plane(p) => { + let n = p.normal(); + Vec3::new(n.x, n.y, n.z).normalized() + } + _ => None, + } +} + +/// Replace edges in a face's oriented boundary (by id, keeping orientation), and insert extra +/// edges where consecutive edges no longer connect. +fn rebuild_face(f: &mt::Face, subst: &HashMap, connectors: &[mt::Edge]) -> Result { + let mut wires = Vec::new(); + for w in f.boundaries() { + let mut edges: Vec = Vec::new(); + for e in w.edge_iter() { + let ne = match subst.get(&e.id()) { + Some(n) => { + // `n` was built in the orientation of the stored edge; match this use. + let stored_forward = e.front() == e.absolute_front(); + if stored_forward { n.clone() } else { n.inverse() } + } + None => e.clone(), + }; + edges.push(ne); + } + // Close gaps with connector edges. + let n = edges.len(); + let mut out: Vec = Vec::new(); + for i in 0..n { + let (Some(cur), Some(next)) = (edges.get(i), edges.get((i + 1) % n)) else { continue }; + out.push(cur.clone()); + if cur.back() != next.front() { + let c = connectors + .iter() + .find_map(|c| { + if c.front() == cur.back() && c.back() == next.front() { + Some(c.clone()) + } else if c.back() == cur.back() && c.front() == next.front() { + Some(c.inverse()) + } else { + None + } + }) + .ok_or_else(|| KernelError::Failed("blend: could not close a face boundary".into()))?; + out.push(c); + } + } + wires.push(mt::Wire::from(out)); + } + let surface = f.oriented_surface(); + mt::Face::try_new(wires, surface).map_err(|e| KernelError::Failed(format!("blend: {e}"))) +} + +fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result { + if !matches!(edge.curve(), mt::Curve::Line(_)) { + return Err(unsupported("only straight edges can be blended")); + } + let shells = solid.boundaries(); + let (si, shell) = shells + .iter() + .enumerate() + .find(|(_, sh)| sh.edge_iter().any(|e| e.id() == edge.id())) + .ok_or_else(|| KernelError::Failed("edge not found".into()))?; + let faces: Vec<&mt::Face> = shell.face_iter().collect(); + let on_edge: Vec = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.edge_iter().any(|e| e.id() == edge.id()))).collect(); + let [i1, i2] = on_edge[..] else { return Err(unsupported("the edge is not between two faces")) }; + let (Some(f1), Some(f2)) = (faces.get(i1), faces.get(i2)) else { return Err(KernelError::Failed("faces".into())) }; + let (Some(n1), Some(n2)) = (plane_normal(f1), plane_normal(f2)) else { return Err(unsupported("both faces next to the edge must be planar")) }; + let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone()); + let (p0, p1) = (vtx(&v0), vtx(&v1)); + let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?; + // In-face directions perpendicular to the edge, pointing away from it. + let mut t1 = n1.cross(d); + if t1.dot(n2) > 0.0 { + t1 = -t1; + } + let mut t2 = n2.cross(d); + if t2.dot(n1) > 0.0 { + t2 = -t2; + } + // Convex edge: the faces' outward normals point away from each other's interior. + let cos_phi = t1.dot(t2).clamp(-1.0, 1.0); + let phi = cos_phi.acos(); + if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) || n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 { + return Err(unsupported("only convex edges can be blended")); + } + blend_geometry(solid, si, &faces, (i1, i2), edge, (v0, v1), (p0, p1, d), (n1, t1, t2, phi), size, r, shape) +} + +/// Distance along each face from the edge to the blend boundary. +fn setback(r: f64, phi: f64, shape: Shape) -> f64 { + match shape { + Shape::Round => r / (phi / 2.0).tan(), + Shape::Flat => r, + } +} + +#[allow(clippy::too_many_arguments)] +fn blend_geometry( + solid: &Solid, + si: usize, + faces: &[&mt::Face], + (i1, i2): (usize, usize), + edge: &mt::Edge, + (v0, v1): (mt::Vertex, mt::Vertex), + (p0, p1, d): (Vec3, Vec3, Vec3), + (n1, t1, t2, phi): (Vec3, Vec3, Vec3, f64), + size: f64, + r: f64, + shape: Shape, +) -> Result { + let s = setback(r, phi, shape); + let tol = (size * 1e-7).max(1e-9); + // End faces and the side edges at each end. + struct End { + v: mt::Vertex, + g: usize, + e1: mt::Edge, + e2: mt::Edge, + } + let mut ends: Vec = Vec::new(); + for (v, p) in [(&v0, p0), (&v1, p1)] { + let at_v: Vec = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect(); + let others: Vec = at_v.iter().copied().filter(|i| *i != i1 && *i != i2).collect(); + let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) }; + let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?; + let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?; + if ng.dot(d).abs() < 1.0 - 1e-9 { + return Err(unsupported("the faces at the edge ends must be perpendicular to the edge")); + } + // Edges at v shared by g with f1 and with f2. + let shared = |fi: usize| -> Result { + let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; + f.edge_iter() + .find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id())) + .ok_or_else(|| unsupported("unexpected corner topology")) + }; + let (e1, e2) = (shared(i1)?, shared(i2)?); + for (e, t) in [(&e1, t1), (&e2, t2)] { + if !matches!(e.curve(), mt::Curve::Line(_)) { + return Err(unsupported("the edges at the corners must be straight")); + } + let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) }; + let along = (other - p).dot(t); + if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol { + return Err(unsupported("the blend is larger than the neighbouring faces")); + } + } + ends.push(End { v: v.clone(), g, e1, e2 }); + } + let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) }; + if end0.g == end1.g { + return Err(unsupported("both ends touch the same face")); + } + let a0 = builder::vertex(p3(p0 + t1 * s)); + let b0 = builder::vertex(p3(p0 + t2 * s)); + let a1 = builder::vertex(p3(p1 + t1 * s)); + let b1 = builder::vertex(p3(p1 + t2 * s)); + // Corner curves on the end faces (A → B) and the blend surface. + let bisector = (t1 + t2).normalized().ok_or_else(|| KernelError::Failed("bisector".into()))?; + let corner = |a: &mt::Vertex, b: &mt::Vertex, p: Vec3| -> mt::Edge { + match shape { + Shape::Round => { + // Arc centre is r inside both faces; its mid point is on the bisector, toward the edge. + let c = p + t1 * s - n1 * r; + let mid = c - bisector * r; + builder::circle_arc(a, b, p3(mid)) + } + Shape::Flat => builder::line(a, b), + } + }; + let c0 = corner(&a0, &b0, p0); + let c1 = corner(&a1, &b1, p1); + let la = builder::line(&a0, &a1); + let lb = builder::line(&b0, &b1); + + // Substitutions: side edges at the ends now stop at A/B; the blended edge moves to A or B. + let mut subst: HashMap = HashMap::new(); + for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] { + for (e, nv) in [(&end.e1, a), (&end.e2, b)] { + let (f, bk) = (e.absolute_front(), e.absolute_back()); + let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) }; + subst.insert(e.id(), ne); + } + } + let mut new_faces: Vec = Vec::with_capacity(faces.len() + 1); + for (i, f) in faces.iter().enumerate() { + let nf = if i == i1 { + let mut m = subst.clone(); + m.insert(edge.id(), la.clone()); + rebuild_face(f, &m, &[])? + } else if i == i2 { + let mut m = subst.clone(); + m.insert(edge.id(), lb.clone()); + rebuild_face(f, &m, &[])? + } else if i == end0.g { + rebuild_face(f, &subst, std::slice::from_ref(&c0))? + } else if i == end1.g { + rebuild_face(f, &subst, std::slice::from_ref(&c1))? + } else { + (*f).clone() + }; + new_faces.push(nf); + } + // The blend face uses each new edge opposite to its neighbour. + let f1_forward = faces.get(i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0))); + let wire: mt::Wire = if f1_forward { + vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into() + } else { + vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into() + }; + let blend_face = match shape { + Shape::Flat => builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("chamfer face: {e}")))?, + Shape::Round => { + let swept: mt::Face = builder::tsweep(&c0, v3(p1 - p0)); + let mut surf = swept.oriented_surface(); + // Outward normal at the arc middle points away from the arc centre. + let c = p0 + t1 * s - n1 * r; + let mid = c - bisector * r; + use mt::{ParametricSurface3D, SearchParameter}; + if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) { + let nn = surf.normal(u, v); + if Vec3::new(nn.x, nn.y, nn.z).dot(bisector) > 0.0 { + surf = mt::Invertible::inverse(&surf); + } + } + mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))? + } + }; + new_faces.push(blend_face); + let mut shells = solid.boundaries().clone(); + let new_shell: mt::Shell = new_faces.into(); + if let Some(sh) = shells.get_mut(si) { + *sh = new_shell; + } + Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}"))) +} + +fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { + if !(r.is_finite() && r > 1e-6 && r < 1e6) { + return Err(KernelError::Invalid(format!("{what} size must be positive"))); + } + if edges.is_empty() { + return Err(KernelError::Invalid("no edges selected".into())); + } + if edges.len() > 1000 { + return Err(KernelError::Invalid("too many edges".into())); + } + let mut cur = body.clone(); + for p in edges { + let size = cur.size(); + let tol = (size * 2e-3).max(1e-3); + let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) }; + if dist > size * 0.05 + 1e-3 { + return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z]))); + } + let solid = cur.deep_copy(); + let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?; + let out = guard(what, || blend_one(&solid, &edge, size, r, shape))?; + cur = Body::new(out)?; + } + 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, Shape::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, Shape::Flat, "chamfer") +} diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 835d723b..56c839e7 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -1,7 +1,7 @@ -use monstertruck_meshing::prelude::*; -use monstertruck_modeling as mt; use serde::Serialize; use solvecraft_geom::{Mesh, Vec3}; +use truck_meshalgo::prelude::*; +use truck_modeling as mt; use crate::{KernelError, Result, guard}; @@ -61,7 +61,7 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) { impl Body { pub(crate) fn new(mut solid: Solid) -> Result { - if solid.is_empty() { + if solid.boundaries().is_empty() { return Err(KernelError::Failed("empty result".into())); } // Orient outward: a closed solid must have positive volume. @@ -216,6 +216,11 @@ impl Body { Ok(best) } + /// Is the point inside the solid? + pub fn contains(&self, p: Vec3) -> bool { + self.tessellate(self.size() * 2e-3).map(|m| m.contains(p)).unwrap_or(false) + } + pub fn face_count(&self) -> usize { self.solid.face_iter().count() } diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index d31acc08..7a845b95 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1,8 +1,8 @@ //! 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 truck_modeling as mt; use crate::body::{Body, Solid, p3, v3}; use crate::{KernelError, Result, guard}; @@ -33,20 +33,12 @@ fn wire(plane: &Plane, lp: &Loop2) -> Result { 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()))? + let vm = builder::vertex(p3(plane.to_world(s.mid()))); + edges.push(builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))))); + builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75)))) } else { - builder::try_circle_arc(a, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(mid))) - .map_err(|e| KernelError::Invalid(e.to_string()))? + builder::circle_arc(a, b, p3(plane.to_world(s.mid()))) } } }; @@ -61,7 +53,7 @@ pub(crate) fn face(plane: &Plane, r: &Region2) -> Result { 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}")))) + guard("face", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}")))) } fn region_ok(r: &Region2) -> Result<()> { @@ -98,7 +90,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu 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)))))?; + let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?; out.push(Body::new(solid)?); } Ok(out) @@ -130,16 +122,26 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: if neg && pos { return Err(KernelError::Invalid("the profile crosses the revolve axis".into())); } + // Points on the axis (within solver tolerance) go exactly onto it, so the sweep does not + // create hair-thin faces. + let snap = |p: Vec2| { + let along = (p - axis_origin).dot(d); + let q = axis_origin + d * along; + if p.dist(q) < 1e-7 { q } else { p } + }; + let snap_loop = |l: &Loop2| Loop2 { + segs: l + .segs + .iter() + .map(|s| match *s { + Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) }, + arc => arc, + }) + .collect(), + }; + let r = &Region2 { outer: snap_loop(&r.outer), holes: r.holes.iter().map(snap_loop).collect() }; 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) - }) - })?; + let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?; out.push(Body::new(solid)?); } Ok(out) diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 05be595e..58bd0f13 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -2,16 +2,17 @@ //! //! 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. +//! measurement and STEP export. The implementation uses the `truck` crates (Apache-2.0; see +//! `plan/adr/0001-geometry-kernel.md`) plus our own local operations (edge fillets and +//! chamfers in `blend.rs`), but no truck 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 blend; mod body; mod build; mod measure; @@ -19,10 +20,11 @@ mod ops; mod step; mod topo; +pub use blend::{chamfer, fillet}; 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 ops::{BoolOp, boolean, transform}; pub use step::step_export; pub use topo::{TopoCounts, merged_topology}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index fa946bdf..42eec355 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -1,10 +1,10 @@ -//! Operations on bodies: booleans, fillets, chamfers, rigid transforms. +//! Operations on bodies: booleans and rigid transforms. -use monstertruck_modeling as mt; use serde::{Deserialize, Serialize}; use solvecraft_geom::Vec3; +use truck_modeling as mt; -use crate::body::{Body, Solid, v3}; +use crate::body::{Body, p3, v3}; use crate::{KernelError, Result, guard}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -15,84 +15,75 @@ pub enum BoolOp { 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. +/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a +/// ray whose direction is derived from point coordinates; moving both solids (and the result +/// back) leaves the geometry unchanged but changes those rays. +const JITTER: [[f64; 3]; 4] = [[0.0, 0.0, 0.0], [0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]]; + +fn volume(b: &Body) -> f64 { + b.tessellate(b.size() * 2e-3).map(|m| m.measure().volume).unwrap_or(f64::NAN) +} + +/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes +/// everything or an intersection of disjoint bodies. Results are checked against volume bounds +/// and retried with shifted copies and other tolerances when they fail or look wrong. pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { let size = a.size().max(b.size()); + let (va, vb) = (volume(a), volume(b)); + let slack = 2e-3 * (va + vb) + 1e-9; + let plausible = |v: f64| match op { + BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack, + BoolOp::Cut => v >= va - vb - slack && v <= va + slack, + BoolOp::Intersect => v <= va.min(vb) + slack, + }; 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)); + let mut empty_votes = 0; + for (attempt, j) in JITTER.iter().enumerate() { + let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01); + for k in [5e-4, 2e-3] { + let tol = (size * k).max(1e-5); + let r = guard("boolean", || { + let (mut sa, mut sb) = (a.deep_copy(), b.deep_copy()); + if attempt > 0 { + sa = mt::builder::translated(&sa, shift); + sb = mt::builder::translated(&sb, shift); } - last = "degenerate result".into(); + let res = match op { + BoolOp::Union => truck_shapeops::or(&sa, &sb, tol), + BoolOp::Cut => { + sb.not(); + truck_shapeops::and(&sa, &sb, tol) + } + BoolOp::Intersect => truck_shapeops::and(&sa, &sb, tol), + }; + let s = res.ok_or_else(|| KernelError::Failed("no result".into()))?; + Ok(if attempt > 0 { mt::builder::translated(&s, -shift) } else { s }) + }); + match r { + Ok(s) if s.boundaries().is_empty() || s.face_iter().next().is_none() => { + empty_votes += 1; + last = "empty result".into(); + } + Ok(s) => match Body::new(s) { + Ok(body) => { + let v = volume(&body); + if v > 0.0 && plausible(v) { + return Ok(Some(body)); + } + last = format!("implausible result volume {v:.4}"); + } + Err(e) => last = e.to_string(), + }, + Err(e) => last = e.to_string(), } - Err(e) => last = e.to_string(), + } + if empty_votes >= 2 && op != BoolOp::Union { + return Ok(None); } } 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()) { @@ -102,7 +93,7 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: 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)); + s = mt::builder::rotated(&s, p3(origin), v3(ax), mt::Rad(angle)); } if translate.len() > 0.0 { s = mt::builder::translated(&s, v3(translate)); diff --git a/crates/kernel/src/step.rs b/crates/kernel/src/step.rs index c35cb337..8cc4844d 100644 --- a/crates/kernel/src/step.rs +++ b/crates/kernel/src/step.rs @@ -1,18 +1,17 @@ -use monstertruck_io::step::save; +use truck_stepio::out; use crate::body::Body; use crate::{KernelError, Result, guard}; -/// STEP (ISO 10303-21, AP203-style geometry) text for the given bodies. +/// STEP (ISO 10303-21) 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) + let models: out::StepModels<_, _, _> = cs.iter().collect(); + let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() }; + Ok(out::CompleteStepDisplay::new(models, header).to_string()) }) } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 31429937..9f98e618 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -119,6 +119,32 @@ fn hostile_inputs_are_errors() { assert!(transform(&b, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).is_err()); } +#[test] +fn fillets_on_several_edges_and_angles() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + // Two opposite vertical edges, then a horizontal top edge between them is not touching either. + let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 10.0)], 4.0).unwrap(); + let corner = 16.0 - PI * 4.0; + assert!(rel(measure(&f).unwrap().volume, 24000.0 - 2.0 * corner * 20.0) < 2e-4); + let top = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0)], 2.0).unwrap(); + assert!(rel(measure(&top).unwrap().volume, 24000.0 - (4.0 - PI) * 40.0) < 2e-4); + // Triangular prism: 60° edges. + let tri = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(15.0, 25.980762113533)]), holes: vec![] }; + let p = extrude(&Plane::XY, &[tri], 0.0, 10.0).unwrap().pop().unwrap(); + let r = 2.0; + let fp = fillet(&p, &[Vec3::new(0.0, 0.0, 5.0)], r).unwrap(); + // Removed area for interior angle φ: r²(cot(φ/2)) − r²(π − φ)/2. + let phi = PI / 3.0; + let removed = r * r / (phi / 2.0).tan() - r * r * (PI - phi) / 2.0; + let v0 = measure(&p).unwrap().volume; + assert!(rel(measure(&fp).unwrap().volume, v0 - removed * 10.0) < 2e-4, "{} vs {}", measure(&fp).unwrap().volume, v0 - removed * 10.0); + // Adjacent edges at a blended corner are not supported yet (an error, not a crash). + assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).is_err()); + // Concave edge: not supported. + let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap(); + assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err()); +} + #[test] #[ignore] fn debug_convergence() { @@ -135,3 +161,58 @@ fn debug_convergence() { println!("face {f}: {} normals, first {:?}, max |n.z| {}", ns.len(), ns.first(), ns.iter().map(|n| n.z.abs()).fold(0.0, f64::max)); } } + +#[test] +#[ignore] +fn debug_coplanar() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + for (z0, z1) in [(0.0, 20.0), (-5.0, 20.0), (0.0, 25.0), (-5.0, 25.0), (5.0, 20.0), (5.0, 25.0)] { + let c = cylinder(Vec3::new(20.0, 15.0, z0), Vec3::Z, 5.0, z1 - z0).unwrap(); + let r = boolean(&b, &c, BoolOp::Cut); + println!("cut z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume))); + let r = boolean(&b, &c, BoolOp::Union); + println!("union z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume))); + } + let r = + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] }; + for ang in [PI / 2.0, PI, 1.5 * PI, 2.0 * PI] { + let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap(); + let m = measure(&q).unwrap(); + println!("revolve {ang}: vol {} expect {} faces {}", m.volume, ang / 2.0 * 1600.0 * 30.0, m.faces); + } +} + +#[test] +#[ignore] +fn debug_blind() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 25.0)).unwrap(); + println!("box pocket: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume))); + let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 15.0)).unwrap(); + println!("box void: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume))); + for r in [5.0, 4.3] { + for z in [5.0, 7.3, 13.1] { + let c = cylinder(Vec3::new(20.3, 15.1, z), Vec3::Z, r, 30.0).unwrap(); + println!("cyl r{r} z{z}: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume))); + } + } + let c = cylinder(Vec3::new(20.0, 15.0, 25.0), -Vec3::Z, 5.0, 20.0).unwrap(); + println!("cyl down: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume))); + let s = sphere(Vec3::new(20.0, 15.0, 20.0), 5.0).unwrap(); + println!("sphere: {:?}", boolean(&b, &s, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume))); +} + +#[test] +#[ignore] +fn debug_revolve_doc_case() { + let r = + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] }; + for ang in [PI / 2.0, -PI / 2.0] { + let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap(); + let m = measure(&q).unwrap(); + println!("ang {ang}: vol {} bbox {:?}", m.volume, m.bbox); + } + let r2 = Region2 { outer: r.outer.reversed(), holes: vec![] }; + let q = revolve(&Plane::XY, std::slice::from_ref(&r2), Vec2::ZERO, Vec2::Y, PI / 2.0).unwrap().pop().unwrap(); + println!("reversed: {}", measure(&q).unwrap().volume); +} diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index b0b982c2..b7ab6358 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -174,7 +174,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { if !faces.contains(&fi) { faces.push(fi); } - let mut vix = |v: &monstertruck_modeling::Vertex| { + let mut vix = |v: &truck_modeling::Vertex| { let k = format!("{:?}", v.id()); let n = vid.len(); let i = *vid.entry(k).or_insert(n);