forked from 1b-agent/opencadstudio-upstream
feat(model): build and read solids with the geometry kernel
Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
8016b79ddf
commit
1f642bb40a
15 files changed
+905
-1627
No files matched your search
Generated
+25
-256
@@ -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",
|
||||
]
|
||||
+2
-3
@@ -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"
|
||||
|
||||
+36
-104
@@ -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<truck_modeling::Wire> = 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,
|
||||
|
||||
+83
-244
@@ -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<Message> {
|
||||
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<Message> {
|
||||
use truck_modeling::{builder, Point3, Rad, Vector3 as TVec3};
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = 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<Message> {
|
||||
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<Solid> = None;
|
||||
let mut acc: Option<Body> = 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<Message> {
|
||||
use truck_modeling::{builder, Matrix4, Vector3 as TVec3};
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = 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<Message> {
|
||||
use truck_modeling::{builder, Matrix4};
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = 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<Message> {
|
||||
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<Handle> = 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<Message> {
|
||||
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<Message> {
|
||||
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<Point3> = (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!(
|
||||
|
||||
+1
-1
@@ -1158,7 +1158,7 @@ pub enum CmdResult {
|
||||
/// rendering). Ends the command.
|
||||
CommitSolid {
|
||||
entity: EntityType,
|
||||
solid: Box<truck_modeling::Solid>,
|
||||
solid: Box<acadrust::kernel::brep::Body>,
|
||||
},
|
||||
/// 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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<usize>]) -> 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<SatDocument> {
|
||||
let mut positions: Vec<[f64; 3]> = Vec::new();
|
||||
let mut vert_index: HashMap<_, usize> = HashMap::new();
|
||||
let mut faces: Vec<Vec<usize>> = 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<usize> = 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<SatDocument> {
|
||||
let mut document = SatDocument::new();
|
||||
append(body, &mut document).ok()?;
|
||||
Some(document)
|
||||
}
|
||||
@@ -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<MeshLodSet> {
|
||||
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<u32> = 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<Vec<[f64; 3]>> {
|
||||
let (bodies, _) = lift(document);
|
||||
let sag = if sag > 0.0 { sag } else { SAG };
|
||||
bodies
|
||||
.iter()
|
||||
.flat_map(|body| brep::edge_polylines(body, sag))
|
||||
.collect()
|
||||
}
|
||||
@@ -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<MeshLodSet> {
|
||||
// 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<u32> = Vec::new();
|
||||
let face_materials: FxHashMap<i32, acadrust::Handle> = 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<i32, [f32; 4]> = 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<Option<acadrust::Handle>> = Vec::new();
|
||||
let mut triangle_colors: Vec<Option<[f32; 4]>> = Vec::new();
|
||||
let face_record_indices: Vec<i32> = 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<u32>,
|
||||
) {
|
||||
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<Face>, 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<Face> {
|
||||
// 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<Wire> = 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<Wire> {
|
||||
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<Face>, 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<usize> = 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<Face> = 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<f64> = 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<f64> = 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<u32>,
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -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<MeshLodSet> {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub mod acis_kernel;
|
||||
pub mod dgn_linestyle;
|
||||
pub mod truck_tess;
|
||||
pub mod tessellate;
|
||||
|
||||
@@ -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<MeshLodSet> {
|
||||
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]);
|
||||
|
||||
+12
-10
@@ -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<Handle, MeshLodSet>,
|
||||
/// 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<Handle, truck_modeling::Solid>,
|
||||
/// 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<Handle, acadrust::kernel::brep::Body>,
|
||||
/// GPU render data for raster images (RasterImage entities), keyed by handle.
|
||||
pub images: HashMap<Handle, ImageModel>,
|
||||
/// 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)
|
||||
|
||||
@@ -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;
|
||||
+356
-157
@@ -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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<acadrust::entities::Wire> {
|
||||
use crate::scene::convert::truck_tess::{tessellate_edge, TruckTessResult};
|
||||
pub fn edge_wires(body: &Body) -> Vec<acadrust::entities::Wire> {
|
||||
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<Vector3> = 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<Solid> {
|
||||
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<Solid> {
|
||||
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<Body> {
|
||||
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<MeshLodSet> {
|
||||
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<MeshLodSet> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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<Curve>,
|
||||
}
|
||||
|
||||
/// 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<Profile> {
|
||||
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<Curve> {
|
||||
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<Curve> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user