From 7bf9ce31aec275577a0e60b9a90a69230407fa1c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 31 May 2026 19:27:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(model):=203D=20solid=20modelling=20tab=20?= =?UTF-8?q?=E2=80=94=20primitives=20+=20boolean=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Home tab to "Draw" and add a Model tab beside it with two groups: - Model: BOX / CYLINDER / CONE / SPHERE / WEDGE / TORUS, placed with a footprint + height. Each is built as a real ACIS Solid3D (acadrust primitive builders) plus a truck B-rep, tessellated into the shaded mesh pipeline, with edge wires for picking + wireframe. - Design: UNION / SUBTRACT / INTERSECT via truck-shapeops. A session-only Scene.model_solids cache holds each solid's truck B-rep so the boolean tools can combine and chain them. Supersedes the old command-line BOX/SPHERE/CYLINDER (insert::solid3d_cmds keeps EXTRUDE/ REVOLVE/SWEEP/LOFT). Known limitations: booleans need solids created this session; curved ACIS primitives render in-session but not after reload (the SAT tessellator handles only planar caps); geometry assumes world_offset 0. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 337 +++++++++++++++++++++++++-- Cargo.toml | 1 + build.rs | 2 +- src/app/cmd_result.rs | 10 + src/app/commands.rs | 44 ++-- src/app/mod.rs | 1 + src/app/model_ops.rs | 75 ++++++ src/command/mod.rs | 7 + src/modules/home/mod.rs | 2 +- src/modules/insert/solid3d_cmds.rs | 355 +---------------------------- src/modules/mod.rs | 1 + src/modules/model/boolean_cmd.rs | 27 +++ src/modules/model/mod.rs | 63 +++++ src/modules/model/primitive_cmd.rs | 324 ++++++++++++++++++++++++++ src/modules/registry.rs | 1 + src/scene/mod.rs | 26 +++ src/scene/model_solid.rs | 216 ++++++++++++++++++ 17 files changed, 1095 insertions(+), 397 deletions(-) create mode 100644 src/app/model_ops.rs create mode 100644 src/modules/model/boolean_cmd.rs create mode 100644 src/modules/model/mod.rs create mode 100644 src/modules/model/primitive_cmd.rs create mode 100644 src/scene/model_solid.rs diff --git a/Cargo.lock b/Cargo.lock index 9534e9f29..81cbfe377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "OpenCADStudio" version = "0.4.6" @@ -20,6 +30,7 @@ dependencies = [ "truck-meshalgo", "truck-modeling", "truck-polymesh", + "truck-shapeops", "ureq", "windows-sys 0.61.2", ] @@ -90,6 +101,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr 2.8.0", +] + [[package]] name = "aliasable" version = "0.1.3" @@ -533,7 +553,7 @@ dependencies = [ "allsorts", "azul-core", "azul-css", - "base64", + "base64 0.22.1", "hyphenation", "lru", "roxmltree 0.21.1", @@ -555,6 +575,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8259ebd5ee48a37fd8931116405456fd67efbb5435b4789e45bdbccf5d7dea7e" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -852,6 +878,19 @@ dependencies = [ "serde", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -925,7 +964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", - "memchr", + "memchr 2.8.0", ] [[package]] @@ -1010,7 +1049,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" dependencies = [ - "memchr", + "memchr 2.8.0", ] [[package]] @@ -1173,6 +1212,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-new" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1662,7 +1712,7 @@ dependencies = [ "futures-macro", "futures-sink", "futures-task", - "memchr", + "memchr 2.8.0", "pin-project-lite", "slab", ] @@ -2027,6 +2077,30 @@ dependencies = [ "serde", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "iced" version = "0.14.0" @@ -2774,6 +2848,23 @@ dependencies = [ "num-traits", ] +[[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 = "mac" version = "0.1.1" @@ -2850,6 +2941,15 @@ dependencies = [ "digest", ] +[[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.0" @@ -3062,13 +3162,22 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05aec50c70fd288702bcd93284a8444607f3292dbdf2a30de5ea5dcdbe72287b" +dependencies = [ + "memchr 1.0.2", +] + [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "memchr", + "memchr 2.8.0", "minimal-lexical", ] @@ -3078,7 +3187,7 @@ version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ - "memchr", + "memchr 2.8.0", ] [[package]] @@ -3088,7 +3197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" dependencies = [ "bytecount", - "memchr", + "memchr 2.8.0", "nom 8.0.0", ] @@ -3123,6 +3232,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[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-derive" version = "0.4.2" @@ -3180,7 +3300,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3944,7 +4064,7 @@ dependencies = [ "azul-core", "azul-css", "azul-layout", - "base64", + "base64 0.22.1", "flate2", "getrandom 0.3.4", "kuchiki", @@ -3960,13 +4080,23 @@ dependencies = [ "xmlparser", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.10+spec-1.1.0", ] [[package]] @@ -4048,13 +4178,23 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" +dependencies = [ + "memchr 2.8.0", + "serde", +] + [[package]] name = "quick-xml" version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ - "memchr", + "memchr 2.8.0", ] [[package]] @@ -4223,7 +4363,7 @@ dependencies = [ "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", - "num-derive", + "num-derive 0.4.2", "num-traits", "paste", "profiling", @@ -4339,6 +4479,35 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr 2.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr 2.8.0", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -4430,7 +4599,7 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" dependencies = [ - "memchr", + "memchr 2.8.0", ] [[package]] @@ -4440,7 +4609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba4f18e13793c89413ba7e7c991db5f56527803d4706df6a14291d045cd67db2" dependencies = [ "allsorts", - "base64", + "base64 0.22.1", "mmapio", "xmlparser", ] @@ -4527,6 +4696,36 @@ dependencies = [ "untrusted", ] +[[package]] +name = "ruststep" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df866eb24b48dd4bb8a3cb0dfeb3a25ff2c251ed93aae1cbfc03eb8ee3c7dcc" +dependencies = [ + "Inflector", + "derive-new", + "derive_more", + "itertools 0.10.5", + "nom 7.1.3", + "ruststep-derive", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "ruststep-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bfdd035ae42e977d18e3197065392784566ef230eda4241d15799ea2154e84" +dependencies = [ + "Inflector", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4673,7 +4872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa 1.0.18", - "memchr", + "memchr 2.8.0", "serde", "serde_core", "zmij", @@ -5343,6 +5542,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -5352,6 +5557,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.25.10+spec-1.1.0" @@ -5359,7 +5575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.1", ] @@ -5470,6 +5686,7 @@ dependencies = [ "truck-geometry", "truck-polymesh", "truck-topology", + "vtkio", ] [[package]] @@ -5505,6 +5722,41 @@ dependencies = [ "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 0.13.0", + "rustc-hash 2.1.2", + "truck-base", + "truck-geometry", + "truck-geotrait", + "truck-meshalgo", + "truck-stepio", + "truck-topology", +] + +[[package]] +name = "truck-stepio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d29cf5b0dec4b927e80b5dca26ce3ca69601c77ea5b9c953e30bfc3e123d189" +dependencies = [ + "chrono", + "derive_more", + "ruststep", + "serde", + "truck-derivers", + "truck-geometry", + "truck-geotrait", + "truck-modeling", + "truck-polymesh", + "truck-topology", +] + [[package]] name = "truck-topology" version = "0.6.0" @@ -5658,7 +5910,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "log", "percent-encoding", "rustls", @@ -5674,7 +5926,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http", "httparse", "log", @@ -5686,7 +5938,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -5747,6 +5999,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtkio" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abbe89e5b97b472d57abeb02755a06d75b28d2df7d1fe3df5baf032281a65c16" +dependencies = [ + "base64 0.13.1", + "bytemuck", + "byteorder", + "flate2", + "lz4_flex", + "nom 3.2.1", + "num-derive 0.3.3", + "num-traits", + "quick-xml 0.22.0", + "serde", + "xz2", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6009,7 +6280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.2", "quote", ] @@ -6649,13 +6920,22 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr 2.8.0", +] + [[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ - "memchr", + "memchr 2.8.0", ] [[package]] @@ -6664,7 +6944,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ - "memchr", + "memchr 2.8.0", ] [[package]] @@ -6830,6 +7110,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "y4m" version = "0.8.0" @@ -6883,7 +7172,7 @@ version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7000,7 +7289,7 @@ version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index 7b6999b19..64893e2a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", rayon = "1" ureq = { version = "3", default-features = false, features = ["rustls"] } inventory = "0.3" +truck-shapeops = "0.4" [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/build.rs b/build.rs index fbe4cc33a..d6acb604a 100644 --- a/build.rs +++ b/build.rs @@ -22,7 +22,7 @@ use std::fs; use std::path::Path; // Display order for known modules; unknown modules follow alphabetically. -const PRIORITY: &[&str] = &["home", "insert", "annotate", "view", "manage"]; +const PRIORITY: &[&str] = &["home", "model", "insert", "annotate", "view", "manage"]; fn main() { let mods_dir = Path::new("src/modules"); diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 00e7e1c1c..f00689321 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -125,6 +125,16 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); } + CmdResult::CommitSolid { entity, solid } => { + let label = self.history_label_from_active_cmd(i, "SOLID"); + self.push_undo_snapshot(i, label); + self.add_model_solid(entity, *solid); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + } CmdResult::CommitAndEditText(entity) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); self.push_undo_snapshot(i, label); diff --git a/src/app/commands.rs b/src/app/commands.rs index e929eca7b..6472f5634 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1190,6 +1190,22 @@ impl OpenCADStudio { } } + // ── Model commands (3D primitives) ───────────────────────────── + "BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "TORUS" => { + use crate::modules::model::primitive_cmd::PrimitiveCommand; + let new_cmd = PrimitiveCommand::new(cmd); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + // ── Design commands (solid booleans) ─────────────────────────── + "UNION" | "SUBTRACT" | "INTERSECT" => { + use crate::modules::model::boolean_cmd::BoolOp; + if let Some(op) = BoolOp::from_id(cmd) { + return self.solid_boolean(op); + } + } + // ── Annotate commands ────────────────────────────────────────── "TEXT" | "T" | "DT" => { use crate::modules::annotate::text::TextCommand; @@ -5038,32 +5054,8 @@ impl OpenCADStudio { } } - // ── 3D Primitive — BOX ──────────────────────────────────────── - "BOX" => { - use crate::modules::insert::solid3d_cmds::BoxCommand; - let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let cmd = BoxCommand::new(color); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - - // ── 3D Primitive — SPHERE ───────────────────────────────────── - "SPHERE" => { - use crate::modules::insert::solid3d_cmds::SphereCommand; - let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let cmd = SphereCommand::new(color); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - - // ── 3D Primitive — CYLINDER ─────────────────────────────────── - "CYLINDER" => { - use crate::modules::insert::solid3d_cmds::CylinderCommand; - let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let cmd = CylinderCommand::new(color); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } + // BOX / SPHERE / CYLINDER / CONE / WEDGE / TORUS are handled by the + // Model-tab primitive command above (with truck boolean caching). // ── EXTRUDE ──────────────────────────────────────────────────── "EXTRUDE" | "EXT" => { diff --git a/src/app/mod.rs b/src/app/mod.rs index d1bd75ad8..6affa7b0d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ mod helpers; mod history; mod layers; mod mtext_editor; +mod model_ops; mod properties; mod text_inline; mod update; diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs new file mode 100644 index 000000000..61764b70f --- /dev/null +++ b/src/app/model_ops.rs @@ -0,0 +1,75 @@ +// 3D solid modelling support on the App: committing Model-tab primitives, +// and the Design-group boolean operations (truck-shapeops) over the scene's +// session-cached truck B-reps. + +use acadrust::entities::Solid3D; +use acadrust::{EntityType, Handle}; +use iced::Task; +use truck_modeling::Solid; + +use super::Message; +use crate::modules::model::boolean_cmd::BoolOp; +use crate::scene::model_solid::{self, Bool}; + +impl super::OpenCADStudio { + /// Commit a Model-tab solid: add its acadrust entity to the document, then + /// register the truck B-rep (caches it for booleans + tessellates it into + /// the shaded mesh pipeline). Returns the new entity handle. + pub(super) fn add_model_solid(&mut self, entity: EntityType, solid: Solid) -> Handle { + let i = self.active_tab; + let Some(handle) = self.commit_entity_handle(entity) else { + return Handle::NULL; + }; + self.tabs[i].scene.register_model_solid(handle, solid); + handle + } + + /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two + /// selected solids whose truck B-reps are in the session cache. + pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + let i = self.active_tab; + // Selected entities that have a cached truck B-rep. + let handles: Vec = self.tabs[i] + .scene + .selected + .iter() + .copied() + .filter(|h| self.tabs[i].scene.model_solids.contains_key(h)) + .collect(); + if handles.len() != 2 { + self.command_line.push_error( + "Boolean: select exactly two solids created this session.", + ); + return Task::none(); + } + let a = self.tabs[i].scene.model_solids[&handles[0]].clone(); + let b = self.tabs[i].scene.model_solids[&handles[1]].clone(); + let kind = match op { + BoolOp::Union => Bool::Union, + BoolOp::Subtract => Bool::Subtract, + BoolOp::Intersect => Bool::Intersect, + }; + let Some(result) = model_solid::boolean(kind, &a, &b) else { + self.command_line + .push_error("Boolean failed — the solids may not overlap."); + return Task::none(); + }; + + self.push_undo_snapshot(i, "BOOLEAN"); + // Remove the two operands (entity + mesh + cached B-rep). + self.tabs[i].scene.erase_entities(&handles); + // The result is freshly combined geometry with no ACIS parametrisation, + // so it lives as a Solid3D whose render/boolean data is the injected + // truck mesh + cached B-rep; its edge wires make it pickable. + let mut s3d = Solid3D::new(); + s3d.wires = model_solid::edge_wires(&result); + let handle = self.add_model_solid(EntityType::Solid3D(s3d), result); + self.tabs[i].scene.deselect_all(); + if !handle.is_null() { + self.tabs[i].scene.select_entity(handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + Task::none() + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs index cda056748..412a84c73 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -51,6 +51,13 @@ pub enum CmdResult { CommitEntity(EntityType), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), + /// Commit a Model-tab 3D solid: the acadrust entity (for selection / + /// persistence) plus its truck B-rep (cached for boolean ops + shaded + /// rendering). Ends the command. + CommitSolid { + entity: EntityType, + solid: Box, + }, /// Commit an acadrust entity, end the command, and open the in-place text /// editor on it (used by MLEADER to type the annotation after placement). CommitAndEditText(EntityType), diff --git a/src/modules/home/mod.rs b/src/modules/home/mod.rs index b97060369..b2fd1165a 100644 --- a/src/modules/home/mod.rs +++ b/src/modules/home/mod.rs @@ -23,7 +23,7 @@ impl CadModule for HomeModule { "home" } fn title(&self) -> &'static str { - "Home" + "Draw" } fn ribbon_groups(&self) -> Vec { diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 2b63601ac..82e835aa0 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -1,352 +1,20 @@ -// 3D solid primitive commands — BOX, SPHERE, CYLINDER -// and 2D→3D extrusion commands — EXTRUDE, REVOLVE. +// 2D→3D modelling commands — EXTRUDE, REVOLVE, SWEEP, LOFT. // -// All create a minimal Solid3D entity (empty ACIS, just as a document -// placeholder to hold the handle) and a MeshModel built with truck. -// The mesh is manually inserted into scene.meshes so it renders immediately. -// -// Round-trip limitation: saving and reopening the file will not restore -// the mesh because the ACIS data is empty. Full ACIS generation requires -// a separate step (out of scope here). -// -// Coordinate convention in OpenCADStudio: the viewport is Y-up (OpenGL style), -// so screen X→DXF X, screen Z→DXF Y, screen Y→DXF Z (height). -// truck works in standard math coordinates; we map accordingly. +// Each picks profile/path entities and emits a `CmdResult` whose handler +// builds a truck solid and inserts its MeshModel into scene.meshes under a +// minimal placeholder Solid3D entity (empty ACIS — saving will not restore +// the mesh). The standalone primitives (BOX/CYLINDER/CONE/SPHERE/WEDGE/TORUS) +// live in the Model tab (`modules::model::primitive_cmd`). use acadrust::{entities::Solid3D, EntityType}; use glam::Vec3; -use truck_modeling::builder; -use truck_modeling::{Point3, Rad, Vector3 as TruckVec3}; use crate::command::{CadCommand, CmdResult}; -use crate::scene::mesh_model::MeshModel; -use crate::scene::truck_tess; -// ── Tessellation helper ──────────────────────────────────────────────────── - -fn solid_to_mesh(solid: &truck_modeling::Solid, color: [f32; 4], name: &str) -> Option { - match truck_tess::tessellate_solid(solid, [0.0; 3]) { - truck_tess::TruckTessResult::Mesh { - verts, - normals, - indices, - } => Some(MeshModel { - name: name.to_string(), - verts, - normals, - indices, - color, - selected: false, - }), - _ => None, - } -} - -fn shell_to_mesh(shell: &truck_modeling::Shell, color: [f32; 4], name: &str) -> Option { - match truck_tess::tessellate_shell(shell, [0.0; 3]) { - truck_tess::TruckTessResult::Mesh { - verts, - normals, - indices, - } => Some(MeshModel { - name: name.to_string(), - verts, - normals, - indices, - color, - selected: false, - }), - _ => None, - } -} - -// ── BOX command ──────────────────────────────────────────────────────────── - -pub struct BoxCommand { - step: BoxStep, - p1: Vec3, - p2: Vec3, - color: [f32; 4], -} - -#[derive(PartialEq)] -enum BoxStep { - Corner1, - Corner2, - Height, -} - -impl BoxCommand { - pub fn new(color: [f32; 4]) -> Self { - Self { - step: BoxStep::Corner1, - p1: Vec3::ZERO, - p2: Vec3::ZERO, - color, - } - } -} - -impl CadCommand for BoxCommand { - fn name(&self) -> &'static str { - "BOX" - } - fn prompt(&self) -> String { - match self.step { - BoxStep::Corner1 => "BOX First corner:".into(), - BoxStep::Corner2 => "BOX Opposite corner (XY):".into(), - BoxStep::Height => "BOX Height:".into(), - } - } - fn on_point(&mut self, pt: Vec3) -> CmdResult { - match self.step { - BoxStep::Corner1 => { - self.p1 = pt; - self.step = BoxStep::Corner2; - CmdResult::NeedPoint - } - BoxStep::Corner2 => { - self.p2 = pt; - self.step = BoxStep::Height; - CmdResult::NeedPoint - } - BoxStep::Height => commit_box( - self.p1, - self.p2, - (pt.y - self.p1.y).abs().max(1e-4), - self.color, - ), - } - } - fn wants_text_input(&self) -> bool { - self.step == BoxStep::Height - } - fn on_text_input(&mut self, text: &str) -> Option { - text.trim() - .parse::() - .ok() - .filter(|&h| h.abs() > 1e-6) - .map(|h| commit_box(self.p1, self.p2, h.abs(), self.color)) - } - fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } -} - -fn commit_box(p1: Vec3, p2: Vec3, height: f32, color: [f32; 4]) -> CmdResult { - // Map OpenCADStudio coords to truck: x→x, z→y, y→z - let x0 = p1.x.min(p2.x) as f64; - let y0 = p1.z.min(p2.z) as f64; - let x1 = p1.x.max(p2.x) as f64; - let y1 = p1.z.max(p2.z) as f64; - let z0 = p1.y as f64; - let h = height as f64; - - // Build face at z=z0, then sweep to z0+h. - let v00 = builder::vertex(Point3::new(x0, y0, z0)); - let v10 = builder::vertex(Point3::new(x1, y0, z0)); - let v11 = builder::vertex(Point3::new(x1, y1, z0)); - let v01 = builder::vertex(Point3::new(x0, y1, z0)); - let e0 = builder::line(&v00, &v10); - let e1 = builder::line(&v10, &v11); - let e2 = builder::line(&v11, &v01); - let e3 = builder::line(&v01, &v00); - let wire: truck_modeling::Wire = [e0, e1, e2, e3].into_iter().collect(); - let face = match builder::try_attach_plane(&[wire]) { - Ok(f) => f, - Err(_) => return CmdResult::Cancel, - }; - // tsweep on Face → Solid - let solid = builder::tsweep(&face, TruckVec3::new(0.0, 0.0, h)); - CmdResult::CommitSolid3D { - mesh_fn: Box::new(move |name| solid_to_mesh(&solid, color, &name)), - } -} - -// ── SPHERE command ───────────────────────────────────────────────────────── - -pub struct SphereCommand { - step: SphereStep, - center: Vec3, - color: [f32; 4], -} - -#[derive(PartialEq)] -enum SphereStep { - Center, - Radius, -} - -impl SphereCommand { - pub fn new(color: [f32; 4]) -> Self { - Self { - step: SphereStep::Center, - center: Vec3::ZERO, - color, - } - } -} - -impl CadCommand for SphereCommand { - fn name(&self) -> &'static str { - "SPHERE" - } - fn prompt(&self) -> String { - match self.step { - SphereStep::Center => "SPHERE Center:".into(), - SphereStep::Radius => "SPHERE Radius:".into(), - } - } - fn on_point(&mut self, pt: Vec3) -> CmdResult { - match self.step { - SphereStep::Center => { - self.center = pt; - self.step = SphereStep::Radius; - CmdResult::NeedPoint - } - SphereStep::Radius => { - commit_sphere(self.center, (pt - self.center).length(), self.color) - } - } - } - fn wants_text_input(&self) -> bool { - self.step == SphereStep::Radius - } - fn on_text_input(&mut self, text: &str) -> Option { - text.trim() - .parse::() - .ok() - .filter(|&r| r > 1e-6) - .map(|r| commit_sphere(self.center, r, self.color)) - } - fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } -} - -fn commit_sphere(center: Vec3, radius: f32, color: [f32; 4]) -> CmdResult { - let cx = center.x as f64; - let cy = center.z as f64; - let cz = center.y as f64; - let r = radius as f64; - - // Build a half-circle arc wire from north pole to south pole (XZ plane). - let north = builder::vertex(Point3::new(cx, cy, cz + r)); - let south = builder::vertex(Point3::new(cx, cy, cz - r)); - let east = Point3::new(cx + r, cy, cz); - let arc = builder::circle_arc(&north, &south, east); - // rsweep the wire around the Z axis for a full revolution → Shell. - let wire: truck_modeling::Wire = std::iter::once(arc).collect(); - let axis_pt = Point3::new(cx, cy, cz); - let axis = TruckVec3::new(0.0, 0.0, 1.0); - let shell = builder::rsweep(&wire, axis_pt, axis, Rad(std::f64::consts::TAU)); - CmdResult::CommitSolid3D { - mesh_fn: Box::new(move |name| shell_to_mesh(&shell, color, &name)), - } -} - -// ── CYLINDER command ─────────────────────────────────────────────────────── - -pub struct CylinderCommand { - step: CylStep, - center: Vec3, - radius: f32, - color: [f32; 4], -} - -#[derive(PartialEq)] -enum CylStep { - Center, - Radius, - Height, -} - -impl CylinderCommand { - pub fn new(color: [f32; 4]) -> Self { - Self { - step: CylStep::Center, - center: Vec3::ZERO, - radius: 1.0, - color, - } - } -} - -impl CadCommand for CylinderCommand { - fn name(&self) -> &'static str { - "CYLINDER" - } - fn prompt(&self) -> String { - match self.step { - CylStep::Center => "CYLINDER Center of base:".into(), - CylStep::Radius => "CYLINDER Radius:".into(), - CylStep::Height => "CYLINDER Height:".into(), - } - } - fn on_point(&mut self, pt: Vec3) -> CmdResult { - match self.step { - CylStep::Center => { - self.center = pt; - self.step = CylStep::Radius; - CmdResult::NeedPoint - } - CylStep::Radius => { - self.radius = (pt - self.center).length().max(1e-4); - self.step = CylStep::Height; - CmdResult::NeedPoint - } - CylStep::Height => commit_cylinder( - self.center, - self.radius, - (pt.y - self.center.y).abs().max(1e-4), - self.color, - ), - } - } - fn wants_text_input(&self) -> bool { - matches!(self.step, CylStep::Radius | CylStep::Height) - } - fn on_text_input(&mut self, text: &str) -> Option { - let v = text.trim().parse::().ok().filter(|&v| v > 1e-6)?; - match self.step { - CylStep::Radius => { - self.radius = v; - self.step = CylStep::Height; - Some(CmdResult::NeedPoint) - } - CylStep::Height => Some(commit_cylinder(self.center, self.radius, v, self.color)), - _ => None, - } - } - fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } -} - -fn commit_cylinder(center: Vec3, radius: f32, height: f32, color: [f32; 4]) -> CmdResult { - let cx = center.x as f64; - let cy = center.z as f64; - let cz = center.y as f64; - let r = radius as f64; - let h = height as f64; - - // Build circle face at z=cz, sweep upward. - let right = builder::vertex(Point3::new(cx + r, cy, cz)); - let left = builder::vertex(Point3::new(cx - r, cy, cz)); - let top_t = Point3::new(cx, cy + r, cz); - let bot_t = Point3::new(cx, cy - r, cz); - let upper = builder::circle_arc(&right, &left, top_t); - let lower = builder::circle_arc(&left, &right, bot_t); - let wire: truck_modeling::Wire = [upper, lower].into_iter().collect(); - let face = match builder::try_attach_plane(&[wire]) { - Ok(f) => f, - Err(_) => return CmdResult::Cancel, - }; - let solid = builder::tsweep(&face, TruckVec3::new(0.0, 0.0, h)); - CmdResult::CommitSolid3D { - mesh_fn: Box::new(move |name| solid_to_mesh(&solid, color, &name)), - } -} +// BOX / SPHERE / CYLINDER (and CONE / WEDGE / TORUS) now live in the Model tab +// (`modules::model::primitive_cmd`), which builds them as truck B-reps cached +// for the Design-group boolean tools. EXTRUDE / REVOLVE / SWEEP / LOFT remain +// here as 2D→3D operations. // ── EXTRUDE command ──────────────────────────────────────────────────────── @@ -661,10 +329,7 @@ pub fn empty_solid3d() -> EntityType { // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["BOX"] }); // BoxCommand -inventory::submit!(crate::command::CommandRegistration { names: &["CYLINDER"] }); // CylinderCommand inventory::submit!(crate::command::CommandRegistration { names: &["EXT", "EXTRUDE"] }); // ExtrudeCommand inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); // LoftCommand inventory::submit!(crate::command::CommandRegistration { names: &["REV", "REVOLVE"] }); // RevolveCommand -inventory::submit!(crate::command::CommandRegistration { names: &["SPHERE"] }); // SphereCommand inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); // SweepCommand diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 1f13d05cf..7dbebd138 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -134,6 +134,7 @@ pub trait CadModule: Send + Sync { pub mod annotate; pub mod home; pub mod insert; +pub mod model; pub mod layout; pub mod manage; pub mod view; diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs new file mode 100644 index 000000000..49f6cd52b --- /dev/null +++ b/src/modules/model/boolean_cmd.rs @@ -0,0 +1,27 @@ +// Boolean operations on 3D solids (Design group). The actual CSG runs in +// `App::solid_boolean` (src/app/model_ops.rs) using truck-shapeops on the +// session-cached truck B-reps; this module just names the operations. + +/// Which boolean a Design-group tool performs on the two selected solids. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum BoolOp { + Union, + Subtract, + Intersect, +} + +impl BoolOp { + pub fn from_id(id: &str) -> Option { + Some(match id { + "UNION" => BoolOp::Union, + "SUBTRACT" => BoolOp::Subtract, + "INTERSECT" => BoolOp::Intersect, + _ => return None, + }) + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["UNION", "SUBTRACT", "INTERSECT"] +}); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs new file mode 100644 index 000000000..ab079fbad --- /dev/null +++ b/src/modules/model/mod.rs @@ -0,0 +1,63 @@ +// Model module — 3D solid modelling. +// +// Model group : create primitive solids (box, cylinder, cone, sphere, …) +// as ACIS Solid3D entities via acadrust's primitive builders. +// Design group : combine solids with truck boolean operations +// (union / subtract / intersect). + +pub mod boolean_cmd; +pub mod primitive_cmd; + +use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +pub struct ModelModule; + +/// Helper to declare a ribbon tool that fires a named command. +fn tool(id: &'static str, label: &'static str, glyph: &'static str) -> ToolDef { + ToolDef { + id, + label, + icon: IconKind::Glyph(glyph), + event: ModuleEvent::Command(id.to_string()), + } +} + +impl CadModule for ModelModule { + fn id(&self) -> &'static str { + "model" + } + fn title(&self) -> &'static str { + "Model" + } + + fn ribbon_groups(&self) -> Vec { + vec![ + RibbonGroup { + title: "Model", + tools: vec![ + RibbonItem::LargeTool(tool("BOX", "Box", "▦")), + RibbonItem::LargeTool(tool("CYLINDER", "Cylinder", "⬭")), + RibbonItem::LargeTool(tool("CONE", "Cone", "△")), + RibbonItem::LargeTool(tool("SPHERE", "Sphere", "◯")), + RibbonItem::Dropdown { + id: "MODEL_MORE", + icon: IconKind::Glyph("◰"), + items: vec![ + ("WEDGE", "Wedge", IconKind::Glyph("◣")), + ("TORUS", "Torus", IconKind::Glyph("◎")), + ], + default: "WEDGE", + }, + ], + }, + RibbonGroup { + title: "Design", + tools: vec![ + RibbonItem::LargeTool(tool("UNION", "Union", "⊕")), + RibbonItem::LargeTool(tool("SUBTRACT", "Subtract", "⊖")), + RibbonItem::LargeTool(tool("INTERSECT", "Intersect", "⊗")), + ], + }, + ] + } +} diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs new file mode 100644 index 000000000..874f28217 --- /dev/null +++ b/src/modules/model/primitive_cmd.rs @@ -0,0 +1,324 @@ +// 3D primitive creation — BOX / CYLINDER / CONE / SPHERE / WEDGE / PYRAMID / +// TORUS. Each is placed CAD-style with a few clicks (planar footprint first, +// then a height value), then built as a real ACIS `Solid3D` via acadrust's +// `acis::primitives` builders. `Scene::add_entity` tessellates the SAT B-rep +// into the 3D mesh pipeline, so the solid renders, selects, and saves to DXF. +// +// A matching truck `Solid` is cached on the scene (see model/mod.rs) when the +// entity is committed, so the Design-group boolean tools can combine it. + +use acadrust::entities::Solid3D; +use acadrust::{primitives, EntityType}; +use glam::Vec3; +use truck_modeling::Solid; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::model_solid; +use crate::scene::wire_model::WireModel; + +/// Which primitive a `PrimitiveCommand` builds. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Shape { + Box, + Wedge, + Cylinder, + Cone, + Sphere, + Torus, +} + +impl Shape { + fn from_id(id: &str) -> Option { + Some(match id { + "BOX" => Shape::Box, + "WEDGE" => Shape::Wedge, + "CYLINDER" => Shape::Cylinder, + "CONE" => Shape::Cone, + "SPHERE" => Shape::Sphere, + "TORUS" => Shape::Torus, + _ => return None, + }) + } + fn name(self) -> &'static str { + match self { + Shape::Box => "BOX", + Shape::Wedge => "WEDGE", + Shape::Cylinder => "CYLINDER", + Shape::Cone => "CONE", + Shape::Sphere => "SPHERE", + Shape::Torus => "TORUS", + } + } + /// True for footprints picked as a centre + radius (round shapes); false + /// for corner-to-corner footprints (box/wedge). + fn radial(self) -> bool { + !matches!(self, Shape::Box | Shape::Wedge) + } + /// Whether a height value is collected after the footprint. + fn needs_height(self) -> bool { + !matches!(self, Shape::Sphere | Shape::Torus) + } +} + +pub struct PrimitiveCommand { + shape: Shape, + /// Footprint points collected so far (local/world XY, z = 0). + pts: Vec, + /// True once the footprint is set and we are collecting the height. + height_step: bool, +} + +impl PrimitiveCommand { + pub fn new(id: &str) -> Self { + Self { + shape: Shape::from_id(id).unwrap_or(Shape::Box), + pts: Vec::new(), + height_step: false, + } + } + + /// Number of footprint points the shape needs before the height step. + fn footprint_pts(&self) -> usize { + match self.shape { + Shape::Torus => 3, // centre, major-radius, minor-radius + _ => 2, // corner/corner or centre/radius + } + } + + /// A reasonable default height when the user just presses Enter. + fn default_height(&self) -> f64 { + match self.shape { + Shape::Box | Shape::Wedge => { + let d = self.pts[1] - self.pts[0]; + (d.x.abs().max(d.y.abs())) as f64 + } + _ => (self.pts[1] - self.pts[0]).length() as f64, + } + .max(1.0) + } + + /// Build both the acadrust `Solid3D` (ACIS, for persistence) and the truck + /// `Solid` B-rep (rendering + booleans) from the footprint + `height`. + fn build(&self, height: f64) -> Option<(EntityType, Solid)> { + let (doc, solid) = match self.shape { + Shape::Box | Shape::Wedge => { + let (a, b) = (self.pts[0], self.pts[1]); + let length = (b.x - a.x).abs() as f64; + let width = (b.y - a.y).abs() as f64; + if length < 1e-6 || width < 1e-6 || height < 1e-6 { + return None; + } + if self.shape == Shape::Box { + let center = [ + (a.x + b.x) as f64 / 2.0, + (a.y + b.y) as f64 / 2.0, + height / 2.0, + ]; + ( + primitives::build_box(center, length, width, height), + model_solid::box_solid(center, length, width, height), + ) + } else { + let origin = [a.x.min(b.x) as f64, a.y.min(b.y) as f64, 0.0]; + ( + primitives::build_wedge(origin, length, width, height), + model_solid::wedge_solid(origin, length, width, height), + ) + } + } + Shape::Cylinder | Shape::Cone => { + let c = self.pts[0]; + let r = (self.pts[1] - c).length() as f64; + if r < 1e-6 || height < 1e-6 { + return None; + } + let center = [c.x as f64, c.y as f64, 0.0]; + if self.shape == Shape::Cylinder { + ( + primitives::build_cylinder(center, r, height), + model_solid::cylinder_solid(center, r, height), + ) + } else { + ( + primitives::build_cone(center, r, height), + model_solid::cone_solid(center, r, height), + ) + } + } + Shape::Sphere => { + let c = self.pts[0]; + let r = (self.pts[1] - c).length() as f64; + if r < 1e-6 { + return None; + } + let center = [c.x as f64, c.y as f64, 0.0]; + ( + primitives::build_sphere(center, r), + model_solid::sphere_solid(center, r), + ) + } + Shape::Torus => { + let c = self.pts[0]; + let major = (self.pts[1] - c).length() as f64; + let minor = (self.pts[2] - self.pts[1]).length() as f64; + if major < 1e-6 || minor < 1e-6 { + return None; + } + let center = [c.x as f64, c.y as f64, 0.0]; + ( + primitives::build_torus(center, major, minor), + model_solid::torus_solid(center, major, minor), + ) + } + }; + let mut s3d = Solid3D::new(); + s3d.set_sat_document(&doc); + // Edge wires make the solid click-pickable and draw a wireframe over + // the shaded mesh. + s3d.wires = model_solid::edge_wires(&solid); + Some((EntityType::Solid3D(s3d), solid)) + } + + fn commit(&self, height: f64) -> CmdResult { + match self.build(height) { + Some((entity, solid)) => CmdResult::CommitSolid { + entity, + solid: Box::new(solid), + }, + None => CmdResult::Cancel, + } + } +} + +impl CadCommand for PrimitiveCommand { + fn name(&self) -> &'static str { + self.shape.name() + } + + fn prompt(&self) -> String { + let n = self.shape.name(); + if self.height_step { + return format!("{n} Specify height :"); + } + match (self.shape.radial(), self.pts.len()) { + (false, 0) => format!("{n} Specify first corner:"), + (false, _) => format!("{n} Specify opposite corner:"), + (true, 0) => format!("{n} Specify center point:"), + (true, 1) => format!("{n} Specify radius:"), + (true, _) => format!("{n} Specify tube radius:"), + } + } + + fn on_point(&mut self, pt: Vec3) -> CmdResult { + if self.height_step { + // A click in the ground plane has no Z; use its distance from the + // footprint centre as the height magnitude. + let h = (pt - self.pts[0]).length() as f64; + return self.commit(h.max(1e-6)); + } + self.pts.push(pt); + if self.pts.len() < self.footprint_pts() { + return CmdResult::NeedPoint; + } + // Footprint complete. + if self.shape.needs_height() { + self.height_step = true; + CmdResult::NeedPoint + } else { + self.commit(0.0) + } + } + + fn on_enter(&mut self) -> CmdResult { + if self.height_step { + let h = self.default_height(); + return self.commit(h); + } + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn wants_text_input(&self) -> bool { + self.height_step + } + + fn on_text_input(&mut self, raw: &str) -> Option { + if !self.height_step { + return None; + } + let h: f64 = raw.trim().parse().ok().filter(|v| *v > 0.0)?; + Some(self.commit(h)) + } + + fn on_mouse_move(&mut self, pt: Vec3) -> Option { + if self.height_step || self.pts.is_empty() { + return None; + } + let mut foot = self.pts.clone(); + foot.push(pt); + Some(footprint_wire(self.shape, &foot)) + } +} + +// ── Footprint preview ─────────────────────────────────────────────────────── + +fn footprint_wire(shape: Shape, pts: &[Vec3]) -> WireModel { + let mut points: Vec<[f32; 3]> = Vec::new(); + if shape.radial() { + let c = pts[0]; + let r = (pts[1] - c).length(); + circle_points(&mut points, c, r); + if shape == Shape::Torus && pts.len() >= 3 { + // outer ring at major + minor for a quick torus hint + let minor = (pts[2] - pts[1]).length(); + points.push([f32::NAN; 3]); + circle_points(&mut points, c, r + minor); + } + } else { + let (a, b) = (pts[0], pts[1]); + points.extend_from_slice(&[ + [a.x, a.y, 0.0], + [b.x, a.y, 0.0], + [b.x, b.y, 0.0], + [a.x, b.y, 0.0], + [a.x, a.y, 0.0], + ]); + } + wire("primitive_preview", points) +} + +fn circle_points(out: &mut Vec<[f32; 3]>, c: Vec3, r: f32) { + const SEG: usize = 48; + for i in 0..=SEG { + let t = i as f32 / SEG as f32 * std::f32::consts::TAU; + out.push([c.x + r * t.cos(), c.y + r * t.sin(), 0.0]); + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "TORUS"] +}); + +fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { + WireModel { + name: name.into(), + points, + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + aci: 0, + key_vertices: vec![], + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + vp_scissor: None, + fill_tris: vec![], + } +} diff --git a/src/modules/registry.rs b/src/modules/registry.rs index cd74d9a09..2530d4fc8 100644 --- a/src/modules/registry.rs +++ b/src/modules/registry.rs @@ -8,6 +8,7 @@ use crate::modules::CadModule; pub fn all_modules() -> Vec> { vec![ Box::new(super::home::HomeModule), + Box::new(super::model::ModelModule), Box::new(super::insert::InsertModule), Box::new(super::annotate::AnnotateModule), Box::new(super::view::ViewModule), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 362c6b500..cff884639 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -10,6 +10,7 @@ pub mod hatch_patterns; pub mod hit_test; pub mod image_model; pub mod mesh_model; +pub mod model_solid; pub mod object; pub mod paper_canvas; pub mod pipeline; @@ -614,6 +615,11 @@ pub struct Scene { pub hatches: HashMap, /// GPU render data for solid meshes (truck Shell/Solid tessellation). pub meshes: HashMap, + /// Live truck B-reps for solids created this session by the Model tab, + /// keyed by entity handle. Backs the Design-group boolean tools (a solid + /// must be here to be combined). Not persisted — rebuilt only by creating + /// or combining primitives in-session. + pub model_solids: HashMap, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). @@ -703,6 +709,7 @@ impl Scene { current_layout: "Model".to_string(), hatches: HashMap::new(), meshes: HashMap::new(), + model_solids: HashMap::new(), images: HashMap::new(), active_viewport: None, bg_color: [0.11, 0.11, 0.11, 1.0], @@ -833,6 +840,23 @@ impl Scene { } /// Re-evaluate every cached mesh's color through `render_style` so a + /// Register a Model-tab solid: cache its truck B-rep (for boolean ops) and + /// tessellate it into the shaded mesh pipeline under `handle`. The solid is + /// in the same offset-relative frame the mesh pipeline uses, so the mesh is + /// stored as-is (Model-tab geometry is authored at world_offset 0). + pub fn register_model_solid(&mut self, handle: Handle, solid: truck_modeling::Solid) { + let color = self + .document + .get_entity(handle) + .map(|e| self.render_style(e).0) + .unwrap_or([0.8, 0.8, 0.85, 1.0]); + if let Some(set) = crate::scene::model_solid::mesh_from_solid(&solid, color) { + self.meshes.insert(handle, set); + } + self.model_solids.insert(handle, solid); + self.bump_geometry(); + } + /// `BACKGROUND` change picks up the new `adapt_to_bg` result without /// re-tessellating ACIS geometry. Caller must bump `geometry_epoch` /// afterwards so the GPU re-uploads the now-updated colour data. @@ -2711,6 +2735,7 @@ impl Scene { for h in &to_remove { self.hatches.remove(h); self.meshes.remove(h); + self.model_solids.remove(h); self.document.remove_entity(*h); } @@ -4070,6 +4095,7 @@ impl Scene { self.selected.remove(&h); self.hatches.remove(&h); self.meshes.remove(&h); + self.model_solids.remove(&h); } // Remove erased handles from all groups; delete groups that become empty. let group_dict_handle = self.document.header.acad_group_dict_handle; diff --git a/src/scene/model_solid.rs b/src/scene/model_solid.rs new file mode 100644 index 000000000..57875a1a3 --- /dev/null +++ b/src/scene/model_solid.rs @@ -0,0 +1,216 @@ +// truck B-rep construction for the Model tab's primitives, plus tessellation +// into the renderer's `MeshLodSet`. Each builder follows truck's own example +// recipes (tsweep / rsweep / cone / try_attach_plane), oriented Z-up with the +// footprint on the z = `base_z` plane to match acadrust's `acis::primitives`. +// +// The resulting truck `Solid` is cached per entity handle on the Scene so the +// Design-group boolean tools can run truck-shapeops on it. + +use truck_modeling::{builder, Point3, Rad, Solid, Vector3, Wire}; + +use crate::scene::mesh_model::{MeshLodSet, MeshModel}; + +const FULL: f64 = 7.0; // > 2π → builder closes the revolution + +/// Axis-aligned box from its center and full extents. +pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Solid { + let min = Point3::new( + center[0] - length / 2.0, + center[1] - width / 2.0, + center[2] - height / 2.0, + ); + let v = builder::vertex(min); + let e = builder::tsweep(&v, Vector3::new(length, 0.0, 0.0)); + let f = builder::tsweep(&e, Vector3::new(0.0, width, 0.0)); + builder::tsweep(&f, Vector3::new(0.0, 0.0, height)) +} + +/// Right triangular prism (wedge): right-triangle cross-section in XZ, +/// extruded along Y. `origin` is the min corner, ramp rising in +X/+Z. +pub fn wedge_solid(origin: [f64; 3], length: f64, width: f64, height: f64) -> Solid { + let o = Point3::new(origin[0], origin[1], origin[2]); + let a = builder::vertex(o); + let b = builder::vertex(Point3::new(o.x + length, o.y, o.z)); + let c = builder::vertex(Point3::new(o.x, o.y, o.z + height)); + let wire: Wire = vec![ + builder::line(&a, &b), + builder::line(&b, &c), + builder::line(&c, &a), + ] + .into(); + let face = builder::try_attach_plane(&[wire]).expect("wedge profile"); + builder::tsweep(&face, Vector3::new(0.0, width, 0.0)) +} + +/// Solid cylinder: disk on the z = base plane, extruded up by `height`. +pub fn cylinder_solid(center: [f64; 3], radius: f64, height: f64) -> Solid { + let v = builder::vertex(Point3::new(center[0] + radius, center[1], center[2])); + let circle = builder::rsweep( + &v, + Point3::new(center[0], center[1], center[2]), + Vector3::unit_z(), + Rad(FULL), + ); + let disk = builder::try_attach_plane(&[circle]).expect("cylinder cap"); + builder::tsweep(&disk, Vector3::new(0.0, 0.0, height)) +} + +/// Solid cone: profile (apex → rim → base-center) revolved about the axis. +pub fn cone_solid(center: [f64; 3], radius: f64, height: f64) -> Solid { + let cx = center[0]; + let cy = center[1]; + let cz = center[2]; + let apex = builder::vertex(Point3::new(cx, cy, cz + height)); + let rim = builder::vertex(Point3::new(cx + radius, cy, cz)); + let base = builder::vertex(Point3::new(cx, cy, cz)); + let wire: Wire = vec![builder::line(&apex, &rim), builder::line(&rim, &base)].into(); + let shell = builder::cone(&wire, Vector3::unit_z(), Rad(FULL)); + Solid::new(vec![shell]) +} + +/// Solid sphere: meridian semicircle revolved about the polar (Z) axis. +pub fn sphere_solid(center: [f64; 3], radius: f64) -> Solid { + let c = Point3::new(center[0], center[1], center[2]); + let top = builder::vertex(Point3::new(c.x, c.y, c.z + radius)); + // Rotate the top point about the X axis by π → meridian from +Z to −Z. + let meridian: Wire = builder::rsweep(&top, c, Vector3::unit_x(), Rad(std::f64::consts::PI)); + let shell = builder::cone(&meridian, Vector3::unit_z(), Rad(FULL)); + Solid::new(vec![shell]) +} + +/// Solid torus in the z = base plane (tube revolved about the Z axis). +pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Solid { + let c = Point3::new(center[0], center[1], center[2]); + let v = builder::vertex(Point3::new(c.x + major, c.y, c.z + minor)); + let tube = builder::rsweep( + &v, + Point3::new(c.x + major, c.y, c.z), + Vector3::unit_y(), + Rad(FULL), + ); + let shell = builder::rsweep(&tube, c, Vector3::unit_z(), Rad(FULL)); + Solid::new(vec![shell]) +} + +// ── Edge extraction (pick geometry + wireframe overlay) ───────────────────── + +/// Tessellate the solid's B-rep edges into acadrust `Wire`s. Stored on the +/// `Solid3D`/result entity so it is click-pickable (the renderer's wire +/// fallback draws these as a wireframe over the shaded mesh, and hit-testing +/// uses their points). +pub fn edge_wires(solid: &Solid) -> Vec { + use crate::scene::truck_tess::{tessellate_edge, TruckTessResult}; + use acadrust::types::Vector3; + let mut wires = Vec::new(); + for shell in solid.boundaries() { + for edge in shell.edge_iter() { + if let TruckTessResult::Lines(pts) = tessellate_edge(&edge, [0.0; 3]) { + if pts.len() < 2 { + continue; + } + let pts3: Vec = pts + .iter() + .map(|p| Vector3::new(p[0] as f64, p[1] as f64, p[2] as f64)) + .collect(); + wires.push(acadrust::entities::Wire::from_points(pts3)); + } + } + } + wires +} + +// ── Boolean operations (truck-shapeops) ───────────────────────────────────── + +/// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so +/// this scene module has no dependency on the UI module. +#[derive(Clone, Copy)] +pub enum Bool { + Union, + Subtract, + Intersect, +} + +const BOOL_TOL: f64 = 0.05; + +/// Combine two solids. `Subtract` removes `b` from `a`. Returns `None` when the +/// operation fails (e.g. the solids don't actually overlap). +pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option { + match op { + Bool::Union => truck_shapeops::or(a, b, BOOL_TOL), + Bool::Intersect => truck_shapeops::and(a, b, BOOL_TOL), + Bool::Subtract => { + let mut bn = b.clone(); + bn.not(); + truck_shapeops::and(a, &bn, BOOL_TOL) + } + } +} + +// ── Tessellation ──────────────────────────────────────────────────────────── + +/// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space, +/// before world_offset is applied by the caller). +pub fn mesh_from_solid(solid: &Solid, color: [f32; 4]) -> Option { + use crate::scene::truck_tess::{tessellate_solid, TruckTessResult}; + match tessellate_solid(solid, [0.0; 3]) { + TruckTessResult::Mesh { + verts, + normals, + indices, + } if !indices.is_empty() => { + let mesh = MeshModel { + name: String::new(), + verts, + normals, + indices, + color, + selected: false, + }; + Some(MeshLodSet::from_single(mesh)) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tri_count(s: &Solid) -> usize { + mesh_from_solid(s, [0.7, 0.7, 0.7, 1.0]) + .map(|m| m.lods[0].indices.len() / 3) + .unwrap_or(0) + } + + #[test] + fn all_primitives_triangulate() { + let c = [0.0, 0.0, 0.0]; + assert!(tri_count(&box_solid(c, 10.0, 10.0, 10.0)) >= 12, "box"); + assert!(tri_count(&wedge_solid(c, 10.0, 10.0, 10.0)) >= 6, "wedge"); + assert!(tri_count(&cylinder_solid(c, 5.0, 12.0)) > 20, "cylinder"); + assert!(tri_count(&cone_solid(c, 5.0, 12.0)) > 10, "cone"); + assert!(tri_count(&sphere_solid(c, 5.0)) > 50, "sphere"); + assert!(tri_count(&torus_solid(c, 8.0, 2.0)) > 50, "torus"); + } + + #[test] + fn booleans_produce_solids() { + let a = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0); + let b = box_solid([5.0, 5.0, 5.0], 10.0, 10.0, 10.0); + for (op, label) in [ + (Bool::Union, "union"), + (Bool::Subtract, "subtract"), + (Bool::Intersect, "intersect"), + ] { + let r = boolean(op, &a, &b); + let n = r.as_ref().map(tri_count).unwrap_or(0); + eprintln!("{label}: tris={n}"); + assert!(r.is_some() && n > 0, "{label} produced nothing"); + } + } + + #[test] + fn box_exposes_edges() { + assert!(edge_wires(&box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0)).len() >= 12); + } +}