diff --git a/Cargo.lock b/Cargo.lock index 147800f6..068bd0ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,11 @@ # 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.9.4" dependencies = [ - "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=fe98e42)", + "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=88e3308)", "ashpd", "bincode", "bytemuck", @@ -56,7 +46,6 @@ dependencies = [ "truck-meshalgo", "truck-modeling", "truck-polymesh", - "truck-shapeops", "ttf-parser", "ureq", "wasm-bindgen", @@ -85,10 +74,10 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadifc" version = "0.5.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396#c65d396abc3defcf96b1d71a3f812c8ad993e77c" +source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=88e3308#88e3308d0f14508346692705e6142de3f3d6a169" dependencies = [ "acadrust", - "base64 0.22.1", + "base64", "cadkernel", "serde", "serde_json", @@ -99,10 +88,10 @@ dependencies = [ [[package]] name = "acadifc" version = "0.5.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=fe98e42#fe98e425578c54facd021d8df74d86052b2f848b" +source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396#c65d396abc3defcf96b1d71a3f812c8ad993e77c" dependencies = [ "acadrust", - "base64 0.22.1", + "base64", "cadkernel", "serde", "serde_json", @@ -680,12 +669,6 @@ dependencies = [ "arrayvec", ] -[[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" @@ -949,7 +932,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#c9e104b99e8901c4804e7cece67f038f249a33f5" +source = "git+https://github.com/HakanSeven12/cadkernel.git#699e346fe67d51d7516abf1d27babf0259849713" dependencies = [ "cavalier_contours", ] @@ -1040,19 +1023,6 @@ dependencies = [ "serde", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "cipher" version = "0.4.4" @@ -1442,17 +1412,6 @@ 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" @@ -2406,30 +2365,6 @@ dependencies = [ "syn 2.0.119", ] -[[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.15.0-dev" @@ -3191,12 +3126,6 @@ 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" @@ -3441,15 +3370,6 @@ dependencies = [ "memchr 2.8.3", ] -[[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" @@ -3512,17 +3432,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[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" @@ -3580,7 +3489,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", @@ -4304,7 +4213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91dac59ed5eed56d899517c12cb9f00756738731926b54e513732a36f49a83d" dependencies = [ "allsorts", - "base64 0.22.1", + "base64", "flate2", "getrandom 0.3.4", "lopdf", @@ -4317,23 +4226,13 @@ dependencies = [ "weezl", ] -[[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 0.25.13+spec-1.1.0", + "toml_edit", ] [[package]] @@ -4493,16 +4392,6 @@ 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.3", - "serde", -] - [[package]] name = "quick-xml" version = "0.39.4" @@ -4633,7 +4522,7 @@ dependencies = [ "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", - "num-derive 0.4.2", + "num-derive", "num-traits", "paste", "profiling", @@ -5092,36 +4981,6 @@ 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.119", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -5842,18 +5701,12 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.4", + "winnow", ] -[[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" @@ -5863,17 +5716,6 @@ 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.13+spec-1.1.0" @@ -5881,9 +5723,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.4", + "winnow", ] [[package]] @@ -5892,7 +5734,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] @@ -6008,7 +5850,6 @@ dependencies = [ "truck-geometry", "truck-polymesh", "truck-topology", - "vtkio", ] [[package]] @@ -6044,41 +5885,6 @@ 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.3", - "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" @@ -6242,7 +6048,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64 0.22.1", + "base64", "log", "percent-encoding", "rustls", @@ -6260,7 +6066,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64 0.22.1", + "base64", "http", "httparse", "log", @@ -6272,7 +6078,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64 0.22.1", + "base64", "data-url", "flate2", "imagesize", @@ -6328,25 +6134,6 @@ 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" @@ -6538,7 +6325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml", "quote", ] @@ -7173,15 +6960,6 @@ 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.3", -] - [[package]] name = "winnow" version = "1.0.4" @@ -7313,15 +7091,6 @@ 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" @@ -7363,7 +7132,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.4", + "winnow", "zbus_macros", "zbus_names", "zvariant", @@ -7375,7 +7144,7 @@ version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", @@ -7391,7 +7160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" dependencies = [ "serde", - "winnow 1.0.4", + "winnow", "zvariant", ] @@ -7482,7 +7251,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.4", + "winnow", "zvariant_derive", "zvariant_utils", ] @@ -7493,7 +7262,7 @@ version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", @@ -7510,5 +7279,5 @@ dependencies = [ "quote", "serde", "syn 2.0.119", - "winnow 1.0.4", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 63406088..89aa8f53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ winresource = "0.1" [features] default = ["solid3d"] -solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"] +solid3d = ["dep:truck-meshalgo", "dep:lzma-sys"] [dependencies] serde_json = "1" @@ -38,7 +38,7 @@ env_logger = "0.11" # The CAD stack is reached through acadifc, which re-exports the codec and # the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…` # paths keep resolving. -acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "fe98e42", features = ["serde", "offset"] } +acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "88e3308", features = ["serde", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -50,7 +50,6 @@ i18n-embed = { version = "0.16", features = ["fluent-system"] } i18n-embed-fl = "0.10" rust-embed = "8" inventory = "0.3" -truck-shapeops = { version = "0.4", optional = true } rustc-hash = "2" sha2 = "0.10" fontdb = "0.23" diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 57714318..7fb701f0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2637,62 +2637,25 @@ impl OpenCADStudio { height, color, } => { - use crate::entities::traits::EntityTypeOps; use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::convert::acad_to_truck::TruckObject; - use crate::scene::convert::truck_tess; - use truck_modeling::builder; - use truck_modeling::Vector3 as TruckVec3; + use crate::scene::model::{solid_model, sweep_model}; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - let truck_entity = entity.to_truck_entity(&self.tabs[i].scene.document); - let result = truck_entity.and_then(|te| { - match te.object { - TruckObject::Contour(wire) => { - // Attach a planar face to the wire profile, then sweep. - let face = builder::try_attach_plane(&[wire]).ok()?; - // tsweep(Face) → Solid - let solid = - builder::tsweep(&face, TruckVec3::new(0.0, 0.0, height as f64)); - match truck_tess::tessellate_solid(&solid) { - truck_tess::TruckTessResult::Mesh { - verts, - verts_low, - normals, - indices, - } => Some(( - crate::scene::model::mesh_model::MeshModel { - name: String::new(), - verts, - verts_low, - normals, - indices, - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - }, - solid, - )), - _ => None, - } - } - _ => None, - } - }); - if let Some((mut mesh, solid)) = result { + // The kernel sweeps the profile into analytic surfaces — + // a straight run becomes a plane and an arc a cylinder — + // so the solid saves as exact ACIS rather than facets. + let result = sweep_model::extruded(&entity, height as f64) + .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); + if let Some((mesh, solid)) = result { let pending = self.begin_undo(i, "EXTRUDE", 1, true); - let new_entity = empty_solid3d(); - let new_handle = self.tabs[i].scene.add_entity(new_entity); - mesh.name = format!("{}", new_handle.value()); - self.tabs[i] - .scene - .meshes - .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); - // Keep the truck B-rep so the save path can export exact - // ACIS geometry (else the solid is dropped by other CAD apps). - self.tabs[i].scene.solid_models.insert(new_handle, solid); + let mut s3d = empty_solid3d(); + if let acadrust::EntityType::Solid3D(inner) = &mut s3d { + inner.wires = solid_model::edge_wires(&solid); + } + let new_handle = self.tabs[i].scene.add_entity(s3d); + self.tabs[i].scene.register_solid_model(new_handle, solid); + let _ = mesh; self.tabs[i].dirty = true; self.command_line.push_output(crate::t!("EXTRUDE: solid created.").as_ref()); if let Some(pd) = pending { @@ -2718,65 +2681,34 @@ impl OpenCADStudio { angle_deg, color, } => { - use crate::entities::traits::EntityTypeOps; use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::convert::acad_to_truck::TruckObject; - use crate::scene::convert::truck_tess; - use truck_modeling::builder; - use truck_modeling::{Point3, Rad, Vector3 as TruckVec3}; + use crate::scene::model::{solid_model, sweep_model}; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - let truck_entity = entity.to_truck_entity(&self.tabs[i].scene.document); - let result = truck_entity.and_then(|te| { - let wire: Option = match te.object { - TruckObject::Contour(w) => Some(w), - TruckObject::Curve(e) => Some(std::iter::once(e).collect()), - _ => None, - }; - let wire = wire?; - let origin = Point3::new( + // A line turned about the axis sweeps into a plane, a + // cylinder or a cone, and an arc into a sphere or a + // torus, so a revolved solid keeps exact geometry too. + let result = sweep_model::revolved( + &entity, + [ axis_start.x as f64, axis_start.y as f64, axis_start.z as f64, - ); - let dir = (axis_end - axis_start).normalize(); - let axis = TruckVec3::new(dir.x as f64, dir.y as f64, dir.z as f64); - let shell = builder::rsweep( - &wire, - origin, - axis, - Rad(angle_deg.to_radians() as f64), - ); - match truck_tess::tessellate_shell(&shell) { - truck_tess::TruckTessResult::Mesh { - verts, - verts_low, - normals, - indices, - } => Some(crate::scene::model::mesh_model::MeshModel { - name: String::new(), - verts, - verts_low, - normals, - indices, - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - }), - _ => None, - } - }); - if let Some(mut mesh) = result { + ], + [axis_end.x as f64, axis_end.y as f64, axis_end.z as f64], + (angle_deg as f64).to_radians(), + ) + .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); + if let Some((mesh, solid)) = result { let pending = self.begin_undo(i, "REVOLVE", 1, true); - let new_entity = empty_solid3d(); - let new_handle = self.tabs[i].scene.add_entity(new_entity); - mesh.name = format!("{}", new_handle.value()); - self.tabs[i] - .scene - .meshes - .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); + let mut s3d = empty_solid3d(); + if let acadrust::EntityType::Solid3D(inner) = &mut s3d { + inner.wires = solid_model::edge_wires(&solid); + } + let new_handle = self.tabs[i].scene.add_entity(s3d); + self.tabs[i].scene.register_solid_model(new_handle, solid); + let _ = mesh; self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref()); @@ -2788,14 +2720,14 @@ impl OpenCADStudio { .push_error(crate::t!("REVOLVE: could not revolve profile.").as_ref()); } } else { - self.command_line.push_error(crate::t!("REVOLVE: entity not found.").as_ref()); + self.command_line + .push_error(crate::t!("REVOLVE: entity not found.").as_ref()); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } - // ── SWEEP ────────────────────────────────────────────────────── CmdResult::SweepEntity { profile_handle, diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 37e1779e..c94c94d5 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,11 +1,15 @@ // 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. +// and the Design-group boolean operations over the scene's session-cached +// B-reps. +// +// The bodies come from the geometry kernel, so every operation here is on +// analytic surfaces rather than on facets — a turned cylinder is still a +// cylinder afterwards, and saves as one. use acadrust::entities::Solid3D; +use acadrust::kernel::brep::Body; use acadrust::{EntityType, Handle}; use iced::Task; -use truck_modeling::Solid; use super::Message; use crate::modules::model::boolean_cmd::BoolOp; @@ -13,9 +17,9 @@ use crate::scene::model::solid_model::{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_solid_model(&mut self, entity: EntityType, solid: Solid) -> Handle { + /// register the B-rep (caches it for booleans + tessellates it into the + /// shaded mesh pipeline). Returns the new entity handle. + pub(super) fn add_solid_model(&mut self, entity: EntityType, solid: Body) -> Handle { let i = self.active_tab; let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; @@ -25,7 +29,7 @@ impl super::OpenCADStudio { } /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two - /// selected solids whose truck B-reps are in the session cache. + /// selected solids whose 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. @@ -92,22 +96,11 @@ impl super::OpenCADStudio { } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); // Bounding box from the solid's edge wires. - let wires = solid_model::edge_wires(&solid); - let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); - for w in &wires { - for p in &w.points { - let c = [p.x, p.y, p.z]; - for k in 0..3 { - min[k] = min[k].min(c[k]); - max[k] = max[k].max(c[k]); - } - } - } - if min[0] > max[0] { + let Some((min, max)) = solid_model::extent(&solid) else { self.command_line .push_error(crate::t!("SLICE: could not determine the solid's extent.").as_ref()); return Task::none(); - } + }; // Generous margin so the box fully spans the solid in the free axes. let m = [ (max[0] - min[0]).max(1.0), @@ -132,7 +125,10 @@ impl super::OpenCADStudio { (lo[2] + hi[2]) / 2.0, ]; let halfspace = solid_model::box_solid(center, hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]); - let Some(result) = solid_model::boolean(Bool::Intersect, &solid, &halfspace) else { + let Some(result) = halfspace + .as_ref() + .and_then(|half| solid_model::boolean(Bool::Intersect, &solid, half)) + else { self.command_line .push_error(crate::t!("SLICE failed — the plane may not cross the solid.").as_ref()); return Task::none(); @@ -197,7 +193,6 @@ impl super::OpenCADStudio { /// through its centre by `angle_deg` degrees. Rotation preserves the solid's /// orientation, so it reuses the cached truck B-rep directly. pub(super) fn solid_rotate3d(&mut self, axis: usize, angle_deg: f64) -> Task { - use truck_modeling::{builder, Point3, Rad, Vector3 as TVec3}; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene @@ -212,33 +207,18 @@ impl super::OpenCADStudio { return Task::none(); } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); - let wires = solid_model::edge_wires(&solid); - let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); - for w in &wires { - for p in &w.points { - let c = [p.x, p.y, p.z]; - for k in 0..3 { - min[k] = min[k].min(c[k]); - max[k] = max[k].max(c[k]); - } - } - } - if min[0] > max[0] { + let Some(middle) = solid_model::centre(&solid) else { self.command_line .push_error(crate::t!("3DROTATE: could not determine the solid's extent.").as_ref()); return Task::none(); - } - let origin = Point3::new( - (min[0] + max[0]) / 2.0, - (min[1] + max[1]) / 2.0, - (min[2] + max[2]) / 2.0, - ); - let axis_v = match axis { - 0 => TVec3::unit_x(), - 1 => TVec3::unit_y(), - _ => TVec3::unit_z(), }; - let rotated = builder::rotated(&solid, origin, axis_v, Rad(angle_deg.to_radians())); + let Some(rotated) = + solid_model::turned(&solid, axis, angle_deg.to_radians(), middle) + else { + self.command_line + .push_error(crate::t!("3DROTATE: could not turn the solid.").as_ref()); + return Task::none(); + }; self.push_undo_snapshot(i, "3DROTATE"); self.tabs[i].scene.erase_entities(&handles); let mut s3d = Solid3D::new(); @@ -261,7 +241,6 @@ impl super::OpenCADStudio { /// segment (oriented along it, `width` wide, `height` tall) unioned together, /// reusing the box primitive + the boolean union path. pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task { - use truck_modeling::{builder, Point3, Rad, Vector3 as TVec3}; let i = self.active_tab; let found: Option<(Handle, Vec<[f64; 2]>, bool)> = self.tabs[i] .scene @@ -287,7 +266,7 @@ impl super::OpenCADStudio { if closed && pts.len() > 2 { segs.push((pts[pts.len() - 1], pts[0])); } - let mut acc: Option = None; + let mut acc: Option = None; let mut used = 0usize; for (a, b) in &segs { let (dx, dy) = (b[0] - a[0], b[1] - a[1]); @@ -296,10 +275,22 @@ impl super::OpenCADStudio { continue; } let angle = dy.atan2(dx); - // Local box: X 0..len, Y -w/2..w/2, Z 0..h, then orient to the segment. - let bx = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height); - let bx = builder::rotated(&bx, Point3::new(0.0, 0.0, 0.0), TVec3::unit_z(), Rad(angle)); - let bx = builder::translated(&bx, TVec3::new(a[0], a[1], 0.0)); + // Local box: X 0..len, Y -w/2..w/2, Z 0..h, then turned to lie + // along the segment and moved onto its start. + let (sin, cos) = angle.sin_cos(); + let Some(bx) = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height) + .and_then(|bx| { + solid_model::placed( + &bx, + [cos, sin, 0.0], + [-sin, cos, 0.0], + [0.0, 0.0, 1.0], + [a[0], a[1], 0.0], + ) + }) + else { + continue; + }; acc = Some(match acc { None => bx, Some(prev) => solid_model::boolean(Bool::Union, &prev, &bx).unwrap_or(prev), @@ -330,10 +321,9 @@ impl super::OpenCADStudio { /// 3DMIRROR — add a mirrored copy of the one selected solid across the plane /// perpendicular to the X/Y/Z axis (0/1/2) through its centre, keeping the - /// original. Reflection reverses face orientation, so `Solid::not()` restores - /// outward normals (the same inversion the boolean subtraction relies on). + /// original. A reflection loses handedness, so the kernel reverses every + /// loop on the way through; without that the copy lights black. pub(super) fn solid_mirror3d(&mut self, axis: usize) -> Task { - use truck_modeling::{builder, Matrix4, Vector3 as TVec3}; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene @@ -348,38 +338,16 @@ impl super::OpenCADStudio { return Task::none(); } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); - let wires = solid_model::edge_wires(&solid); - let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); - for w in &wires { - for p in &w.points { - let c = [p.x, p.y, p.z]; - for k in 0..3 { - min[k] = min[k].min(c[k]); - max[k] = max[k].max(c[k]); - } - } - } - if min[0] > max[0] { + let Some(middle) = solid_model::centre(&solid) else { self.command_line .push_error(crate::t!("3DMIRROR: could not determine the solid's extent.").as_ref()); return Task::none(); - } - let c = TVec3::new( - (min[0] + max[0]) / 2.0, - (min[1] + max[1]) / 2.0, - (min[2] + max[2]) / 2.0, - ); - let s = match axis { - 0 => TVec3::new(-1.0, 1.0, 1.0), - 1 => TVec3::new(1.0, -1.0, 1.0), - _ => TVec3::new(1.0, 1.0, -1.0), }; - // Reflect about the plane through the centre: T(c) · scale · T(-c). - let mat = Matrix4::from_translation(c) - * Matrix4::from_nonuniform_scale(s.x, s.y, s.z) - * Matrix4::from_translation(-c); - let mut reflected = builder::transformed(&solid, mat); - reflected.not(); // restore outward orientation after the reflection + let Some(reflected) = solid_model::mirrored(&solid, axis, middle) else { + self.command_line + .push_error(crate::t!("3DMIRROR: could not mirror the solid.").as_ref()); + return Task::none(); + }; self.push_undo_snapshot(i, "3DMIRROR"); let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&reflected); @@ -399,14 +367,13 @@ impl super::OpenCADStudio { /// 3DALIGN — move/rotate the one selected solid so its three source points /// land on the three destination points. The frame-to-frame transform is - /// computed in glam (`M = D · S⁻¹`) and handed to truck as a raw matrix; both - /// frames are right-handed so the result is a pure rotation+translation. + /// computed in glam (`M = D · S⁻¹`); both frames are right-handed, so the + /// result is a pure rotation and translation and the kernel accepts it. pub(super) fn solid_align3d( &mut self, src: [[f64; 3]; 3], dst: [[f64; 3]; 3], ) -> Task { - use truck_modeling::{builder, Matrix4}; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene @@ -443,13 +410,13 @@ impl super::OpenCADStudio { .push_error(crate::t!("3DALIGN: each point triple must be non-coincident and non-collinear.").as_ref()); return Task::none(); }; - let a = (d * s.inverse()).to_cols_array(); // column-major [f64; 16] - let mat = Matrix4::new( - a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], - a[14], a[15], - ); let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); - let aligned = builder::transformed(&solid, mat); + let Some(aligned) = solid_model::by_matrix(&solid, (d * s.inverse()).to_cols_array()) + else { + self.command_line + .push_error(crate::t!("3DALIGN: could not align the solid.").as_ref()); + return Task::none(); + }; self.push_undo_snapshot(i, "3DALIGN"); self.tabs[i].scene.erase_entities(&handles); let mut s3d = Solid3D::new(); @@ -467,15 +434,11 @@ impl super::OpenCADStudio { } /// SECTION — draw the cross-section outline where an axis-aligned plane - /// (X/Y/Z = `axis` at `value`) cuts the one selected solid, as Line entities. - /// Reuses the mesh-interference analyzer to find the cut segments. - #[cfg(feature = "solid3d")] + /// (X/Y/Z = `axis` at `value`) cuts the one selected solid, as Line + /// entities. pub(super) fn solid_section(&mut self, axis: usize, value: f64) -> Task { use acadrust::types::Vector3; use acadrust::Line; - use truck_meshalgo::analyzers::Collision; - use truck_meshalgo::tessellation::{MeshableShape, MeshedShape}; - use truck_modeling::{builder, Point3, Shell, Wire}; let i = self.active_tab; let handles: Vec = self.tabs[i] @@ -491,69 +454,18 @@ impl super::OpenCADStudio { return Task::none(); } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); - let wires = solid_model::edge_wires(&solid); - let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); - for w in &wires { - for p in &w.points { - let c = [p.x, p.y, p.z]; - for k in 0..3 { - min[k] = min[k].min(c[k]); - max[k] = max[k].max(c[k]); - } - } - } - if min[0] > max[0] { + let Some((min, max)) = solid_model::extent(&solid) else { self.command_line .push_error(crate::t!("SECTION: could not determine the solid's extent.").as_ref()); return Task::none(); - } - // Margin so the cutting plane fully spans the solid in the free axes. - let m = [ - (max[0] - min[0]).max(1.0) * 0.1, - (max[1] - min[1]).max(1.0) * 0.1, - (max[2] - min[2]).max(1.0) * 0.1, - ]; - let lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]]; - let hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]]; - let corners: [Point3; 4] = match axis { - 0 => [ - Point3::new(value, lo[1], lo[2]), - Point3::new(value, hi[1], lo[2]), - Point3::new(value, hi[1], hi[2]), - Point3::new(value, lo[1], hi[2]), - ], - 1 => [ - Point3::new(lo[0], value, lo[2]), - Point3::new(hi[0], value, lo[2]), - Point3::new(hi[0], value, hi[2]), - Point3::new(lo[0], value, hi[2]), - ], - _ => [ - Point3::new(lo[0], lo[1], value), - Point3::new(hi[0], lo[1], value), - Point3::new(hi[0], hi[1], value), - Point3::new(lo[0], hi[1], value), - ], }; - let v: Vec<_> = corners.iter().map(|p| builder::vertex(*p)).collect(); - let wire: Wire = vec![ - builder::line(&v[0], &v[1]), - builder::line(&v[1], &v[2]), - builder::line(&v[2], &v[3]), - builder::line(&v[3], &v[0]), - ] - .into_iter() - .collect(); - let Ok(face) = builder::try_attach_plane(&[wire]) else { + // The plane has to actually reach the solid to cut it. + if value < min[axis] || value > max[axis] { self.command_line - .push_error(crate::t!("SECTION: could not build the cutting plane.").as_ref()); + .push_output(crate::t!("SECTION: the plane does not cross the solid.").as_ref()); return Task::none(); - }; - let shell: Shell = std::iter::once(face).collect(); - let tol = 0.02; - let solid_mesh = solid.triangulation(tol).to_polygon(); - let plane_mesh = shell.triangulation(tol).to_polygon(); - let segs = solid_mesh.extract_interference(&plane_mesh); + } + let segs = solid_model::section(&solid, axis, value); if segs.is_empty() { self.command_line .push_output(crate::t!("SECTION: the plane does not cross the solid.").as_ref()); @@ -562,8 +474,8 @@ impl super::OpenCADStudio { self.push_undo_snapshot(i, "SECTION"); for (p1, p2) in &segs { let line = Line::from_points( - Vector3::new(p1.x, p1.y, p1.z), - Vector3::new(p2.x, p2.y, p2.z), + Vector3::new(p1[0], p1[1], p1[2]), + Vector3::new(p2[0], p2[1], p2[2]), ); self.tabs[i].scene.add_entity(EntityType::Line(line)); } @@ -577,18 +489,11 @@ impl super::OpenCADStudio { Task::none() } - /// Without `solid3d` (e.g. wasm) there is no mesh-interference kernel. - #[cfg(not(feature = "solid3d"))] - pub(super) fn solid_section(&mut self, _axis: usize, _value: f64) -> Task { - self.command_line - .push_error(crate::t!("SECTION: solid modelling is unavailable in this build.").as_ref()); - Task::none() - } - - /// PYRAMID — create an `n`-sided pyramid (regular polygon base of the given - /// circumradius, apex at `height`) as a tessellated mesh, reusing the same - /// face→Shell→mesh path as LOFT. Each face is independent, so no closed-shell - /// topology is required; the windings give outward normals. + /// PYRAMID — create an `n`-sided pyramid: a regular polygon base of the + /// given circumradius with its apex at `height`. + /// + /// A real B-rep rather than a bag of faces, so it joins the boolean tools + /// and the exact-geometry save path like every other primitive. pub(super) fn solid_pyramid( &mut self, radius: f64, @@ -596,90 +501,24 @@ impl super::OpenCADStudio { sides: usize, ) -> Task { use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::convert::truck_tess; - use crate::scene::model::mesh_model::MeshModel; - use truck_modeling::{builder, Point3, Shell, Wire}; let i = self.active_tab; let n = sides.max(3); - if radius <= 0.0 || height <= 0.0 { + let Some(solid) = solid_model::pyramid_solid([0.0; 3], radius, height, n) else { self.command_line .push_error(crate::t!("PYRAMID: radius and height must be positive.").as_ref()); return Task::none(); - } - let corners: Vec = (0..n) - .map(|k| { - let a = std::f64::consts::TAU * k as f64 / n as f64; - Point3::new(radius * a.cos(), radius * a.sin(), 0.0) - }) - .collect(); - let apex = builder::vertex(Point3::new(0.0, 0.0, height)); - let mut faces = Vec::new(); - // Base: reversed winding so the normal points down (outward at the base). - { - let bv: Vec<_> = corners.iter().rev().map(|p| builder::vertex(*p)).collect(); - let wire: Wire = (0..n) - .map(|k| builder::line(&bv[k], &bv[(k + 1) % n])) - .collect(); - if let Ok(f) = builder::try_attach_plane(&[wire]) { - faces.push(f); - } - } - // Side triangles [corner k, corner k+1, apex] → outward normals. - let sv: Vec<_> = corners.iter().map(|p| builder::vertex(*p)).collect(); - for k in 0..n { - let k1 = (k + 1) % n; - let wire: Wire = vec![ - builder::line(&sv[k], &sv[k1]), - builder::line(&sv[k1], &apex), - builder::line(&apex, &sv[k]), - ] - .into_iter() - .collect(); - if let Ok(f) = builder::try_attach_plane(&[wire]) { - faces.push(f); - } - } - if faces.len() < n + 1 { - self.command_line - .push_error(crate::t!("PYRAMID: could not build all faces.").as_ref()); - return Task::none(); - } - let shell = Shell::from(faces); - let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let truck_tess::TruckTessResult::Mesh { - verts, - verts_low, - normals, - indices, - } = truck_tess::tessellate_shell(&shell) - else { - self.command_line - .push_error(crate::t!("PYRAMID: tessellation failed.").as_ref()); - return Task::none(); }; - if verts.is_empty() { - self.command_line - .push_error(crate::t!("PYRAMID: tessellation produced no geometry.").as_ref()); - return Task::none(); - } self.push_undo_snapshot(i, "PYRAMID"); - let new_handle = self.tabs[i].scene.add_entity(empty_solid3d()); - let mesh = MeshModel { - name: format!("{}", new_handle.value()), - verts, - verts_low, - normals, - indices, - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - }; - self.tabs[i] - .scene - .meshes - .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); + let mut entity = empty_solid3d(); + if let EntityType::Solid3D(inner) = &mut entity { + inner.wires = solid_model::edge_wires(&solid); + } + let handle = self.add_solid_model(entity, solid); + 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(); self.command_line.push_output(crate::tf!( diff --git a/src/command.rs b/src/command.rs index dca1fc0b..e24dc1c9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1158,7 +1158,7 @@ pub enum CmdResult { /// rendering). Ends the command. CommitSolid { entity: EntityType, - solid: Box, + 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). diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 1a78fd45..9e72918c 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -11,7 +11,7 @@ use acadrust::entities::Solid3D; use acadrust::{primitives, EntityType}; use glam::DVec3; use crate::t; -use truck_modeling::Solid; +use acadrust::kernel::brep::Body; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::scene::model::solid_model; @@ -100,9 +100,9 @@ impl PrimitiveCommand { .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)> { + /// Build both the acadrust `Solid3D` (ACIS, for persistence) and the + /// kernel `Body` (rendering + booleans) from the footprint + `height`. + fn build(&self, height: f64) -> Option<(EntityType, Body)> { let (doc, solid) = match self.shape { Shape::Box | Shape::Wedge => { let (a, b) = (self.pts[0], self.pts[1]); @@ -174,6 +174,7 @@ impl PrimitiveCommand { ) } }; + let solid = solid?; let mut s3d = Solid3D::new(); s3d.set_sat_document(&doc); // Edge wires make the solid click-pickable and draw a wireframe over @@ -185,27 +186,26 @@ impl PrimitiveCommand { fn commit(&self, height: f64) -> CmdResult { match self.build(height) { Some((entity, solid)) => { - let matrix = truck_modeling::Matrix4::new( - self.plane.x.x, - self.plane.x.y, - self.plane.x.z, - 0.0, - self.plane.y.x, - self.plane.y.y, - self.plane.y.z, - 0.0, - self.plane.z.x, - self.plane.z.y, - self.plane.z.z, - 0.0, - self.plane.origin.x, - self.plane.origin.y, - self.plane.origin.z, - 1.0, + // Built upright in its own frame, then put on the working + // plane — the same move `place_entity` makes for the ACIS + // copy, so the two stay on top of each other. + let placed = solid_model::placed( + &solid, + [self.plane.x.x, self.plane.x.y, self.plane.x.z], + [self.plane.y.x, self.plane.y.y, self.plane.y.z], + [self.plane.z.x, self.plane.z.y, self.plane.z.z], + [ + self.plane.origin.x, + self.plane.origin.y, + self.plane.origin.z, + ], ); - CmdResult::CommitSolid { - entity: self.plane.place_entity(entity), - solid: Box::new(truck_modeling::builder::transformed(&solid, matrix)), + match placed { + Some(placed) => CmdResult::CommitSolid { + entity: self.plane.place_entity(entity), + solid: Box::new(placed), + }, + None => CmdResult::Cancel, } } None => CmdResult::Cancel, diff --git a/src/scene/convert/acis_export.rs b/src/scene/convert/acis_export.rs index c23479f0..ffaabcb7 100644 --- a/src/scene/convert/acis_export.rs +++ b/src/scene/convert/acis_export.rs @@ -1,131 +1,32 @@ -//! Export a truck B-rep `Solid` to an exact ACIS `SatDocument`. +//! Export a B-rep [`Body`] to an exact ACIS `SatDocument`. //! -//! OCS models 3-D solids with truck (`scene.solid_models`), but EXTRUDE / -//! REVOLVE / SWEEP / LOFT / boolean results carry no ACIS geometry, so other -//! CAD applications drop them on open. Converting the truck solid to ACIS lets -//! the DWG/DXF writer store real modeler geometry. +//! A solid built by the Model tab, by EXTRUDE / REVOLVE, or by a boolean +//! carries no ACIS geometry of its own, so other CAD applications drop it on +//! open. Writing the body out as real modeller geometry is what stops that. //! -//! This module handles the **planar** case: a solid whose faces are all planes -//! bounded by straight edges (boxes, extruded polygons, planar boolean results). -//! It walks the topology into a vertex/face-ring mesh and hands it to acadrust's -//! [`build_planar_body`], which assembles the exact B-rep. Any curved face or -//! edge makes it return `None` — those solids are left for the NURBS path. +//! # Exact, not facetted +//! +//! The kernel keeps analytic surfaces — a cylinder is a `cylinder` record and +//! a sphere a `sphere` — so what lands in the document is the shape itself +//! rather than a triangulation of it. That is the whole reason the Model tab +//! builds bodies at all: a facetted export is a one-way door, and the next +//! application to open the file would have no way back to the circle. +//! +//! The writing is [`acis::append`], which lives with the codec because it has +//! to know both sides. What is left here is the small matter of putting one +//! body into a fresh document. -use std::collections::HashMap; +use acadrust::acis::append; +use acadrust::entities::acis::SatDocument; +use acadrust::kernel::brep::Body; -use acadrust::entities::acis::{primitives::build_planar_body, SatDocument}; -use truck_modeling::{Curve, Solid, Surface}; - -/// Signed volume × 6 of the closed mesh (positive when every face ring is wound -/// counter-clockwise as seen from outside). Used to make the export independent -/// of truck's face-orientation convention. -fn signed_volume6(vertices: &[[f64; 3]], faces: &[Vec]) -> f64 { - let mut acc = 0.0; - for f in faces { - let a = vertices[f[0]]; - // Fan-triangulate the (planar, convex-or-not) ring from its first vertex. - for i in 1..f.len() - 1 { - let b = vertices[f[i]]; - let c = vertices[f[i + 1]]; - acc += a[0] * (b[1] * c[2] - b[2] * c[1]) - - a[1] * (b[0] * c[2] - b[2] * c[0]) - + a[2] * (b[0] * c[1] - b[1] * c[0]); - } - } - acc -} - -/// Build an exact ACIS `SatDocument` from a truck solid whose faces are all -/// planar and edges all straight. Returns `None` for any curved face/edge (left -/// for the NURBS export), for faces with holes, or for a degenerate body. -pub fn planar_solid_to_sat(solid: &Solid) -> Option { - let mut positions: Vec<[f64; 3]> = Vec::new(); - let mut vert_index: HashMap<_, usize> = HashMap::new(); - let mut faces: Vec> = Vec::new(); - - for shell in solid.boundaries() { - for face in shell.face_iter() { - // Planar faces only — a curved surface needs the NURBS path. - if !matches!(face.surface(), Surface::Plane(_)) { - return None; - } - // Single outer loop only (no holes) in the planar path. - let boundaries = face.boundaries(); - if boundaries.len() != 1 { - return None; - } - let wire = &boundaries[0]; - - // Every bounding edge must be a straight line. - for edge in wire.edge_iter() { - if !matches!(edge.curve(), Curve::Line(_)) { - return None; - } - } - - // Ordered vertex ring of the loop. - let mut ring: Vec = Vec::new(); - for v in wire.vertex_iter() { - let key = v.id(); - let p = v.point(); - let idx = *vert_index.entry(key).or_insert_with(|| { - positions.push([p.x, p.y, p.z]); - positions.len() - 1 - }); - // Drop a closing vertex that repeats the ring's start. - if ring.last() != Some(&idx) { - ring.push(idx); - } - } - if ring.len() >= 2 && ring.first() == ring.last() { - ring.pop(); - } - if ring.len() < 3 { - return None; - } - faces.push(ring); - } - } - - if faces.len() < 4 || positions.len() < 4 { - return None; - } - - // Make the winding outward-CCW regardless of truck's convention: a - // correctly-oriented closed shell has positive signed volume; if it came out - // negative every ring is inside-out, so reverse them all. - if signed_volume6(&positions, &faces) < 0.0 { - for f in faces.iter_mut() { - f.reverse(); - } - } - - build_planar_body(&positions, &faces) -} - -#[cfg(test)] -mod tests { - use super::*; - use truck_modeling::builder; - use truck_modeling::{Point3, Vector3}; - - /// A truck box (dimension-raising sweep vertex→edge→face→solid) must export - /// to a valid 6-face / 12-edge / 8-vertex ACIS body. - #[test] - fn box_exports_to_valid_planar_sat() { - let p = builder::vertex(Point3::new(0.0, 0.0, 0.0)); - let e = builder::tsweep(&p, Vector3::new(2.0, 0.0, 0.0)); - let f = builder::tsweep(&e, Vector3::new(0.0, 2.0, 0.0)); - let solid = builder::tsweep(&f, Vector3::new(0.0, 0.0, 2.0)); - - let sat = planar_solid_to_sat(&solid).expect("box should export to SAT"); - assert_eq!(sat.faces().len(), 6, "box has 6 planar faces"); - assert_eq!(sat.edges().len(), 12, "box has 12 straight edges"); - assert_eq!(sat.vertices().len(), 8, "box has 8 vertices"); - assert!( - sat.validate().is_empty(), - "exported box failed ACIS validation: {:?}", - sat.validate() - ); - } +/// An exact ACIS document holding `body`, or `None` when the kernel has no +/// record form for something in it — a spline curve, at present. +/// +/// Refusing is the point: a document missing a face is one that parses into a +/// different solid, and nothing downstream could tell. +pub fn planar_solid_to_sat(body: &Body) -> Option { + let mut document = SatDocument::new(); + append(body, &mut document).ok()?; + Some(document) } diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs new file mode 100644 index 00000000..c8c20b6a --- /dev/null +++ b/src/scene/convert/acis_kernel.rs @@ -0,0 +1,128 @@ +//! Drawing an ACIS solid by lifting it into the geometry kernel. +//! +//! A DWG or DXF carries its solids as ACIS records — analytic surfaces and the +//! topology joining them — and the kernel holds exactly that. So the shortest +//! path from a file to a picture is to lift the document into a `Body` and ask +//! the kernel for triangles, rather than to re-derive each surface's extent by +//! sampling it. +//! +//! # Why it can still fall short +//! +//! A face on a surface the kernel does not model, a curve it has no form for, +//! a pointer graph that does not hold together: [`lift`] reports each as a +//! [`Loss`] rather than quietly dropping it. What comes back then is a body +//! with faces missing, and the mesh it makes has holes — which is why the +//! result is marked incomplete and the caller keeps its own sampler for those. +//! +//! Saying so is the point. A partial mesh that claimed to be whole would show +//! a solid with a wall missing and nothing to suggest anything was wrong. + +use acadrust::acis::lift; +use acadrust::entities::acis::SatDocument; +use acadrust::kernel::brep; + +use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; + +/// How far a triangle may sit from the surface it lies on. +const SAG: f64 = 0.05; + +/// What counts as the same point when the kernel reads a body over. +const TOL: f64 = 1e-9; + +/// Tessellate an ACIS document by lifting it into the kernel. +/// +/// `None` when nothing in the document lifts at all. The result's `complete` +/// flag says whether every face made it; a caller with a fallback sampler +/// uses it to decide whether to run one. +pub fn tessellate_sat( + document: &SatDocument, + name: String, + color: [f32; 4], + sag: f64, +) -> Option { + let (bodies, loss) = lift(document); + if bodies.is_empty() { + return None; + } + let sag = if sag > 0.0 { sag } else { SAG }; + + let mut positions: Vec<[f64; 3]> = Vec::new(); + let mut normals: Vec<[f32; 3]> = Vec::new(); + let mut indices: Vec = Vec::new(); + // A face the kernel holds but cannot express in its surface's own + // parameters leaves a hole, the same as one that never lifted — so both + // are counted before calling the mesh whole. + let mut undrawn = 0usize; + for body in &bodies { + for face in body.face_keys() { + let Some(mesh) = brep::mesh::face(body, face, sag, TOL) else { + undrawn += 1; + continue; + }; + let base = positions.len() as u32; + positions.extend_from_slice(&mesh.positions); + normals.extend( + mesh.normals + .iter() + .map(|n| [n[0] as f32, n[1] as f32, n[2] as f32]), + ); + indices.extend( + mesh.triangles + .iter() + .flat_map(|t| [base + t[0] as u32, base + t[1] as u32, base + t[2] as u32]), + ); + } + } + if indices.is_empty() { + return None; + } + + // The renderer holds each position as a coarse float plus a fine + // correction, so a solid at survey coordinates keeps its last millimetres + // instead of losing them to f32. + let mut verts = Vec::with_capacity(positions.len()); + let mut verts_low = Vec::with_capacity(positions.len()); + for point in &positions { + let high = [point[0] as f32, point[1] as f32, point[2] as f32]; + verts.push(high); + verts_low.push([ + (point[0] - high[0] as f64) as f32, + (point[1] - high[1] as f64) as f32, + (point[2] - high[2] as f64) as f32, + ]); + } + + let mut set = MeshLodSet::from_single(MeshModel { + name, + verts, + verts_low, + normals, + indices, + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color, + selected: false, + }); + set.complete = loss.is_empty() && undrawn == 0; + Some(set) +} + +/// The edges of every body in an ACIS document, as polylines. +/// +/// What draws a solid's wireframe and what a click hit-tests against. Taken +/// from the kernel's own curves rather than from the mesh, so a rim is a +/// circle sampled to tolerance instead of whatever the triangulation left +/// along it. +/// +/// Not called yet: the solid tessellator keeps its own feature-edge pass, +/// which also carries isolines. Here because it is the kernel's answer to the +/// same question, and the two should converge on it. +#[allow(dead_code)] +pub fn edge_polylines(document: &SatDocument, sag: f64) -> Vec> { + let (bodies, _) = lift(document); + let sag = if sag > 0.0 { sag } else { SAG }; + bodies + .iter() + .flat_map(|body| brep::edge_polylines(body, sag)) + .collect() +} diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs deleted file mode 100644 index 20c6afe2..00000000 --- a/src/scene/convert/acis_to_truck.rs +++ /dev/null @@ -1,679 +0,0 @@ -// ACIS B-rep → truck B-rep → mesh. -// -// Rebuilds every ACIS face as a truck topological `Face` and lets truck's -// meshing kernel triangulate it, routing loaded 3DSOLID / BODY / REGION / -// SURFACE geometry through the same NURBS/B-rep kernel the Model tab builds on -// instead of the bespoke per-surface sampler in `solid3d_tess`. -// -// plane-surface → planar face from the sampled boundary loop -// cone-surface → partial revolutions via rsweep; closed revolutions use -// the parametric sampler -// sphere-surface → bespoke sampler, clipped to the boundary window (truck -// builds a full ball and cannot trim it to a partial face) -// torus-surface → bespoke sampler, clipped to the revolution arc between -// the tube's end caps (truck builds a full closed ring) -// spline-surface → truck BSplineSurface, grid-sampled (see spline_tess) -// -// Each face is meshed independently and its triangles are oriented outward -// using an analytic per-surface normal — truck's own face orientation is not -// consistent across independently built faces, so normals/winding are derived -// from geometry instead. Face coverage is recorded on `MeshLodSet`; partial -// shells remain displayable but cannot masquerade as complete solid topology. - -use truck_meshalgo::tessellation::{MeshableShape, MeshedShape}; -use truck_modeling::{builder, Face, InnerSpace, Point3, Rad, Shell, Vector3, Wire}; -use truck_polymesh::PolygonMesh; - -use acadrust::entities::acis::types::Sense; -use acadrust::entities::acis::{ - SatConeSurface, SatDocument, SatFace, SatPlaneSurface, SatSphereSurface, SatTorusSurface, -}; - -use crate::scene::convert::solid3d_tess::{ - body_transform, collect_face_loops, cone_axis_span, cone_radius, finalize_mesh, - tess_cone_face, tess_plane_face, tess_sphere_face, tess_torus_face, LodConfig, - BOUNDARY_CHORD_FRAC, TRUCK_CHORD_FRAC, -}; -use crate::scene::model::mesh_model::MeshLodSet; -use rustc_hash::FxHashMap; - -/// Slightly over 2π so revolution builders close the loop. -const FULL: f64 = std::f64::consts::TAU + 0.2; -/// Triangle mesh chord tolerance (world units) for planar faces, where the -/// surface itself adds no curvature. -const MESH_TOL: f64 = 0.01; - -/// Analytic outward-normal rule for a face, used to orient triangles and -/// supply smooth per-vertex normals (truck's face orientation is unreliable -/// for independently built faces). -enum Outward { - /// Constant normal (planar face). - Const([f64; 3]), - /// Cone/cylinder: radial away from the axis, tilted by the half-angle. - Cone { - center: [f64; 3], - axis: [f64; 3], - sin: f64, - cos: f64, - }, -} - -impl Outward { - fn at(&self, p: [f64; 3]) -> [f64; 3] { - match self { - Outward::Const(n) => *n, - Outward::Cone { - center, - axis, - sin, - cos, - } => { - let d = vsub(p, *center); - let h = vdot(d, *axis); - let radial = vnorm(vsub(d, vscale(*axis, h))); - vnorm(vsub(vscale(radial, *cos), vscale(*axis, *sin))) - } - } - } -} - -/// Tessellate an ACIS SAT document by rebuilding it as truck faces. -/// -/// Returns `None` when no face could be rebuilt (caller should fall back to -/// the bespoke sampler). -pub fn tessellate_sat_truck( - sat: &SatDocument, - name: String, - color: [f32; 4], - _facet_res: f64, -) -> Option { - // Vertices accumulate in f64 world/local space; `finalize_mesh` splits them - // into the double-single (high, low) pair once, so a solid at UTM scale - // keeps full precision instead of quantizing to the f32 grid. - let mut verts: Vec<[f64; 3]> = Vec::new(); - let mut normals: Vec<[f32; 3]> = Vec::new(); - let mut indices: Vec = Vec::new(); - let face_materials: FxHashMap = sat - .records - .iter() - .filter(|record| record.entity_type == "material-adesk-attrib") - .filter_map(|record| { - let owner = record.token_pointer(2)?.0; - let handle = record.token(3)?.as_integer()?; - (owner >= 0 && handle > 0) - .then(|| (owner, acadrust::Handle::new(handle as u64))) - }) - .collect(); - let face_colors: FxHashMap = sat - .records - .iter() - .filter(|record| record.entity_type == "color-adesk-attrib") - .filter_map(|record| { - let owner = record.token_pointer(2)?.0; - let value = record.token(3)?.as_integer()?; - let face_color = if (1..=255).contains(&value) { - acadrust::Color::from_index(value as i16) - } else if value > 257 { - acadrust::Color::from_true_color_value(value as i32) - } else { - return None; - }; - Some(( - owner, - { - let mut rgba = - crate::scene::convert::tess_util::aci_to_rgba(&face_color); - rgba[3] = color[3]; - rgba - }, - )) - }) - .collect(); - let mut triangle_material_handles: Vec> = Vec::new(); - let mut triangle_colors: Vec> = Vec::new(); - let face_record_indices: Vec = sat - .records - .iter() - .filter(|record| record.is_a("face")) - .map(|record| record.index) - .collect(); - let mut complete = true; - - for (face, face_record_index) in sat.faces().into_iter().zip(face_record_indices) { - let Some(surf_rec) = sat.resolve(face.surface()) else { - complete = false; - continue; - }; - let before = indices.len(); - let mut appended = false; - if let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) { - if !faces.is_empty() { - let shell: Shell = faces.into(); - let poly = shell.triangulation(tol).to_polygon(); - if !poly.tri_faces().is_empty() && cone_sweep_covered(sat, &face, surf_rec, &poly) - { - append_group(&mut verts, &mut normals, &mut indices, &poly, &outward); - appended = true; - } - } - } - // Truck's rsweep/triangulation degenerates to zero triangles on some - // short-arc cones (a curved wall face whose profile passes through the - // local origin), leaving a see-through gap. Fall back to the bespoke - // parametric sampler for just that face — it writes into the same - // buffers, so the shared finalize below still applies uniformly. - if !appended { - bespoke_face(sat, &face, surf_rec, &mut verts, &mut normals, &mut indices); - appended = indices.len() > before; - } - if !appended { - complete = false; - } else { - let material = face_materials.get(&face_record_index).copied(); - triangle_material_handles.extend( - std::iter::repeat(material).take((indices.len() - before) / 3), - ); - let face_color = face_colors.get(&face_record_index).copied(); - triangle_colors.extend( - std::iter::repeat(face_color).take((indices.len() - before) / 3), - ); - } - } - - if indices.is_empty() { - return None; - } - - let mesh = finalize_mesh( - name, - verts, - normals, - indices, - triangle_material_handles, - triangle_colors, - color, - body_transform(sat), - ); - let mut set = MeshLodSet::from_lods(vec![mesh]); - set.complete = complete; - Some(set) -} - -/// Fill one face with the bespoke parametric sampler (body-local verts into the -/// shared mesh buffers) when truck produced no triangles for it. Mirrors the -/// surface dispatch of the standalone sampler. -fn bespoke_face( - sat: &SatDocument, - face: &SatFace, - surf_rec: &acadrust::entities::acis::SatRecord, - v: &mut Vec<[f64; 3]>, - n: &mut Vec<[f32; 3]>, - i: &mut Vec, -) { - match surf_rec.entity_type.as_str() { - "plane-surface" => { - if let Some(p) = SatPlaneSurface::from_record(surf_rec) { - tess_plane_face(sat, face, &p, LodConfig::HIGH.chord_frac, v, n, i); - } - } - "cone-surface" => { - if let Some(c) = SatConeSurface::from_record(surf_rec) { - tess_cone_face(sat, face, &c, LodConfig::HIGH, v, n, i); - } - } - "sphere-surface" => { - if let Some(s) = SatSphereSurface::from_record(surf_rec) { - tess_sphere_face(sat, face, &s, LodConfig::HIGH, v, n, i); - } - } - "torus-surface" => { - if let Some(t) = SatTorusSurface::from_record(surf_rec) { - tess_torus_face(sat, face, &t, LodConfig::HIGH, v, n, i); - } - } - "spline-surface" | "meshsurf-surface" | "bs3-surface" => { - crate::scene::convert::spline_tess::tess_spline_face( - sat, - face, - LodConfig::HIGH, - v, - n, - i, - ); - } - _ => {} - } -} - -/// Build the truck face(s) + outward rule for one analytic ACIS face. -fn build_face_group( - sat: &SatDocument, - face: &SatFace, - surf_rec: &acadrust::entities::acis::SatRecord, -) -> Option<(Vec, Outward, f64)> { - // Chord tolerance scaled to a curved surface's radius, so the facet count - // is radius-independent (matching the circle/arc wire tessellation) rather - // than exploding on large radii. - let curve_tol = |radius: f64| (radius.abs() * TRUCK_CHORD_FRAC).max(1e-6); - match surf_rec.entity_type.as_str() { - "plane-surface" => { - let plane = SatPlaneSurface::from_record(surf_rec)?; - let f = plane_face(sat, face)?; - let (nx, ny, nz) = plane.normal(); - let n = if matches!(face.sense(), Sense::Reversed) { - [-nx, -ny, -nz] - } else { - [nx, ny, nz] - }; - Some((vec![f], Outward::Const(vnorm(n)), MESH_TOL)) - } - "cone-surface" => { - let cone = SatConeSurface::from_record(surf_rec)?; - let tol = curve_tol(cone_radius(&cone)); - let (faces, out) = cone_faces(sat, face, &cone)?; - Some((faces, out, tol)) - } - // Truck builds a sphere as a full revolution and cannot trim it to the - // face's boundary, so a partial sphere renders as a whole ball. Route - // sphere faces to the bespoke sampler instead — it meshes only the - // boundary's parametric window (see `tess_sphere_face`). - "sphere-surface" => None, - // Like a sphere, truck builds a torus as a full revolution and cannot - // trim it to the face's boundary, so an open "C" tube renders as a whole - // closed ring. Route torus faces to the bespoke sampler, which meshes - // only the revolution arc between the tube's end caps (see - // `tess_torus_face` / `torus_phi_range`). - "torus-surface" => None, - _ => None, - } -} - -// ── Planar face ──────────────────────────────────────────────────────────── - -/// Build a planar truck face from a face's sampled boundary loop. Curved -/// boundary edges (circles) are sampled into line segments, which keeps the -/// wire planar so `try_attach_plane` can fit the plane. -fn plane_face(sat: &SatDocument, face: &SatFace) -> Option { - // A pierced face (e.g. a wall with a window opening) has one outer boundary - // loop plus an inner loop per hole. `try_attach_plane` fits the plane from - // the first wire and cuts the rest as holes, letting truck's mesher trim - // them. ACIS records no outer-vs-hole flag — the kernel classifies loops at - // runtime from geometry — so we derive the outer boundary here. (#123) - let loops = collect_face_loops(sat, face, BOUNDARY_CHORD_FRAC); - if loops.is_empty() { - return None; - } - // The outer boundary is the loop that encloses the rest — i.e. the one with - // the largest area. ACIS does not guarantee it comes first in the face's - // loop list (a pierced wall lists its window holes before the wall edge), - // so pick it by area rather than trusting index 0. (#123) - let outer_idx = (0..loops.len()) - .max_by(|&a, &b| { - vlen(loop_normal(&loops[a])) - .partial_cmp(&vlen(loop_normal(&loops[b]))) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .unwrap(); - if loops[outer_idx].len() < 3 { - return None; - } - // `try_attach_plane` fits its plane normal from the first (outer) wire, so - // feeding the outer in its sampled order makes truck read it as CCW. truck - // then cuts an inner wire as a hole only when it winds the *opposite* way, - // so reverse any hole loop whose area normal agrees with the outer's. The - // outer normal has the largest magnitude (largest area), keeping the sign - // test robust against sampling noise on small holes. (#123) - let outer_n = loop_normal(&loops[outer_idx]); - let mut wires: Vec = Vec::new(); - wires.push(polygon_wire(&loops[outer_idx], false)?); - for (i, lp) in loops.iter().enumerate() { - if i == outer_idx { - continue; - } - let same = vdot(loop_normal(lp), outer_n) > 0.0; - if let Some(w) = polygon_wire(lp, same) { - wires.push(w); - } - } - builder::try_attach_plane(&wires).ok() -} - -fn polygon_wire(pts: &[[f64; 3]], reverse: bool) -> Option { - if pts.len() < 3 { - return None; - } - let verts: Vec<_> = if reverse { - pts.iter() - .rev() - .map(|p| builder::vertex(Point3::new(p[0], p[1], p[2]))) - .collect() - } else { - pts.iter() - .map(|p| builder::vertex(Point3::new(p[0], p[1], p[2]))) - .collect() - }; - let n = verts.len(); - let edges: Vec<_> = (0..n) - .map(|i| builder::line(&verts[i], &verts[(i + 1) % n])) - .collect(); - Some(edges.into()) -} - -/// Newell area-weighted normal of a closed 3-D polygon (orientation only). -fn loop_normal(pts: &[[f64; 3]]) -> [f64; 3] { - let mut n = [0.0f64; 3]; - let m = pts.len(); - for i in 0..m { - let a = pts[i]; - let b = pts[(i + 1) % m]; - n[0] += (a[1] - b[1]) * (a[2] + b[2]); - n[1] += (a[2] - b[2]) * (a[0] + b[0]); - n[2] += (a[0] - b[0]) * (a[1] + b[1]); - } - n -} - -// ── Cone / cylinder face ───────────────────────────────────────────────────── - -/// Build a pierced cone/cylinder lateral surface as revolution faces. A -/// single-loop face uses the parametric sampler, avoiding partial revolution -/// segments that can disappear when the profile crosses a coordinate plane. -fn cone_faces( - sat: &SatDocument, - face: &SatFace, - cone: &SatConeSurface, -) -> Option<(Vec, Outward)> { - let (cx, cy, cz) = cone.center(); - let (ax, ay, az) = cone.axis(); - let (ux, uy, uz) = cone.major_axis(); - let radius = cone_radius(cone); - let sin = cone.sin_half_angle(); - let cos = cone.cos_half_angle(); - - let axis = norm(Vector3::new(ax, ay, az)); - let udir = norm(Vector3::new(ux, uy, uz)); - // v = axis × u completes the right-handed frame; angles increase from u - // toward v (CCW about the axis), matching `builder::rsweep`. - let vdir = Vector3::new( - axis.y * udir.z - axis.z * udir.y, - axis.z * udir.x - axis.x * udir.z, - axis.x * udir.y - axis.y * udir.x, - ); - let center = Point3::new(cx, cy, cz); - - let (hmin, hmax) = - cone_axis_span(sat, face, cone, [axis.x, axis.y, axis.z], [cx, cy, cz])?; - let r_at = |h: f64| { - if cos.abs() > 1e-9 { - radius + h * sin / cos - } else { - radius - } - }; - - let loops = collect_face_loops(sat, face, BOUNDARY_CHORD_FRAC); - if loops.len() == 1 { - return None; - } - let arcs: Vec<(f64, f64)> = loops - .iter() - .map(|lp| cone_boundary_arc(lp, [cx, cy, cz], axis, udir, vdir)) - .collect(); - let outer_idx = arcs - .iter() - .enumerate() - .max_by(|(_, a), (_, b)| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i)?; - let (theta0, sweep) = arcs[outer_idx]; - let closed = sweep >= std::f64::consts::TAU; - let hole_indices: Vec = arcs - .iter() - .enumerate() - .filter_map(|(i, arc)| (i != outer_idx && arc.1 < std::f64::consts::TAU).then_some(i)) - .collect(); - if closed && hole_indices.is_empty() { - return None; - } - // Radial direction at the arc's start angle (u rotated by theta0 about axis). - let rad0 = udir * theta0.cos() + vdir * theta0.sin(); - - let p0 = center + rad0 * r_at(hmin) + axis * hmin; - let p1 = center + rad0 * r_at(hmax) + axis * hmax; - let profile = builder::line(&builder::vertex(p0), &builder::vertex(p1)); - let shell: Shell = builder::rsweep(&profile, center, axis, Rad(sweep)); - let mut faces: Vec = shell.face_iter().cloned().collect(); - if closed { - let lateral = faces.first_mut()?; - for index in hole_indices { - let wire = polygon_wire(&loops[index], false)?; - lateral.try_add_boundary(wire).ok()?; - } - } - - let out = Outward::Cone { - center: [cx, cy, cz], - axis: [axis.x, axis.y, axis.z], - sin, - cos, - }; - Some((faces, out)) -} - -/// Whether a triangulated cone face actually spans the revolution its boundary -/// claims. -/// -/// `rsweep` returns a full turn as several segment faces (120° apiece), and a -/// hole only ever lands on one of them: a window wider than a segment, or a -/// segment truck degenerates on, silently costs that whole third of the tube. -/// The face still reports triangles, so nothing downstream notices — the wall is -/// simply missing there and the solid reads as open. Measuring the meshed -/// coverage against the boundary catches every such loss, whatever caused it, -/// and hands the face to the parametric sampler instead. -/// -/// Non-cone faces and genuinely partial cones pass straight through. -fn cone_sweep_covered( - sat: &SatDocument, - face: &SatFace, - surf_rec: &acadrust::entities::acis::SatRecord, - poly: &PolygonMesh, -) -> bool { - use std::f64::consts::TAU; - /// A meshed full revolution may skip at most this much before we call it - /// torn — comfortably wider than one chord step, far under a 120° segment. - const MAX_GAP: f64 = std::f64::consts::FRAC_PI_4; - - let Some(cone) = SatConeSurface::from_record(surf_rec) else { - return true; - }; - let (cx, cy, cz) = cone.center(); - let (ax, ay, az) = cone.axis(); - let (ux, uy, uz) = cone.major_axis(); - let axis = norm(Vector3::new(ax, ay, az)); - let udir = norm(Vector3::new(ux, uy, uz)); - let vdir = Vector3::new( - axis.y * udir.z - axis.z * udir.y, - axis.z * udir.x - axis.x * udir.z, - axis.x * udir.y - axis.y * udir.x, - ); - - // Only a face whose boundary closes the revolution owes us a full sweep. - let loops = collect_face_loops(sat, face, BOUNDARY_CHORD_FRAC); - let closed = loops - .iter() - .map(|lp| cone_boundary_arc(lp, [cx, cy, cz], axis, udir, vdir).1) - .fold(0.0f64, f64::max) - >= TAU; - if !closed { - return true; - } - - let mut angles: Vec = Vec::new(); - for p in poly.positions() { - let d = vsub([p.x, p.y, p.z], [cx, cy, cz]); - let ru = vdot(d, [udir.x, udir.y, udir.z]); - let rv = vdot(d, [vdir.x, vdir.y, vdir.z]); - if ru.hypot(rv) > 1e-9 { - angles.push(rv.atan2(ru).rem_euclid(TAU)); - } - } - if angles.len() < 3 { - return false; - } - angles.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let mut widest = 0.0f64; - for i in 0..angles.len() { - let next = if i + 1 < angles.len() { - angles[i + 1] - } else { - angles[0] + TAU - }; - widest = widest.max(next - angles[i]); - } - widest <= MAX_GAP -} - -/// Smallest angular arc `(theta_start, sweep)` about the cone axis enclosing the -/// face's boundary points, robust to the ±π seam. Returns `(0.0, FULL)` when the -/// points wrap the whole revolution (a closed rim) or the boundary is unusable, -/// so a real cylinder/cone still sweeps a complete (overlapped) circle. -fn cone_boundary_arc( - poly: &[[f64; 3]], - center: [f64; 3], - _axis: Vector3, - udir: Vector3, - vdir: Vector3, -) -> (f64, f64) { - use std::f64::consts::TAU; - let mut angles: Vec = Vec::new(); - for p in poly { - let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]; - let ru = d[0] * udir.x + d[1] * udir.y + d[2] * udir.z; - let rv = d[0] * vdir.x + d[1] * vdir.y + d[2] * vdir.z; - if ru.hypot(rv) > 1e-9 { - angles.push(rv.atan2(ru)); - } - } - if angles.len() < 2 { - return (0.0, FULL); - } - angles.sort_by(|a, b| a.partial_cmp(b).unwrap()); - // Largest angular gap between consecutive samples (wrapping past the seam); - // the enclosing arc is its complement. - let mut max_gap = 0.0; - let mut gap_at = angles.len() - 1; - for i in 0..angles.len() { - let a = angles[i]; - let b = if i + 1 < angles.len() { - angles[i + 1] - } else { - angles[0] + TAU - }; - let g = b - a; - if g > max_gap { - max_gap = g; - gap_at = i; - } - } - let span = TAU - max_gap; - // Nearly a full revolution → treat as a closed rim. - if span >= TAU * 0.98 - 1e-9 || span < 1e-6 { - return (0.0, FULL); - } - // Start at the sample just after the largest gap, sweep CCW by `span`. - let start = angles[(gap_at + 1) % angles.len()]; - (start, span) -} - -// ── Mesh append with analytic outward normals ──────────────────────────────── - -/// Append one face's triangulation to `mesh`, computing smooth per-vertex -/// normals from the outward rule and flipping any triangle whose winding -/// disagrees with that outward direction. -fn append_group( - verts: &mut Vec<[f64; 3]>, - normals: &mut Vec<[f32; 3]>, - out_indices: &mut Vec, - poly: &PolygonMesh, - outward: &Outward, -) { - let positions = poly.positions(); - let base = verts.len() as u32; - for p in positions { - let pos = [p.x, p.y, p.z]; - verts.push(pos); - let n = outward.at(pos); - normals.push([n[0] as f32, n[1] as f32, n[2] as f32]); - } - for tri in poly.tri_faces() { - let (i0, i1, i2) = (tri[0].pos, tri[1].pos, tri[2].pos); - let a = pt(positions[i0]); - let b = pt(positions[i1]); - let c = pt(positions[i2]); - let gn = vcross(vsub(b, a), vsub(c, a)); - let cen = [ - (a[0] + b[0] + c[0]) / 3.0, - (a[1] + b[1] + c[1]) / 3.0, - (a[2] + b[2] + c[2]) / 3.0, - ]; - let out = outward.at(cen); - let (j0, j1, j2) = (base + i0 as u32, base + i1 as u32, base + i2 as u32); - if vdot(gn, out) < 0.0 { - out_indices.extend_from_slice(&[j0, j2, j1]); - } else { - out_indices.extend_from_slice(&[j0, j1, j2]); - } - } -} - -#[inline] -fn pt(p: Point3) -> [f64; 3] { - [p.x, p.y, p.z] -} - -// ── Vector helpers (cgmath Vector3) ────────────────────────────────────────── - -#[inline] -fn norm(v: Vector3) -> Vector3 { - let len = v.magnitude(); - if len < 1e-12 { - Vector3::unit_z() - } else { - v / len - } -} - -// ── Vector helpers ([f64; 3]) ──────────────────────────────────────────────── - -#[inline] -fn vsub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { - [a[0] - b[0], a[1] - b[1], a[2] - b[2]] -} -#[inline] -fn vscale(a: [f64; 3], s: f64) -> [f64; 3] { - [a[0] * s, a[1] * s, a[2] * s] -} -#[inline] -fn vdot(a: [f64; 3], b: [f64; 3]) -> f64 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] -} -#[inline] -fn vcross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { - [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ] -} -#[inline] -fn vlen(a: [f64; 3]) -> f64 { - vdot(a, a).sqrt() -} -#[inline] -fn vnorm(a: [f64; 3]) -> [f64; 3] { - let l = vlen(a); - if l < 1e-12 { - [0.0, 0.0, 1.0] - } else { - [a[0] / l, a[1] / l, a[2] / l] - } -} diff --git a/src/scene/convert/mod.rs b/src/scene/convert/mod.rs index cfd4e59a..ea0ff3c1 100644 --- a/src/scene/convert/mod.rs +++ b/src/scene/convert/mod.rs @@ -1,23 +1,7 @@ pub mod acad_to_truck; #[cfg(feature = "solid3d")] pub mod acis_export; -#[cfg(feature = "solid3d")] -pub mod acis_to_truck; -/// Without `solid3d` (e.g. wasm) ACIS/SAT meshing is unavailable; the entry -/// point stays so callers compile, returning no mesh. -#[cfg(not(feature = "solid3d"))] -pub mod acis_to_truck { - use crate::scene::model::mesh_model::MeshLodSet; - use acadrust::entities::acis::SatDocument; - pub fn tessellate_sat_truck( - _sat: &SatDocument, - _name: String, - _color: [f32; 4], - _facet_res: f64, - ) -> Option { - None - } -} +pub mod acis_kernel; pub mod dgn_linestyle; pub mod truck_tess; pub mod tessellate; diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index ca48c6fc..df75b6be 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -46,7 +46,6 @@ pub(crate) const EDGE_CHORD_FRAC: f64 = 0.002; /// a hexagonal prism whose flats sink far inside the true radius, tearing the /// wall away from the planar faces and caps that meet it. #[cfg(feature = "solid3d")] -pub(crate) const TRUCK_CHORD_FRAC: f64 = 0.005; /// Boundary-loop sampling for parameter-range classification (which arc of a /// sphere/torus a face covers): a fine fraction so the classification is /// accurate; the points are not rendered. @@ -276,9 +275,10 @@ fn tessellate_sat( )) } -/// Tessellate an ACIS document, preferring the truck B-rep kernel and falling -/// back to the bespoke per-surface sampler when truck can't rebuild the shell -/// (e.g. an unhandled surface type). +/// Tessellate an ACIS document, preferring the geometry kernel and falling +/// back to the bespoke per-surface sampler when the kernel cannot rebuild the +/// whole shell (a surface kind it does not model, a face whose boundary it +/// cannot express in that surface's own parameters). fn tessellate_acis( sat: &SatDocument, name: String, @@ -286,7 +286,7 @@ fn tessellate_acis( facet_res: f64, isolines: usize, ) -> Option { - let truck = crate::scene::convert::acis_to_truck::tessellate_sat_truck( + let truck = crate::scene::convert::acis_kernel::tessellate_sat( sat, name.clone(), color, @@ -2049,7 +2049,7 @@ pub(crate) fn tess_plane_face( } // Fan triangulation from vertex 0 (outer loop only; holes are handled by - // the truck B-rep path in `acis_to_truck`). + // the kernel B-rep path in `acis_kernel`). let n = poly.len() as u32; for i in 1..(n - 1) { indices.extend_from_slice(&[base, base + i, base + i + 1]); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 67cfa415..947d9c93 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1779,11 +1779,12 @@ pub struct Scene { /// owning block emits a transformed instance so a block placed at an /// INSERT scale renders at the right size. (#123) pub block_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 solid_models: HashMap, + /// Live 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) and the exact-geometry save path, which writes + /// each one back out as ACIS rather than as facets. Not persisted — + /// rebuilt only by creating or combining primitives in-session. + pub solid_models: HashMap, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). @@ -2719,11 +2720,12 @@ 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_solid_model(&mut self, handle: Handle, solid: truck_modeling::Solid) { + /// Register a Model-tab solid: cache its B-rep (for boolean ops and the + /// exact-geometry save path) and tessellate it into the shaded mesh + /// pipeline under `handle`. The body 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_solid_model(&mut self, handle: Handle, solid: acadrust::kernel::brep::Body) { let entity = self.document.get_entity(handle); let color = entity .map(|e| self.render_style(e).0) diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index 42eda2d1..a53e1e7e 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -8,5 +8,6 @@ pub mod ole_pres; pub mod pdf_raster; pub mod mesh_model; pub mod solid_model; +pub mod sweep_model; pub mod visual_style_model; pub mod object; diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index d2b35e90..6047bc0a 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -1,95 +1,201 @@ -// 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`. +// B-rep construction for the Model tab's primitives, plus tessellation into +// the renderer's `MeshLodSet`. // -// The resulting truck `Solid` is cached per entity handle on the Scene so the -// Design-group boolean tools can run truck-shapeops on it. +// The bodies come from the geometry kernel, which builds each primitive the +// way ACIS records it: an analytic surface with singular vertices where the +// surface has them, rather than a mesh or a spline that happens to look like +// one. That is what lets a solid built here be written back out as exact +// geometry instead of a facetted approximation — see `acis_bridge`. +// +// Everything is oriented Z-up with the footprint on the z = base plane, to +// match acadrust's `acis::primitives`. +// +// The resulting `Body` is cached per entity handle on the Scene so the +// Design-group boolean tools can run on it. -use truck_modeling::{builder, Point3, Rad, Solid, Vector3, Wire}; +use acadrust::kernel::brep::{self, Body}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; -const FULL: f64 = 7.0; // > 2π → builder closes the revolution +/// How far a triangle may sit from the surface it lies on. A drawing is +/// measured in millimetres, and a twentieth of one is past what a screen +/// resolves at any sane zoom. +const SAG: f64 = 0.05; + +/// What counts as the same point when the kernel checks a body over. +const TOL: f64 = 1e-9; /// 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)) +pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Option { + brep::make::cuboid( + [ + center[0] - length / 2.0, + center[1] - width / 2.0, + center[2] - height / 2.0, + ], + [length, width, 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)) +pub fn wedge_solid(origin: [f64; 3], length: f64, width: f64, height: f64) -> Option { + brep::make::wedge(origin, length, width, height) } -/// 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 cylinder standing on the z = base plane. +pub fn cylinder_solid(center: [f64; 3], radius: f64, height: f64) -> Option { + brep::make::cylinder(center, radius, 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 cone standing on the z = base plane, apex `height` above it. +pub fn cone_solid(center: [f64; 3], radius: f64, height: f64) -> Option { + brep::make::cone(center, radius, height) } -/// 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 sphere about `center`. +pub fn sphere_solid(center: [f64; 3], radius: f64) -> Option { + brep::make::sphere(center, radius) } /// 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]) +pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Option { + brep::make::torus(center, major, minor) +} + +/// Solid pyramid on a regular polygon of `sides` corners. +pub fn pyramid_solid(center: [f64; 3], radius: f64, height: f64, sides: usize) -> Option { + brep::make::pyramid(center, radius, height, sides) +} + +// ── Placement ─────────────────────────────────────────────────────────────── + +/// Moves a body by a rigid transform, given as three axes and an origin. +/// +/// The Model tab builds every primitive in its own upright frame and then +/// puts it on the working plane, which is the only reason this exists. A +/// body carries analytic surfaces, so moving it moves their frames rather +/// than any points. +pub fn placed( + body: &Body, + x: [f64; 3], + y: [f64; 3], + z: [f64; 3], + origin: [f64; 3], +) -> Option { + brep::transform( + body, + &brep::Placement { + x_axis: x, + y_axis: y, + z_axis: z, + origin, + }, + ) +} + +/// Turns a body about one of the world axes, through the point `about`. +pub fn turned(body: &Body, axis: usize, angle: f64, about: [f64; 3]) -> Option { + let (sin, cos) = angle.sin_cos(); + // The rotation's columns, written out per axis rather than assembled from + // a general formula: three cases are shorter than the axis-angle one and + // there is nothing to get subtly wrong in them. + let (x, y, z) = match axis { + 0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]), + 1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]), + _ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]), + }; + placed(body, x, y, z, about_origin(x, y, z, about)) +} + +/// Reflects a body in the plane across one of the world axes, through `about`. +/// +/// The kernel puts the mirrored solid back the right way out; a reflection +/// left alone lights black. +pub fn mirrored(body: &Body, axis: usize, about: [f64; 3]) -> Option { + let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + columns[axis][axis] = -1.0; + let [x, y, z] = columns; + placed(body, x, y, z, about_origin(x, y, z, about)) +} + +/// Moves a body by a transform given as a column-major 4×4, which is what a +/// frame-to-frame solve produces. +pub fn by_matrix(body: &Body, matrix: [f64; 16]) -> Option { + placed( + body, + [matrix[0], matrix[1], matrix[2]], + [matrix[4], matrix[5], matrix[6]], + [matrix[8], matrix[9], matrix[10]], + [matrix[12], matrix[13], matrix[14]], + ) +} + +/// Where a transform's origin has to sit for it to act about `about` rather +/// than about the world origin: `about − M·about`. +fn about_origin(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: [f64; 3]) -> [f64; 3] { + let mut origin = about; + for axis in 0..3 { + origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2]; + } + origin +} + +/// The box a body occupies, from its mesh. +pub fn extent(body: &Body) -> Option<([f64; 3], [f64; 3])> { + let mesh = brep::mesh::body(body, SAG, TOL); + if mesh.positions.is_empty() { + return None; + } + let mut low = [f64::INFINITY; 3]; + let mut high = [f64::NEG_INFINITY; 3]; + for point in &mesh.positions { + for axis in 0..3 { + low[axis] = low[axis].min(point[axis]); + high[axis] = high[axis].max(point[axis]); + } + } + Some((low, high)) +} + +/// Where an axis-aligned plane cuts a body, as line segments. +/// +/// Taken off the mesh rather than the surfaces: a section of a cone by a +/// slanted plane is a conic, of a torus a quartic, and the answer wanted here +/// is a set of Line entities either way. Each triangle the plane crosses +/// contributes the one segment where it does. +pub fn section(body: &Body, axis: usize, value: f64) -> Vec<([f64; 3], [f64; 3])> { + let mesh = brep::mesh::body(body, SAG, TOL); + let mut out = Vec::new(); + for triangle in &mesh.triangles { + let corners: Vec<[f64; 3]> = triangle.iter().map(|i| mesh.positions[*i]).collect(); + // Where each edge of the triangle meets the plane. A triangle with a + // corner exactly on it contributes that corner twice, which collapses + // to nothing and is dropped below. + let mut hits: Vec<[f64; 3]> = Vec::new(); + for step in 0..3 { + let (from, to) = (corners[step], corners[(step + 1) % 3]); + let (a, b) = (from[axis] - value, to[axis] - value); + if (a > 0.0) == (b > 0.0) || a == b { + continue; + } + let along = a / (a - b); + hits.push([ + from[0] + (to[0] - from[0]) * along, + from[1] + (to[1] - from[1]) * along, + from[2] + (to[2] - from[2]) * along, + ]); + } + if hits.len() == 2 { + let span = (hits[0][0] - hits[1][0]).abs() + + (hits[0][1] - hits[1][1]).abs() + + (hits[0][2] - hits[1][2]).abs(); + if span > 1e-9 { + out.push((hits[0], hits[1])); + } + } + } + out } // ── Edge extraction (pick geometry + wireframe overlay) ───────────────────── @@ -98,35 +204,22 @@ pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Solid { /// `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::convert::truck_tess::{tessellate_edge, TruckTessResult}; +pub fn edge_wires(body: &Body) -> Vec { 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, pts_low) = tessellate_edge(&edge) { - if pts.len() < 2 { - continue; - } - let pts3: Vec = pts - .iter() - .zip(pts_low.iter()) - .map(|(p, l)| { - Vector3::new( - p[0] as f64 + l[0] as f64, - p[1] as f64 + l[1] as f64, - p[2] as f64 + l[2] as f64, - ) - }) - .collect(); - wires.push(acadrust::entities::Wire::from_points(pts3)); - } - } - } - wires + brep::edge_polylines(body, SAG) + .into_iter() + .map(|points| { + acadrust::entities::Wire::from_points( + points + .into_iter() + .map(|p| Vector3::new(p[0], p[1], p[2])) + .collect(), + ) + }) + .collect() } -// ── Boolean operations (truck-shapeops) ───────────────────────────────────── +// ── Boolean operations ────────────────────────────────────────────────────── /// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so /// this scene module has no dependency on the UI module. @@ -137,66 +230,124 @@ pub enum Bool { Intersect, } -#[cfg(feature = "solid3d")] -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). -#[cfg(feature = "solid3d")] -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) - } - } -} - -/// Without `solid3d` (e.g. wasm) there is no boolean kernel. -#[cfg(not(feature = "solid3d"))] -pub fn boolean(_op: Bool, _a: &Solid, _b: &Solid) -> Option { - None +/// Combine two solids. `Subtract` removes `b` from `a`. +/// +/// `None` when the kernel refuses — a face pair it has no closed form for, a +/// cut it cannot make. It refuses rather than returning a solid with a wall +/// missing, and passing that on unchanged is the point: a half-done boolean +/// looks finished. +pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { + let how = match op { + Bool::Union => brep::Operation::Union, + Bool::Subtract => brep::Operation::Difference, + Bool::Intersect => brep::Operation::Intersection, + }; + brep::combine(a.clone(), b.clone(), how, TOL).ok() } // ── 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::convert::truck_tess::{tessellate_solid, TruckTessResult}; - match tessellate_solid(solid) { - TruckTessResult::Mesh { - verts, - verts_low, - normals, - indices, - } if !indices.is_empty() => { - let mesh = MeshModel { - name: String::new(), - verts, - verts_low, - normals, - indices, - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - }; - Some(MeshLodSet::from_single(mesh)) - } - _ => None, +/// Tessellate a `Body` into a single-LOD `MeshLodSet` (world-space, before +/// world_offset is applied by the caller). +pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { + let mesh = brep::mesh::body(body, SAG, TOL); + if mesh.is_empty() { + return None; } + // The renderer holds each position as a coarse float plus a fine + // correction, so a survey coordinate keeps its last millimetres instead + // of losing them to f32. + let mut verts = Vec::with_capacity(mesh.positions.len()); + let mut verts_low = Vec::with_capacity(mesh.positions.len()); + for point in &mesh.positions { + let high = [point[0] as f32, point[1] as f32, point[2] as f32]; + verts.push(high); + verts_low.push([ + (point[0] - high[0] as f64) as f32, + (point[1] - high[1] as f64) as f32, + (point[2] - high[2] as f64) as f32, + ]); + } + let normals = mesh + .normals + .iter() + .map(|n| [n[0] as f32, n[1] as f32, n[2] as f32]) + .collect(); + let indices = mesh + .triangles + .iter() + .flat_map(|t| [t[0] as u32, t[1] as u32, t[2] as u32]) + .collect(); + Some(MeshLodSet::from_single(MeshModel { + name: String::new(), + verts, + verts_low, + normals, + indices, + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color, + selected: false, + })) +} + +/// The middle of a body, for a caller needing a point to turn or scale about. +/// +/// Read off the mesh rather than `body_bounds`, which refuses a face that +/// wraps a closed surface — a sphere is one such face and has no box at all. +pub fn centre(body: &Body) -> Option<[f64; 3]> { + let mesh = brep::mesh::body(body, SAG, TOL); + if mesh.positions.is_empty() { + return None; + } + let mut low = [f64::INFINITY; 3]; + let mut high = [f64::NEG_INFINITY; 3]; + for point in &mesh.positions { + for axis in 0..3 { + low[axis] = low[axis].min(point[axis]); + high[axis] = high[axis].max(point[axis]); + } + } + Some([ + (low[0] + high[0]) * 0.5, + (low[1] + high[1]) * 0.5, + (low[2] + high[2]) * 0.5, + ]) +} + +/// How much a body encloses, from its mesh. +/// +/// The divergence theorem over triangles wound outwards, which is what makes +/// it a check rather than only a measurement: a solid built inside out +/// reports a negative volume rather than a plausible one, and one missing a +/// face reports far too little. Nothing in the app measures volume yet, so it +/// exists to test with. +#[cfg(test)] +pub fn volume(body: &Body) -> f64 { + use acadrust::kernel::space::Vec3; + let mesh = brep::mesh::body(body, SAG, TOL); + let Some(middle) = centre(body) else { + return 0.0; + }; + // About the body's own middle: at survey coordinates the tetrahedra + // reaching back to the origin are enormous and nearly cancel, and a + // cubic millimetre read off a sum of billions is noise. + let middle = Vec3::from(middle); + mesh.triangles + .iter() + .map(|triangle| { + let at = |index: usize| Vec3::from(mesh.positions[triangle[index]]) - middle; + at(0).cross(at(1)).dot(at(2)) / 6.0 + }) + .sum() } #[cfg(test)] mod tests { use super::*; - fn tri_count(s: &Solid) -> usize { - mesh_from_solid(s, [0.7, 0.7, 0.7, 1.0]) + fn tri_count(body: &Body) -> usize { + mesh_from_solid(body, [0.7, 0.7, 0.7, 1.0]) .map(|m| m.lods[0].indices.len() / 3) .unwrap_or(0) } @@ -204,18 +355,44 @@ mod tests { #[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"); + assert!(tri_count(&box_solid(c, 10.0, 10.0, 10.0).unwrap()) >= 12, "box"); + assert!(tri_count(&wedge_solid(c, 10.0, 10.0, 10.0).unwrap()) >= 6, "wedge"); + assert!(tri_count(&cylinder_solid(c, 5.0, 12.0).unwrap()) > 20, "cylinder"); + assert!(tri_count(&cone_solid(c, 5.0, 12.0).unwrap()) > 10, "cone"); + assert!(tri_count(&sphere_solid(c, 5.0).unwrap()) > 50, "sphere"); + assert!(tri_count(&torus_solid(c, 8.0, 2.0).unwrap()) > 50, "torus"); + assert!(tri_count(&pyramid_solid(c, 5.0, 9.0, 6).unwrap()) >= 8, "pyramid"); + } + + #[test] + fn every_primitive_is_the_size_it_was_asked_for() { + // Triangle counts say a mesh exists; the volume says it is the right + // shape and the right way out. A face left out reads far too small + // and one wound inwards reads negative, and neither shows up in a + // count. + use std::f64::consts::PI; + let c = [0.0, 0.0, 0.0]; + let cases: [(Body, f64); 5] = [ + (box_solid(c, 10.0, 4.0, 6.0).unwrap(), 240.0), + (cylinder_solid(c, 5.0, 12.0).unwrap(), PI * 25.0 * 12.0), + (cone_solid(c, 5.0, 12.0).unwrap(), PI * 25.0 * 12.0 / 3.0), + (sphere_solid(c, 5.0).unwrap(), 4.0 / 3.0 * PI * 125.0), + (torus_solid(c, 8.0, 2.0).unwrap(), 2.0 * PI * PI * 8.0 * 4.0), + ]; + for (body, expected) in cases { + let got = volume(&body); + assert!(got > 0.0, "wound inwards: {got}"); + // Facets are chords and lie inside, so a mesh reads short and + // never over. + assert!(got > 0.94 * expected, "{got} vs {expected}"); + assert!(got <= expected * 1.000_001, "{got} vs {expected}"); + } } #[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); + let a = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap(); + let b = box_solid([5.0, 5.0, 5.0], 10.0, 10.0, 10.0).unwrap(); for (op, label) in [ (Bool::Union, "union"), (Bool::Subtract, "subtract"), @@ -223,13 +400,35 @@ mod tests { ] { 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); + assert!(edge_wires(&box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap()).len() >= 12); + } + + #[test] + fn placing_a_body_moves_it_without_changing_its_size() { + let body = box_solid([0.0, 0.0, 0.0], 10.0, 4.0, 6.0).unwrap(); + // A quarter turn about Z, then five along X. + let moved = placed( + &body, + [0.0, 1.0, 0.0], + [-1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + [5.0, 0.0, 0.0], + ) + .expect("a turned box"); + assert!((volume(&moved) - 240.0).abs() < 1e-6, "{}", volume(&moved)); + // Centred on the origin to begin with, so the turn leaves it there + // and the move puts it five along x. + let middle = centre(&moved).unwrap(); + assert!((middle[0] - 5.0).abs() < 1e-9, "{middle:?}"); + // And ten along x really did become ten along y. + let (low, high) = extent(&moved).unwrap(); + assert!((high[1] - low[1] - 10.0).abs() < 1e-9, "{low:?} {high:?}"); + assert!((high[0] - low[0] - 4.0).abs() < 1e-9, "{low:?} {high:?}"); } } diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs new file mode 100644 index 00000000..0e451d9e --- /dev/null +++ b/src/scene/model/sweep_model.rs @@ -0,0 +1,203 @@ +// EXTRUDE / REVOLVE: turning a drawn profile into a solid. +// +// The profile comes from `entities::curve`, which is where every entity's +// geometry is already defined once, so a circle, an arc-bulged polyline and a +// closed spline all arrive as the same thing: a plane plus a chain of curves +// in that plane's coordinates. The kernel sweeps that chain into analytic +// surfaces — a straight run into a plane, an arc into a cylinder, a profile +// turned about an axis into a cone, a sphere or a torus — so the result can +// be written back out as ACIS rather than as facets. +// +// A spline profile has no analytic side wall, so it is refused rather than +// approximated into a surface nobody asked for. That is the kernel's answer +// and this module passes it on. + +use acadrust::kernel::brep::{self, Body}; +use acadrust::kernel::geom2d::Curve; +use acadrust::kernel::space::{Plane, PlanarCurve}; +use acadrust::EntityType; + +use crate::entities::curve::entity_curve; + +/// A drawn profile, as the kernel wants it: the plane it lies in and the +/// chain of pieces closing a loop in that plane. +pub struct Profile { + pub plane: Plane, + pub pieces: Vec, +} + +/// The closed profile an entity describes, or `None` when it does not +/// describe one. +/// +/// A single closed curve — a circle, an ellipse, a closed polyline — is what +/// a sweep needs. The kernel wants it as a chain of at least three pieces, +/// which a polyline already is and which a circle is not, so a round profile +/// is handed over as the arcs it is made of rather than as one curve with +/// nowhere for the sweep to start. +pub fn profile_of(entity: &EntityType) -> Option { + let planar: PlanarCurve = entity_curve(entity)?; + if !planar.curve.is_closed() { + return None; + } + let pieces = match &planar.curve { + // A polyline is already a chain. Its own segments carry the bulges, + // so an arc stays an arc rather than becoming a run of chords. + Curve::Polyline(_) => planar.curve.segments(), + // Everything else closed on itself is cut into quarters, which is + // both the fewest pieces a chain may have and the fewest that leave + // each one unambiguous about which way round it goes. + Curve::Circle(circle) => quarters(circle.centre, circle.radius), + other => split_evenly(other, 4), + }; + (pieces.len() >= 3).then_some(Profile { + plane: planar.plane, + pieces, + }) +} + +/// A circle as four arcs. +fn quarters(centre: [f64; 2], radius: f64) -> Vec { + use acadrust::kernel::geom2d::Arc; + use std::f64::consts::FRAC_PI_2; + (0..4) + .map(|quarter| { + let start = FRAC_PI_2 * quarter as f64; + Curve::Arc(Arc { + centre, + radius, + start_angle: start, + end_angle: start + FRAC_PI_2, + }) + }) + .collect() +} + +/// Any other closed curve as `count` straight pieces between points on it. +/// +/// The honest fallback: an ellipse or a spline has no analytic sweep, so the +/// kernel would refuse the exact form anyway. Chords at least say plainly +/// what they are. +fn split_evenly(curve: &Curve, count: usize) -> Vec { + use acadrust::kernel::geom2d::Line; + (0..count) + .map(|step| Curve::Line(Line { + start: curve.point_at(step as f64 / count as f64), + end: curve.point_at((step + 1) as f64 / count as f64), + })) + .collect() +} + +/// EXTRUDE: drag the profile `height` along its own plane's normal. +/// +/// `None` for a profile that does not close, encloses nothing, or holds a +/// piece with no analytic side wall. +pub fn extruded(entity: &EntityType, height: f64) -> Option { + let profile = profile_of(entity)?; + let normal = profile.plane.normal()?; + brep::extrude( + profile.plane, + &profile.pieces, + [normal[0] * height, normal[1] * height, normal[2] * height], + ) +} + +/// REVOLVE: turn the profile about the axis from `from` to `to` by `angle` +/// radians. +/// +/// The axis has to lie in the profile's plane — a profile and an axis that do +/// not share one sweep into surfaces with no analytic form, and the kernel +/// refuses rather than approximating them. +pub fn revolved( + entity: &EntityType, + from: [f64; 3], + to: [f64; 3], + angle: f64, +) -> Option { + let profile = profile_of(entity)?; + brep::revolve( + profile.plane, + &profile.pieces, + from, + [to[0] - from[0], to[1] - from[1], to[2] - from[2]], + angle, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Circle, LwPolyline, LwVertex}; + use acadrust::types::{Vector2, Vector3}; + + fn square(size: f64) -> EntityType { + let mut polyline = LwPolyline::new(); + for corner in [[0.0, 0.0], [size, 0.0], [size, size], [0.0, size]] { + polyline.add_vertex(LwVertex::new(Vector2::new(corner[0], corner[1]))); + } + polyline.is_closed = true; + EntityType::LwPolyline(polyline) + } + + fn volume(body: &Body) -> f64 { + crate::scene::model::solid_model::volume(body) + } + + #[test] + fn a_closed_polyline_extrudes_into_a_prism() { + let solid = extruded(&square(10.0), 4.0).expect("a prism"); + assert!((volume(&solid) - 400.0).abs() < 1e-6, "{}", volume(&solid)); + } + + #[test] + fn a_circle_extrudes_into_a_cylinder() { + // Cut into quarter arcs on the way, so the wall is four cylinder + // patches rather than a run of flat chords — the volume says which. + let mut circle = Circle::new(); + circle.center = Vector3::new(0.0, 0.0, 0.0); + circle.radius = 5.0; + let solid = extruded(&EntityType::Circle(circle), 3.0).expect("a cylinder"); + let expected = std::f64::consts::PI * 25.0 * 3.0; + let got = volume(&solid); + assert!(got > 0.98 * expected, "{got} vs {expected}"); + assert!(got <= expected * 1.000_001, "{got} vs {expected}"); + } + + #[test] + fn a_square_revolved_about_its_own_edge_is_a_tube() { + // The axis runs up the square's left side, so what it sweeps is a + // solid cylinder of radius ten and height ten. + let solid = revolved( + &square(10.0), + [0.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + std::f64::consts::TAU, + ); + // The profile is in the XY plane and the axis lies in it, so this is + // a revolution about the y axis: radius ten, height ten. + let solid = solid.expect("a cylinder"); + let expected = std::f64::consts::PI * 100.0 * 10.0; + let got = volume(&solid); + assert!(got > 0.98 * expected, "{got} vs {expected}"); + } + + #[test] + fn an_open_profile_is_refused() { + let mut polyline = LwPolyline::new(); + for corner in [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]] { + polyline.add_vertex(LwVertex::new(Vector2::new(corner[0], corner[1]))); + } + polyline.is_closed = false; + assert!(extruded(&EntityType::LwPolyline(polyline), 4.0).is_none()); + } + + #[test] + fn an_axis_off_the_profiles_plane_is_refused() { + assert!(revolved( + &square(10.0), + [0.0, 0.0, 0.0], + [0.0, 1.0, 1.0], + std::f64::consts::TAU + ) + .is_none()); + } +}