Merge current main while retaining aligned dependency revisions
No files matched your search
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"bincode",
|
"bincode",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cadkernel",
|
"cadkernel",
|
||||||
|
"cadkernel-constraints",
|
||||||
"clap",
|
"clap",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"cosmic-text 0.15.0",
|
"cosmic-text 0.15.0",
|
||||||
@@ -38,7 +39,6 @@ dependencies = [
|
|||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"ocs_doc_api",
|
|
||||||
"ocs_plugin_api",
|
"ocs_plugin_api",
|
||||||
"open",
|
"open",
|
||||||
"printpdf",
|
"printpdf",
|
||||||
@@ -323,6 +323,15 @@ version = "1.0.103"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -893,6 +902,14 @@ dependencies = [
|
|||||||
"spade",
|
"spade",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cadkernel-constraints"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/ramox81/cadkernel.git?rev=0ccc61978286a6ad8467ac02de566ffd8ee100b3#0ccc61978286a6ad8467ac02de566ffd8ee100b3"
|
||||||
|
dependencies = [
|
||||||
|
"nalgebra",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "calloop"
|
name = "calloop"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -3178,6 +3195,16 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matrixmultiply"
|
||||||
|
version = "0.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"rawpointer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3358,6 +3385,33 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nalgebra"
|
||||||
|
version = "0.33.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd"
|
||||||
|
dependencies = [
|
||||||
|
"approx",
|
||||||
|
"matrixmultiply",
|
||||||
|
"nalgebra-macros",
|
||||||
|
"num-complex",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
"simba",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nalgebra-macros"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3891,20 +3945,6 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ocs_doc_api"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"acadrust",
|
|
||||||
"bincode",
|
|
||||||
"cadkernel",
|
|
||||||
"ocs_plugin_api",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"toml 1.1.3+spec-1.1.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ocs_plugin_api"
|
name = "ocs_plugin_api"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4607,6 +4647,12 @@ dependencies = [
|
|||||||
"objc2-quartz-core 0.3.2",
|
"objc2-quartz-core 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rawpointer"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -5015,6 +5061,15 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "safe_arch"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -5228,6 +5283,19 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simba"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95"
|
||||||
|
dependencies = [
|
||||||
|
"approx",
|
||||||
|
"num-complex",
|
||||||
|
"num-traits",
|
||||||
|
"paste",
|
||||||
|
"wide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -6532,6 +6600,16 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wide"
|
||||||
|
version = "0.7.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"safe_arch",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "widestring"
|
name = "widestring"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ members = [
|
|||||||
"crates/ocs_web_worker",
|
"crates/ocs_web_worker",
|
||||||
"crates/dwg-thumbnailer",
|
"crates/dwg-thumbnailer",
|
||||||
"crates/dwg-thumbnailer-win",
|
"crates/dwg-thumbnailer-win",
|
||||||
"crates/ocs_doc_api",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[target.'cfg(windows)'.build-dependencies]
|
[target.'cfg(windows)'.build-dependencies]
|
||||||
@@ -33,6 +32,7 @@ clap = { version = "4", features = ["derive", "string"] }
|
|||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "2ecf0c57d387e293d11042aa37637322f2ab9c01", features = ["serde"] }
|
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "2ecf0c57d387e293d11042aa37637322f2ab9c01", features = ["serde"] }
|
||||||
cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3", features = ["acis", "offset"] }
|
cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3", features = ["acis", "offset"] }
|
||||||
|
cadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3" }
|
||||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||||
@@ -76,7 +76,6 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0
|
|||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
getrandom = "0.3"
|
getrandom = "0.3"
|
||||||
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
|
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
|
||||||
ocs_doc_api = { path = "crates/ocs_doc_api", features = ["host", "ipc"] }
|
|
||||||
bincode = "1.3"
|
bincode = "1.3"
|
||||||
meshopt = "0.6.2"
|
meshopt = "0.6.2"
|
||||||
i18n-embed = { version = "0.16", features = ["desktop-requester"] }
|
i18n-embed = { version = "0.16", features = ["desktop-requester"] }
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="4" y1="20" x2="20" y2="20" stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<line x1="4" y1="20" x2="16" y2="5" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<path d="M 11 20 A 7 7 0 0 0 8.4 14.6" stroke="#B4B6B9" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 406 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<circle cx="12" cy="12" r="8" stroke="#B4B6B9" stroke-width="2.25" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="2.2" fill="#6DB7ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 225 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<circle cx="9" cy="12" r="3.4" stroke="#B4B6B9" stroke-width="2" fill="none"/>
|
||||||
|
<circle cx="14" cy="12" r="3.4" fill="#6DB7ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="2" y1="12" x2="9" y2="12" stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<line x1="13" y1="12" x2="22" y2="12" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="#B4B6B9" stroke-width="2.25" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="4" stroke="#6DB7ED" stroke-width="2.25" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 257 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="4" y1="6" x2="4" y2="18" stroke="#B4B6B9" stroke-width="1.75" stroke-linecap="round"/>
|
||||||
|
<line x1="20" y1="6" x2="20" y2="18" stroke="#B4B6B9" stroke-width="1.75" stroke-linecap="round"/>
|
||||||
|
<line x1="4" y1="12" x2="20" y2="12" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<path d="M 4 12 L 8 9 M 4 12 L 8 15" stroke="#6DB7ED" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M 20 12 L 16 9 M 20 12 L 16 15" stroke="#6DB7ED" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 616 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="5" y1="9" x2="19" y2="9" stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<line x1="5" y1="15" x2="19" y2="15" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="3" y1="7" x2="13" y2="7" stroke="#B4B6B9" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="3" cy="7" r="1.8" fill="#B4B6B9"/>
|
||||||
|
<circle cx="13" cy="7" r="1.8" fill="#B4B6B9"/>
|
||||||
|
<line x1="9" y1="18" x2="19" y2="18" stroke="#6DB7ED" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="9" cy="18" r="1.8" fill="#6DB7ED"/>
|
||||||
|
<circle cx="19" cy="18" r="1.8" fill="#6DB7ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="4" y1="8" x2="20" y2="8" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<line x1="6" y1="8" x2="3" y2="13" stroke="#B4B6B9" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
<line x1="11" y1="8" x2="8" y2="13" stroke="#B4B6B9" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
<line x1="16" y1="8" x2="13" y2="13" stroke="#B4B6B9" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="8" x2="18" y2="13" stroke="#B4B6B9" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 587 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="4" y1="12" x2="20" y2="12" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 192 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="3" y1="19" x2="21" y2="5" stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<circle cx="12" cy="12" r="2.2" fill="#6DB7ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<circle cx="10" cy="10" r="6" stroke="#B4B6B9" stroke-width="2.25" fill="none"/>
|
||||||
|
<line x1="10" y1="10" x2="21" y2="21" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<circle cx="14.24" cy="14.24" r="1.8" fill="#6DB7ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 333 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="5" y1="19" x2="13" y2="5" stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<line x1="11" y1="19" x2="19" y2="5" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="4" y1="17" x2="20" y2="17" stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<line x1="12" y1="4" x2="12" y2="17" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<path d="M 4 18 A 11 11 0 0 1 20 6" stroke="#B4B6B9" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="8" cy="7" r="2.2" fill="#6DB7ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 249 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="12" y1="3" x2="12" y2="21" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<line x1="7" y1="12" x2="17" y2="12" stroke="#B4B6B9" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
<circle cx="5" cy="12" r="2" fill="#B4B6B9"/>
|
||||||
|
<circle cx="19" cy="12" r="2" fill="#B4B6B9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<circle cx="9" cy="9" r="6" stroke="#B4B6B9" stroke-width="2.25" fill="none"/>
|
||||||
|
<line x1="2" y1="15" x2="21" y2="15" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
<circle cx="9" cy="15" r="1.8" fill="#6DB7ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 192 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h7v7H3ZM14 14h7v7h-7Z"/><path stroke="#6DB7ED" d="m8 15 5-5m-5 0h5v5M2 22h20"/></svg>
|
||||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="m2 20 7-7m6-6 7-7"/><path stroke="#6DB7ED" d="m6 9 6 6m0-12 6 6"/></svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="m2 21 9-9m2-2 9-9"/><circle stroke="#6DB7ED" cx="12" cy="11" r="3"/></svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="m2 14 10 6 10-6M2 10l10 6 10-6"/><path stroke="#6DB7ED" d="m2 6 10-5 10 5-10 6Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h15v16H3ZM3 10l6-6M3 16 15 4M6 20l12-12M12 20l6-6"/><path stroke="#6DB7ED" d="m14 12 6-6 3 3-6 6-4 1Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 279 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 16h8l4-8h8"/><path stroke="#6DB7ED" d="m5 5 5 3-5 3m14 2-5 3 5 3"/></svg>
|
||||||
|
After Width: | Height: | Size: 241 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18 14 7"/><path stroke="#6DB7ED" d="m14 7 7-4-4 7m-3-3 5-2"/><path d="M3 14v4h4"/></svg>
|
||||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12v13H2Z"/><path stroke="#6DB7ED" d="M10 11h12v11H10ZM6 7h5v5H6Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h12v12H3ZM8 9h12v12H8Z"/><path stroke="#6DB7ED" d="m16 2 6 6m0-6-6 6"/></svg>
|
||||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18V7h8v10h9"/><path stroke="#6DB7ED" d="m12 11 7-7 3 3-7 7-4 1Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18c3-12 9 0 18-12"/><path stroke="#6DB7ED" d="M5 3h14m-4-3 4 3-4 3M19 22H5m4-3-4 3 4 3"/></svg>
|
||||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path stroke="#6DB7ED" d="m8 3-3 3 3 3m8 0 3 3-3 3m-8 0-3 3 3 3"/></svg>
|
||||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18C4 2 13 22 20 5"/><path stroke="#6DB7ED" d="m13 9 6-6 3 3-6 6-4 1Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 245 B |
@@ -1,45 +0,0 @@
|
|||||||
# Document API architecture
|
|
||||||
|
|
||||||
`DocApi → Document → collections → typed handles` builds DTO requests.
|
|
||||||
`Transport::apply` carries one operation or a read-only query batch to the
|
|
||||||
executor. The native adapter binds each request to its existing `HostSession`.
|
|
||||||
|
|
||||||
For out-of-process / worker-thread plugins, `doc_api_for_host(host)` constructs a
|
|
||||||
`DocApi` backed by `OcsPluginApiIpc`, which sends `PluginRequest::DocApiRequest`
|
|
||||||
through the host's `PluginRequestSender` and deserializes
|
|
||||||
`PluginResponse::DocApiResponse`. This keeps plugin code free of manual envelope
|
|
||||||
serialization while reusing the same executor that the in-process transport uses.
|
|
||||||
|
|
||||||
The executor validates inputs and dispatches through `DocApiBackend`. Backends
|
|
||||||
must prepare fallible geometry and serialization before changing the document.
|
|
||||||
Bulk writes prepare all entities, begin one undo record, apply the prepared
|
|
||||||
changes, then finalize once. Cancellation releases a pending undo capture;
|
|
||||||
it is not a rollback mechanism for partially applied writes.
|
|
||||||
|
|
||||||
The native adapter reuses scene changes, document history, entity transforms
|
|
||||||
and profile conversion. Solid construction, sweeps, booleans, meshing and mass
|
|
||||||
properties belong to cadkernel. Profile planes preserve their source elevation
|
|
||||||
and orientation. Geometry revisions advance when the scene changes; a compound
|
|
||||||
operation may advance the revision more than once while recording one undo step.
|
|
||||||
|
|
||||||
`Operation` and `Query` are manually maintained wire enums. Append variants;
|
|
||||||
do not reorder them. The consistency tests verify their bincode discriminants.
|
|
||||||
`spec/entities.toml` supplies family, constructor and method mappings. `build.rs`
|
|
||||||
generates the API reference and binding schema. The layout snapshot is curated
|
|
||||||
vocabulary, not a reflected binary codec schema.
|
|
||||||
|
|
||||||
Layer CRUD is a document-table family, not an entity family. The API carries
|
|
||||||
layer properties in the plain-data `LayerInfo` DTO and the host translates them
|
|
||||||
to/from `acadrust::tables::Layer`. Layer names are case-insensitive. The host
|
|
||||||
rejects duplicate names, deletion of layer "0" or the current layer, and removal
|
|
||||||
of a layer that still has entities assigned.
|
|
||||||
|
|
||||||
XDATA and XRECORD are serialization roundtrip families: the API carries their
|
|
||||||
payloads as plain-data DTOs and the host stores/retrieves them without semantic
|
|
||||||
interpretation. XDATA records are keyed by registered application name on any
|
|
||||||
entity; XRECORD objects are standalone named dictionaries of group-code/value
|
|
||||||
entries. Both preserve exact bytes/values across save and reload.
|
|
||||||
|
|
||||||
To extend the API, update the DTO, backend, executor, facade and spec together,
|
|
||||||
add a native behavior test, and rebuild the generated reference/schema. New
|
|
||||||
backend methods must reject unsupported behavior explicitly.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "ocs_doc_api"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and hosts (v2)."
|
|
||||||
license = "MIT"
|
|
||||||
build = "build.rs"
|
|
||||||
|
|
||||||
# Dependency wiring: pin the SAME cadkernel/acadrust git revs
|
|
||||||
# as the host so `Body`/`EntityType` types are ABI-identical.
|
|
||||||
[dependencies]
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
bincode = "1.3"
|
|
||||||
thiserror = "1"
|
|
||||||
|
|
||||||
# Host adapters convert ObjectId to acadrust handles; kernel handles stay local.
|
|
||||||
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "2ecf0c57d387e293d11042aa37637322f2ab9c01", features = ["serde"], optional = true }
|
|
||||||
cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3", features = ["acis", "offset"], optional = true }
|
|
||||||
ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] }
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
toml = "1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json = "1"
|
|
||||||
toml = "1"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
# Default keeps the facade and DTOs independent of geometry dependencies.
|
|
||||||
default = []
|
|
||||||
# host: pull acadrust + cadkernel + the executor/backend trait (in-process side).
|
|
||||||
host = ["acadrust", "cadkernel"]
|
|
||||||
# ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side).
|
|
||||||
ipc = ["acadrust", "ocs_plugin_api"]
|
|
||||||
# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only).
|
|
||||||
doc_api_host = ["ipc"]
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "ocs_doc_api"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[test]]
|
|
||||||
name = "mock_backend"
|
|
||||||
required-features = ["host"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "e1_primitives_and_queries"
|
|
||||||
required-features = ["host"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "e2_intersect_two_solids"
|
|
||||||
required-features = ["host"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "e3_transform_and_chain"
|
|
||||||
required-features = ["host"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "e4_2d_entities_and_bulk"
|
|
||||||
required-features = ["host"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "e5_failure_and_guards"
|
|
||||||
required-features = ["host"]
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# ocs_doc_api
|
|
||||||
|
|
||||||
Typed document operations and queries for OpenCADStudio plugins and hosts.
|
|
||||||
The same facade runs over an in-process backend or the plugin IPC channel.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use ocs_doc_api::{ApiResult, DocApi};
|
|
||||||
|
|
||||||
fn intersect_solids(api: &DocApi) -> ApiResult<f64> {
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
let a = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?;
|
|
||||||
let b = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?;
|
|
||||||
a.intersect(&b)?.volume()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each successful write marks the document changed and records one undo step.
|
|
||||||
A failed operation leaves the document unchanged. Bulk creation and transforms
|
|
||||||
prepare every entity before applying changes. Separate calls commit separately;
|
|
||||||
`OpGroup` offers best-effort cleanup of creations, not rollback.
|
|
||||||
|
|
||||||
Boolean `intersect`, `union` and `subtract` replace the first solid and erase the
|
|
||||||
second. `intersects` only tests bounding-box overlap. Volume and centroid are
|
|
||||||
computed by the kernel from a tessellated solid (chord tolerance 0.1 drawing units,
|
|
||||||
angular tolerance 0.0001 radians), then cached per geometry revision. A request
|
|
||||||
may compute at most 32 uncached mass properties; excess work returns an error.
|
|
||||||
Bulk operations and query batches are capped at 100,000 items.
|
|
||||||
|
|
||||||
Transports bind to one document tab. Typed handles from another transport and
|
|
||||||
requests for another tab are rejected. Raw `ObjectId` values are relative to the
|
|
||||||
receiving document. Errors are serialized as `ApiError` variants over IPC.
|
|
||||||
|
|
||||||
Layer CRUD is exposed through `Document::layers()`, `create_layer(info)`,
|
|
||||||
`update_layer(name, info)`, `delete_layer(name)` and through the `layer()` /
|
|
||||||
`set_layer(...)` methods on every entity handle (`Line`, `Circle`, `Solid`,
|
|
||||||
`Entity`, etc.). Layer properties are carried as the plain-data `LayerInfo` DTO
|
|
||||||
with `LayerFlags`, `Color` and `LineWeight`. The host enforces CAD-style rules:
|
|
||||||
layer "0" and the current layer cannot be deleted, duplicate names are rejected,
|
|
||||||
and a layer still referenced by entities cannot be removed.
|
|
||||||
|
|
||||||
XDATA and XRECORD payloads are carried as plain-data, serde-compatible DTOs
|
|
||||||
(`XDataRecord`, `XRecordSpec`, and their value/entry types) and roundtripped
|
|
||||||
verbatim by the host. Every `Entity` handle can read/write one XDATA record per
|
|
||||||
registered application name via `xdata(application_name)` and `set_xdata(...)`,
|
|
||||||
and standalone XRECORD objects can be created with `doc.entities().create_xrecord(...)`
|
|
||||||
and updated through the `XRecord` handle.
|
|
||||||
|
|
||||||
| Feature | Use |
|
|
||||||
|---|---|
|
|
||||||
| Default | DTOs, facade, transport trait and binding schema |
|
|
||||||
| `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` |
|
|
||||||
| `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host |
|
|
||||||
| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option<DocApi>` for out-of-process / worker-thread plugins |
|
|
||||||
|
|
||||||
### Connecting from a plugin
|
|
||||||
|
|
||||||
For out-of-process or worker-thread plugins that receive `&dyn HostApi` (or any
|
|
||||||
`ocs_plugin_api::host::HostApi` implementor), enable the `doc_api_host` feature
|
|
||||||
and use the helper instead of manually serializing `DocApiEnvelope`s:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use ocs_doc_api::doc_api_for_host;
|
|
||||||
use ocs_plugin_api::host::HostApi;
|
|
||||||
|
|
||||||
fn on_dispatch(host: &mut dyn HostApi) -> Option<()> {
|
|
||||||
let doc_api = doc_api_for_host(host)?;
|
|
||||||
let doc = doc_api.document(host.tab_id());
|
|
||||||
let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?;
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`doc_api_for_host` returns `None` for hosts that do not expose a
|
|
||||||
`PluginRequestSender` (for example the in-process `HostSession`). In-process code
|
|
||||||
should use `DocApi::in_process(backend, tab_id)` with a concrete
|
|
||||||
`DocApiBackend`.
|
|
||||||
|
|
||||||
### Plugin example: create, read, update and delete a line
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use ocs_doc_api::{doc_api_for_host, ApiResult, Color, LayerInfo, LineWeight};
|
|
||||||
use ocs_plugin_api::host::HostApi;
|
|
||||||
|
|
||||||
fn draw_and_modify_line(host: &mut dyn HostApi) -> ApiResult<()> {
|
|
||||||
// Build a DocApi from the host's plugin request channel.
|
|
||||||
let api = doc_api_for_host(host).ok_or_else(|| {
|
|
||||||
ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into())
|
|
||||||
})?;
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
|
|
||||||
// Ensure a target layer exists.
|
|
||||||
let mut info = LayerInfo::new("MyLayer");
|
|
||||||
info.color = Color::Index(1);
|
|
||||||
info.line_weight = LineWeight::Value(25);
|
|
||||||
let _ = doc.create_layer(&info); // ok if it already exists
|
|
||||||
|
|
||||||
// Create a line on that layer.
|
|
||||||
let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?;
|
|
||||||
line.set_layer("MyLayer")?;
|
|
||||||
|
|
||||||
// Read it back.
|
|
||||||
assert_eq!(line.layer()?, "MYLAYER");
|
|
||||||
|
|
||||||
// Move the endpoint in place (same ObjectId).
|
|
||||||
line.transform(ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]))?;
|
|
||||||
|
|
||||||
// Delete it; the document is left unchanged if this failed.
|
|
||||||
line.delete()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plugin example: create a box and read its volume
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use ocs_doc_api::{doc_api_for_host, ApiResult};
|
|
||||||
use ocs_plugin_api::host::HostApi;
|
|
||||||
|
|
||||||
fn create_box_and_measure(host: &mut dyn HostApi) -> ApiResult<f64> {
|
|
||||||
let api = doc_api_for_host(host).ok_or_else(|| {
|
|
||||||
ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into())
|
|
||||||
})?;
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
|
|
||||||
let box_ = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?;
|
|
||||||
let volume = box_.volume()?;
|
|
||||||
|
|
||||||
// Boolean example: intersect with a second box.
|
|
||||||
let other = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?;
|
|
||||||
let intersection = box_.intersect(&other)?;
|
|
||||||
let _intersection_volume = intersection.volume()?;
|
|
||||||
|
|
||||||
Ok(volume)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [API reference](src/gen/api_reference.md) for constructors and methods,
|
|
||||||
[architecture](ARCHITECTURE.md) for backend rules, and
|
|
||||||
[binding guide](bindings/README.md) for the Python facade and transport contract.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Language bindings
|
|
||||||
|
|
||||||
`src/gen/binding_schema.json` describes handle families, constructors, methods,
|
|
||||||
arguments and operation/query mappings. `build.rs` derives it from
|
|
||||||
`spec/entities.toml`. Its `layouts` section is manually maintained vocabulary;
|
|
||||||
it does not contain everything needed to generate a bincode codec.
|
|
||||||
|
|
||||||
Generate and test the Python facade from the repository root:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 crates/ocs_doc_api/bindings/python/generate.py > /tmp/ocs_doc.py
|
|
||||||
python3 -m unittest discover -s crates/ocs_doc_api/bindings/python
|
|
||||||
```
|
|
||||||
|
|
||||||
The generated `DocApi(transport, active_tab)` expects a caller-supplied
|
|
||||||
`transport.apply(envelope) -> Receipt`. It does not open a plugin connection.
|
|
||||||
An envelope has `version` and a Python tuple `("Op", operation_dict)` or
|
|
||||||
`("Queries", [query_dict])`. Enum payloads use externally tagged dictionaries,
|
|
||||||
for example `{"CreateSolid": {"Sphere": {"centre": [0, 0, 0], "radius": 2}}}`.
|
|
||||||
The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format,
|
|
||||||
sends `DocApiRequest { tab_id, bytes }`, and decodes `Result<Receipt, ApiError>`.
|
|
||||||
Host plugin API version 6 and document envelope version 1 are required.
|
|
||||||
|
|
||||||
For Rust plugins compiled against `ocs_doc_api` with the `doc_api_host` feature,
|
|
||||||
use the `doc_api_for_host` helper to build a typed `DocApi` from any
|
|
||||||
`ocs_plugin_api::host::HostApi` that exposes a `PluginRequestSender`
|
|
||||||
(out-of-process / worker-thread hosts):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use ocs_doc_api::doc_api_for_host;
|
|
||||||
use ocs_plugin_api::host::HostApi;
|
|
||||||
|
|
||||||
fn on_dispatch(host: &mut dyn HostApi) -> Option<()> {
|
|
||||||
let doc_api = doc_api_for_host(host)?;
|
|
||||||
let doc = doc_api.document(host.tab_id());
|
|
||||||
let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?;
|
|
||||||
// ...
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In-process plugins should use `DocApi::in_process(backend, tab_id)` with a
|
|
||||||
concrete `DocApiBackend`; `doc_api_for_host` returns `None` for hosts that do not
|
|
||||||
expose a `PluginRequestSender`.
|
|
||||||
|
|
||||||
Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query
|
|
||||||
results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade
|
|
||||||
unwraps these into typed handles or values. Layer results use `{"Layers": [...]}`
|
|
||||||
and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`,
|
|
||||||
`Color`, `LineWeight`) appear as plain dictionaries in operation payloads, e.g.
|
|
||||||
`{"CreateLayer": {"name": "Walls", "flags": {"frozen": false, "locked": false,
|
|
||||||
"frozen_in_new_viewport": false, "off": false}, "color": {"Index": 7},
|
|
||||||
"line_type": "Continuous", "line_weight": {"Default": null},
|
|
||||||
"plot_style": "", "is_plottable": true}}`.
|
|
||||||
XDATA and XRECORD payloads appear as nested externally tagged dictionaries
|
|
||||||
(e.g. `{"XData": {"application_name": ..., "values": [...]}}` or
|
|
||||||
`{"XRecord": {"name": ..., "entries": [...]}}`). The bridge must raise an
|
|
||||||
exception for an error response. Transport binding determines the tab; requesting
|
|
||||||
another tab or combining typed handles from different documents raises an error.
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate a Python facade over a caller-supplied document transport.
|
|
||||||
|
|
||||||
Usage: python generate.py ../../src/gen/binding_schema.json > ocs_doc.py
|
|
||||||
The transport converts these Python DTOs to/from the Rust wire representation.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
HEADER = '''"""Generated document facade. See bindings/README.md for the transport contract."""
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DocApiEnvelope:
|
|
||||||
version: int
|
|
||||||
body: Any
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Receipt:
|
|
||||||
outcome: Any
|
|
||||||
query_results: list
|
|
||||||
new_revision: int
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Entity:
|
|
||||||
id: int
|
|
||||||
_doc: "Document"
|
|
||||||
|
|
||||||
def bounds(self):
|
|
||||||
return self._doc._apply_query({"GetBounds": {"id": self.id}})
|
|
||||||
|
|
||||||
def transform(self, placement):
|
|
||||||
self._doc._apply_op({"Transform": {"id": self.id, "placement": placement}})
|
|
||||||
|
|
||||||
def delete(self):
|
|
||||||
self._doc._apply_op({"Delete": {"id": self.id}})
|
|
||||||
|
|
||||||
def layer(self):
|
|
||||||
return self._doc._apply_query({"GetEntityLayer": {"id": self.id}})
|
|
||||||
|
|
||||||
def set_layer(self, layer):
|
|
||||||
self._doc._apply_op({"SetEntityLayer": {"id": self.id, "layer": layer}})
|
|
||||||
'''
|
|
||||||
|
|
||||||
DOCUMENT = '''
|
|
||||||
@dataclass
|
|
||||||
class Document:
|
|
||||||
_transport: Any
|
|
||||||
_tab: int
|
|
||||||
|
|
||||||
def _id(self, value):
|
|
||||||
if isinstance(value, Entity):
|
|
||||||
if value._doc._transport is not self._transport or value._doc._tab != self._tab:
|
|
||||||
raise ValueError("handle belongs to another document")
|
|
||||||
return value.id
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
def _apply_op(self, op, want=None):
|
|
||||||
receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Op", op)))
|
|
||||||
if want is None:
|
|
||||||
return None
|
|
||||||
if want.startswith("Vec<"):
|
|
||||||
handle = globals()[want[4:-1]]
|
|
||||||
return [handle(i, self) for i in receipt.outcome["NewIds"]]
|
|
||||||
return globals()[want](receipt.outcome["NewId"], self)
|
|
||||||
|
|
||||||
def _apply_query(self, query):
|
|
||||||
receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [query])))
|
|
||||||
if len(receipt.query_results) != 1:
|
|
||||||
raise ValueError("expected one query result")
|
|
||||||
result = receipt.query_results[0]
|
|
||||||
if not isinstance(result, dict) or len(result) != 1:
|
|
||||||
raise ValueError("invalid query result")
|
|
||||||
return next(iter(result.values()))
|
|
||||||
|
|
||||||
def revision(self):
|
|
||||||
return self._apply_query("GetGeometryRevision")
|
|
||||||
|
|
||||||
def block_entities(self, block_name):
|
|
||||||
return self._apply_query({"GetBlockEntities": {"block_name": block_name}})
|
|
||||||
|
|
||||||
def solids(self): return Solids(self)
|
|
||||||
def curves(self): return Curves(self)
|
|
||||||
def entities(self): return Entities(self)
|
|
||||||
|
|
||||||
def layers(self):
|
|
||||||
return self._apply_query({"ListLayers": {}})
|
|
||||||
|
|
||||||
def create_layer(self, info):
|
|
||||||
self._apply_op({"CreateLayer": info})
|
|
||||||
|
|
||||||
def update_layer(self, name, info):
|
|
||||||
self._apply_op({"UpdateLayer": {"name": name, "info": info}})
|
|
||||||
|
|
||||||
def delete_layer(self, name):
|
|
||||||
self._apply_op({"DeleteLayer": {"name": name}})
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DocApi:
|
|
||||||
_transport: Any
|
|
||||||
active_tab: int = 0
|
|
||||||
|
|
||||||
def document(self, tab):
|
|
||||||
if tab != self.active_tab:
|
|
||||||
raise ValueError("transport is bound to another tab")
|
|
||||||
return Document(self._transport, tab)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def render_method(method, constructor=False):
|
|
||||||
args = method.get("args", [])
|
|
||||||
signature = "".join(", " + a["name"] for a in args)
|
|
||||||
variant = method.get("op") or method["query"]
|
|
||||||
fields = {}
|
|
||||||
for arg in args:
|
|
||||||
name, ty = arg["name"], arg["ty"]
|
|
||||||
if ty in ("EntityRef", "Solid", "ObjectId"):
|
|
||||||
fields[name] = f"self._doc._id({name})"
|
|
||||||
elif ty == "&[EntityRef]":
|
|
||||||
fields[name] = f"[self._doc._id(p) for p in {name}]"
|
|
||||||
else:
|
|
||||||
fields[name] = name
|
|
||||||
fixed = dict(method.get("fixed") or {})
|
|
||||||
if variant in ("SolidBoolean", "GetIntersects"):
|
|
||||||
fields = {"a": "self.id", "b": fields.pop("other"), **fields}
|
|
||||||
fields.pop("other", None)
|
|
||||||
elif not constructor:
|
|
||||||
fields = {"id": "self.id", **fields}
|
|
||||||
if variant == "Revolve":
|
|
||||||
fields["axis"] = "(pivot, axis)"
|
|
||||||
fields.pop("pivot")
|
|
||||||
nested = fixed.pop("primitive", fixed.pop("curve", None))
|
|
||||||
fields.update({key: repr(value) for key, value in fixed.items()})
|
|
||||||
payload = "{" + ", ".join(repr(k) + ": " + v for k, v in fields.items()) + "}"
|
|
||||||
if variant == "CreateMany":
|
|
||||||
payload = "[{'Curve': {'Point': {'position': p}}} for p in positions]"
|
|
||||||
elif nested:
|
|
||||||
payload = "{" + repr(nested) + ": " + payload + "}"
|
|
||||||
message = "{" + repr(variant) + ": " + payload + "}"
|
|
||||||
if method.get("kind") == "query":
|
|
||||||
call = f"self._doc._apply_query({message})"
|
|
||||||
else:
|
|
||||||
want = method.get("returns")
|
|
||||||
if want == "()":
|
|
||||||
want = None
|
|
||||||
call = f"self._doc._apply_op({message}, {want!r})"
|
|
||||||
return f" def {method['name']}(self{signature}):\n return {call}\n"
|
|
||||||
|
|
||||||
|
|
||||||
def render(schema):
|
|
||||||
out = [HEADER, f"ENVELOPE_VERSION = {schema['envelope_version']}\n"]
|
|
||||||
handles = {name: {} for name in schema["object_model"]["handles"]}
|
|
||||||
collections = {"solids": {}, "curves": {}, "entities": {}}
|
|
||||||
for family in schema["families"]:
|
|
||||||
handle = family.get("handle") or family["name"]
|
|
||||||
for method in family.get("methods", []):
|
|
||||||
existing = handles[handle].get(method["name"])
|
|
||||||
if existing is not None and existing != method:
|
|
||||||
raise ValueError(f"conflicting method: {handle}.{method['name']}")
|
|
||||||
handles[handle][method["name"]] = method
|
|
||||||
for constructor in family.get("constructors", []):
|
|
||||||
collections[family["collection"]][constructor["name"]] = constructor
|
|
||||||
# Families sharing Entity contribute to one class, preserving all methods.
|
|
||||||
entity_methods = "\n".join(render_method(m) for m in handles.pop("Entity").values())
|
|
||||||
out[0] += entity_methods
|
|
||||||
for handle, methods in handles.items():
|
|
||||||
out.append(f"\nclass {handle}(Entity):\n" + (
|
|
||||||
"\n".join(render_method(m) for m in methods.values()) or " pass\n"))
|
|
||||||
for collection, constructors in collections.items():
|
|
||||||
out.append(f"\nclass {collection.title()}:\n def __init__(self, doc):\n self._doc = doc\n")
|
|
||||||
out.extend(render_method(c, constructor=True) for c in constructors.values())
|
|
||||||
out.append(DOCUMENT)
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
default = Path(__file__).resolve().parents[2] / "src/gen/binding_schema.json"
|
|
||||||
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
|
|
||||||
sys.stdout.write(render(json.loads(path.read_text(encoding="utf-8"))))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import inspect
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import types
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from generate import render
|
|
||||||
|
|
||||||
|
|
||||||
class BindingTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.schema = json.loads((Path(__file__).resolve().parents[2] / 'src/gen/binding_schema.json').read_text())
|
|
||||||
self.module = types.ModuleType('generated')
|
|
||||||
sys.modules['generated'] = self.module
|
|
||||||
self.addCleanup(sys.modules.pop, 'generated')
|
|
||||||
exec(render(self.schema), self.module.__dict__)
|
|
||||||
module = self.module
|
|
||||||
|
|
||||||
class Transport:
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
|
|
||||||
def apply(self, envelope):
|
|
||||||
self.calls.append(envelope)
|
|
||||||
kind, payload = envelope.body
|
|
||||||
if kind == 'Queries':
|
|
||||||
return module.Receipt(None, [{'Volume': 125.0}], 2)
|
|
||||||
if 'CreateMany' in payload:
|
|
||||||
return module.Receipt({'NewIds': [10, 11]}, [], 2)
|
|
||||||
return module.Receipt({'NewId': 9}, [], 2)
|
|
||||||
|
|
||||||
self.transport = Transport()
|
|
||||||
self.api = module.DocApi(self.transport, active_tab=4)
|
|
||||||
self.doc = self.api.document(4)
|
|
||||||
|
|
||||||
def test_constructors_returns_and_wire_payloads(self):
|
|
||||||
solid = self.doc.solids().create_cuboid([0, 0, 0], [5, 5, 5])
|
|
||||||
self.assertIsInstance(solid, self.module.Solid)
|
|
||||||
self.assertEqual(self.transport.calls[-1].body, ('Op', {'CreateSolid': {'Cuboid': {'origin': [0, 0, 0], 'size': [5, 5, 5]}}}))
|
|
||||||
self.assertEqual(solid.volume(), 125.0)
|
|
||||||
self.assertIsInstance(solid.intersect(solid), self.module.Solid)
|
|
||||||
self.assertEqual(self.transport.calls[-1].body[1], {'SolidBoolean': {'a': 9, 'b': 9, 'op': 'Intersection', 'erase_sources': True}})
|
|
||||||
points = self.doc.curves().create_points([[0, 0, 0], [1, 1, 1]])
|
|
||||||
self.assertEqual([p.id for p in points], [10, 11])
|
|
||||||
self.assertTrue(all(isinstance(p, self.module.Point) for p in points))
|
|
||||||
self.doc.solids().revolve(points[0], [0, 0, 0], [0, 0, 1], 3.14)
|
|
||||||
self.assertEqual(self.transport.calls[-1].body[1]['Revolve']['axis'], ([0, 0, 0], [0, 0, 1]))
|
|
||||||
self.doc.solids().loft(points)
|
|
||||||
self.assertEqual(self.transport.calls[-1].body[1], {'Loft': {'profiles': [10, 11]}})
|
|
||||||
self.assertIsNone(points[0].delete())
|
|
||||||
|
|
||||||
def test_every_schema_method_and_constructor_survives_generation(self):
|
|
||||||
for family in self.schema['families']:
|
|
||||||
handle = getattr(self.module, family['handle'])
|
|
||||||
collection = getattr(self.doc, family['collection'])()
|
|
||||||
for method in family['methods']:
|
|
||||||
self.assertTrue(callable(getattr(handle, method['name'])))
|
|
||||||
for constructor in family['constructors']:
|
|
||||||
method = getattr(collection, constructor['name'])
|
|
||||||
self.assertEqual(list(inspect.signature(method).parameters), [a['name'] for a in constructor['args']])
|
|
||||||
|
|
||||||
def test_foreign_handles_and_tab_mismatch_do_not_send(self):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
self.api.document(5)
|
|
||||||
foreign = self.module.Solid(9, self.module.DocApi(object(), 4).document(4))
|
|
||||||
local = self.module.Solid(9, self.doc)
|
|
||||||
for operation in [lambda: local.union(foreign), lambda: self.doc.solids().extrude(foreign, [0, 0, 1]), lambda: self.doc.solids().loft([foreign])]:
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
operation()
|
|
||||||
self.assertEqual(self.transport.calls, [])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
//! Regenerate the API reference and binding schema from the curated spec.
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Spec {
|
|
||||||
meta: Meta,
|
|
||||||
#[serde(default)]
|
|
||||||
family: Vec<Family>,
|
|
||||||
#[serde(default)]
|
|
||||||
kernel_map: Vec<KernelMap>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Meta {
|
|
||||||
envelope_version: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Family {
|
|
||||||
name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
acadrust_variant: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
handle: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
collection: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
constructor: Vec<Constructor>,
|
|
||||||
#[serde(default)]
|
|
||||||
method: Vec<Method>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
// `op`/`fixed` are part of the spec schema (used by binding_schema consumers and
|
|
||||||
// future codegen) even though api_reference generation doesn't read them yet.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct Constructor {
|
|
||||||
name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
args: Vec<Arg>,
|
|
||||||
#[serde(default)]
|
|
||||||
op: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
fixed: Option<toml::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
bulk: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
returns: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
status: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Method {
|
|
||||||
name: String,
|
|
||||||
kind: String, // "op" | "query"
|
|
||||||
#[serde(default)]
|
|
||||||
op: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
query: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
args: Vec<Arg>,
|
|
||||||
#[serde(default)]
|
|
||||||
returns: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
fixed: Option<toml::Value>,
|
|
||||||
#[serde(default)]
|
|
||||||
status: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Arg {
|
|
||||||
name: String,
|
|
||||||
ty: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct KernelMap {
|
|
||||||
op: String,
|
|
||||||
kernel: String,
|
|
||||||
returns: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
|
||||||
let spec_path = manifest_dir.join("spec/entities.toml");
|
|
||||||
println!("cargo:rerun-if-changed={}", spec_path.display());
|
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=src/gen/layouts.json");
|
|
||||||
let spec_src = fs::read_to_string(&spec_path).expect("read spec/entities.toml");
|
|
||||||
let spec: Spec = toml::from_str(&spec_src).expect("parse spec/entities.toml");
|
|
||||||
|
|
||||||
let gen_dir = manifest_dir.join("src/gen");
|
|
||||||
fs::create_dir_all(&gen_dir).unwrap();
|
|
||||||
|
|
||||||
write_if_changed(&gen_dir.join("api_reference.md"), &api_reference_md(&spec));
|
|
||||||
|
|
||||||
write_if_changed(
|
|
||||||
&gen_dir.join("binding_schema.json"),
|
|
||||||
&binding_schema_json(&spec),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrite a generated file only when content changed (keeps mtime/CI stable).
|
|
||||||
fn write_if_changed(path: &PathBuf, content: &str) {
|
|
||||||
let unchanged = fs::read_to_string(path)
|
|
||||||
.map(|old| old == content)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !unchanged {
|
|
||||||
fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a method/constructor's `fixed` fields compactly (e.g.
|
|
||||||
/// `, op="Intersection", erase_sources=true`). Empty when no fixed fields.
|
|
||||||
fn fmt_fixed(fixed: Option<&toml::Value>) -> String {
|
|
||||||
let Some(fixed) = fixed else {
|
|
||||||
return String::new();
|
|
||||||
};
|
|
||||||
let Some(tbl) = fixed.as_table() else {
|
|
||||||
return String::new();
|
|
||||||
};
|
|
||||||
if tbl.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let parts: Vec<String> = tbl.iter().map(|(k, v)| format!("{k}={v}")).collect();
|
|
||||||
format!("; fixed {}", parts.join(", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fmt_args(args: &[Arg]) -> String {
|
|
||||||
// Renders `(...)` with the args, or `()` for a zero-arg method.
|
|
||||||
if args.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
args.iter()
|
|
||||||
.map(|a| format!("{}: {}", a.name, a.ty))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The parenthesised argument list: `(a: T, b: U)` or `()` for zero args.
|
|
||||||
fn fmt_call_args(args: &[Arg]) -> String {
|
|
||||||
if args.is_empty() {
|
|
||||||
return "()".to_string();
|
|
||||||
}
|
|
||||||
format!("({})", fmt_args(args))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The generic handle methods + cross-cutting surface (applies to every handle;
|
|
||||||
/// documented once here rather than per-family).
|
|
||||||
fn generic_reference_md() -> String {
|
|
||||||
r#"## Generic methods (every handle)
|
|
||||||
|
|
||||||
Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`,
|
|
||||||
`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a
|
|
||||||
`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports:
|
|
||||||
|
|
||||||
- **`id()` -> `ObjectId`** — the stable entity identity.
|
|
||||||
- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities
|
|
||||||
computed; later families may be `Unsupported`).
|
|
||||||
- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1:
|
|
||||||
solids only.**
|
|
||||||
- **`delete()`** — remove the entity (one undo step).
|
|
||||||
- **`layer()` -> `String`** — the name of the layer the entity is on.
|
|
||||||
- **`set_layer(layer)`** — move the entity to an existing layer (one undo step;
|
|
||||||
blocked if the entity or target layer is locked).
|
|
||||||
|
|
||||||
`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed
|
|
||||||
downcast when `kind == "Solid3D"`), and XDATA access through
|
|
||||||
`xdata(application_name)` / `set_xdata(application_name, record)`.
|
|
||||||
|
|
||||||
## Layer table
|
|
||||||
|
|
||||||
Layers are a named document table, not an entity family. `Document::layers()`
|
|
||||||
lists all layers as `Vec<LayerInfo>`. Table-level ops are `CreateLayer(info)`,
|
|
||||||
`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()`
|
|
||||||
returns the layer name and `Entity::set_layer(layer)` moves the entity to an
|
|
||||||
existing layer. The host rejects duplicate names, deletion of layer "0" or the
|
|
||||||
current layer, and removing a layer that still has entities assigned.
|
|
||||||
|
|
||||||
## Transports
|
|
||||||
|
|
||||||
- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host.
|
|
||||||
- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the
|
|
||||||
`ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes
|
|
||||||
`DocApiRequest { tab_id, bytes }` to the same executor.
|
|
||||||
- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that
|
|
||||||
builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a
|
|
||||||
`PluginRequestSender` (out-of-process / worker-thread plugins).
|
|
||||||
|
|
||||||
## Collections & cross-cutting
|
|
||||||
|
|
||||||
- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`.
|
|
||||||
- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup,
|
|
||||||
`revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only
|
|
||||||
batch, one round-trip, **no revision bump**).
|
|
||||||
- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`,
|
|
||||||
`transform_many(&ids, placement)`, `delete_many(&ids)`.
|
|
||||||
- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` /
|
|
||||||
`compensate`). **Not** a transaction: no atomicity, no rollback.
|
|
||||||
|
|
||||||
## Bulk ops — single op, all-or-nothing, item-capped
|
|
||||||
|
|
||||||
`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo
|
|
||||||
step, one publish, and **all-or-nothing via upfront validation** (no rollback).
|
|
||||||
Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for
|
|
||||||
bulk data (N ≳ a few hundred); per-op autocommit is the right tool for
|
|
||||||
modeling-scale work.
|
|
||||||
|
|
||||||
## Semantics
|
|
||||||
|
|
||||||
- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host
|
|
||||||
call = one undo step. Multi-op write transactions were removed (security/
|
|
||||||
integrity review).
|
|
||||||
- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a
|
|
||||||
failed op applies nothing and records no undo step. `erase_sources` booleans run
|
|
||||||
only on success.
|
|
||||||
- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported,
|
|
||||||
Transport}`; bulk validation names the failing index; a stale handle returns
|
|
||||||
`UnknownId` (never a panic).
|
|
||||||
|
|
||||||
"#
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_suffix(status: Option<&str>) -> &'static str {
|
|
||||||
match status {
|
|
||||||
Some("unsupported") => " — **not yet supported** (returns `ApiError::Unsupported`)",
|
|
||||||
Some("planned") => " — **planned** (no callable surface yet)",
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api_reference_md(spec: &Spec) -> String {
|
|
||||||
let mut s = String::new();
|
|
||||||
s.push_str("# ocs_doc_api — API reference (auto-generated)\n\n");
|
|
||||||
s.push_str(&format!(
|
|
||||||
"Envelope version: `{}`\n\n",
|
|
||||||
spec.meta.envelope_version
|
|
||||||
));
|
|
||||||
s.push_str("Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.\n\n");
|
|
||||||
|
|
||||||
for fam in &spec.family {
|
|
||||||
let collection = fam.collection.as_deref().unwrap_or("-");
|
|
||||||
let handle = fam.handle.as_deref().unwrap_or(&fam.name);
|
|
||||||
s.push_str(&format!(
|
|
||||||
"## `{}` (acadrust `{}`, collection `{}`)\n\n",
|
|
||||||
handle,
|
|
||||||
fam.acadrust_variant.as_deref().unwrap_or("-"),
|
|
||||||
collection
|
|
||||||
));
|
|
||||||
if let Some(d) = &fam.description {
|
|
||||||
s.push_str(&format!("{d}\n\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fam.constructor.is_empty() {
|
|
||||||
s.push_str(&format!("### Constructors (`doc.{collection}()`)\n\n"));
|
|
||||||
for c in &fam.constructor {
|
|
||||||
let ret = c.returns.as_deref().unwrap_or(handle);
|
|
||||||
let bulk = if c.bulk { " *(bulk op)*" } else { "" };
|
|
||||||
let op = c.op.as_deref().unwrap_or("-");
|
|
||||||
let fixed = fmt_fixed(c.fixed.as_ref());
|
|
||||||
s.push_str(&format!(
|
|
||||||
"- **{}**{} -> `{ret}`{bulk} — `{op}`{fixed}{}\n",
|
|
||||||
c.name,
|
|
||||||
fmt_call_args(&c.args),
|
|
||||||
status_suffix(c.status.as_deref())
|
|
||||||
));
|
|
||||||
}
|
|
||||||
s.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fam.method.is_empty() {
|
|
||||||
s.push_str("### Methods\n\n");
|
|
||||||
for m in &fam.method {
|
|
||||||
let target = m.op.as_deref().or(m.query.as_deref()).unwrap_or("-");
|
|
||||||
let fixed = fmt_fixed(m.fixed.as_ref());
|
|
||||||
s.push_str(&format!(
|
|
||||||
"- **{}**{} -> `{}` — {} `{}`{fixed}{}\n",
|
|
||||||
m.name,
|
|
||||||
fmt_call_args(&m.args),
|
|
||||||
m.returns.as_deref().unwrap_or("()"),
|
|
||||||
m.kind,
|
|
||||||
target,
|
|
||||||
status_suffix(m.status.as_deref())
|
|
||||||
));
|
|
||||||
}
|
|
||||||
s.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.push_str(&generic_reference_md());
|
|
||||||
|
|
||||||
s.push_str("## Kernel mapping\n\n| op | kernel call | returns |\n|---|---|---|\n");
|
|
||||||
for k in &spec.kernel_map {
|
|
||||||
s.push_str(&format!("| {} | `{}` | {} |\n", k.op, k.kernel, k.returns));
|
|
||||||
}
|
|
||||||
s.push('\n');
|
|
||||||
s.push_str(&wire_vocabulary_md());
|
|
||||||
s.push('\n');
|
|
||||||
s.push_str(&error_and_envelope_md());
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The wire vocabulary section: every `Operation`/`Query` variant with its payload,
|
|
||||||
/// read from `src/gen/layouts.json` (the curated wire-layout snapshot).
|
|
||||||
fn wire_vocabulary_md() -> String {
|
|
||||||
let layouts = read_layouts();
|
|
||||||
let mut s = String::from("## Wire vocabulary\n\nEvery `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec<Query>)` for a read batch.\n\n");
|
|
||||||
s.push_str("### `Operation`\n\n");
|
|
||||||
if let Some(variants) = layouts
|
|
||||||
.get("Operation")
|
|
||||||
.and_then(|o| o.get("enum"))
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
for v in variants {
|
|
||||||
if let Some(name) = v.as_str() {
|
|
||||||
s.push_str(&format!("- `{name}`\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.push_str("\n### `Query`\n\n");
|
|
||||||
if let Some(variants) = layouts
|
|
||||||
.get("Query")
|
|
||||||
.and_then(|o| o.get("enum"))
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
for v in variants {
|
|
||||||
if let Some(name) = v.as_str() {
|
|
||||||
s.push_str(&format!("- `{name}`\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The error model + envelope/transport section.
|
|
||||||
fn error_and_envelope_md() -> String {
|
|
||||||
r#"## Errors (`ApiError`)
|
|
||||||
|
|
||||||
| Variant | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `Validation { op, reason }` | Input rejected before any document mutation. |
|
|
||||||
| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). |
|
|
||||||
| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). |
|
|
||||||
| `Unsupported(String)` | Capability not supported for this entity or backend. |
|
|
||||||
| `Transport(String)` | The channel failed (disconnected / oversized / timeout). |
|
|
||||||
|
|
||||||
`ApiError` is serializable, so IPC returns the same structured error the in-process
|
|
||||||
executor produced.
|
|
||||||
|
|
||||||
## Envelope & transport
|
|
||||||
|
|
||||||
`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or
|
|
||||||
`Queries(Vec<Query>)` (a read batch). bincode-serialized; `version` is the crate's
|
|
||||||
envelope version (bridges must check it). Transports: `InProcess` (host) and
|
|
||||||
`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport:
|
|
||||||
Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries
|
|
||||||
`outcome` (per-op result), `query_results`, and `new_revision`.
|
|
||||||
"#
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The merged, self-contained binding handover schema (§10.2). Object model +
|
|
||||||
/// method signatures + op/query mapping + curated wire vocabulary inlined per node.
|
|
||||||
/// The handle types for the binding schema, derived from the spec's families
|
|
||||||
/// ("Entity" always first, then the deduped family handles in spec order).
|
|
||||||
fn handles_for_schema(spec: &Spec) -> Vec<String> {
|
|
||||||
let mut out = vec!["Entity".to_string()];
|
|
||||||
for fam in &spec.family {
|
|
||||||
let h = fam.handle.as_deref().unwrap_or(&fam.name);
|
|
||||||
if h != "Entity" && !out.iter().any(|x| x == h) {
|
|
||||||
out.push(h.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn binding_schema_json(spec: &Spec) -> String {
|
|
||||||
let registry = read_layouts();
|
|
||||||
|
|
||||||
let families: Vec<serde_json::Value> = spec
|
|
||||||
.family
|
|
||||||
.iter()
|
|
||||||
.map(|fam| {
|
|
||||||
let methods: Vec<serde_json::Value> = fam
|
|
||||||
.method
|
|
||||||
.iter()
|
|
||||||
.map(|m| {
|
|
||||||
serde_json::json!({
|
|
||||||
"name": m.name,
|
|
||||||
"kind": m.kind,
|
|
||||||
"op": m.op,
|
|
||||||
"query": m.query,
|
|
||||||
"args": m.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::<Vec<_>>(),
|
|
||||||
"returns": m.returns,
|
|
||||||
"bulk": m.op.as_deref() == Some("CreateMany"),
|
|
||||||
"read_only": m.kind == "query",
|
|
||||||
"fixed": m.fixed,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
serde_json::json!({
|
|
||||||
"name": fam.name,
|
|
||||||
"handle": fam.handle,
|
|
||||||
"collection": fam.collection,
|
|
||||||
"acadrust_variant": fam.acadrust_variant,
|
|
||||||
"methods": methods,
|
|
||||||
"constructors": fam.constructor.iter().map(|c| serde_json::json!({
|
|
||||||
"name": c.name, "op": c.op, "fixed": c.fixed, "bulk": c.bulk,
|
|
||||||
"returns": c.returns.as_deref().unwrap_or(fam.handle.as_deref().unwrap_or(&fam.name)),
|
|
||||||
"args": c.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::<Vec<_>>()
|
|
||||||
})).collect::<Vec<_>>(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let schema = serde_json::json!({
|
|
||||||
"schema": "ocs_doc_api/binding_schema",
|
|
||||||
"envelope_version": spec.meta.envelope_version,
|
|
||||||
"object_model": {
|
|
||||||
"root": "DocApi",
|
|
||||||
"document": "Document",
|
|
||||||
"collections": ["solids", "curves", "entities"],
|
|
||||||
// Derived from the spec's family handles (deduped, "Entity" first) so
|
|
||||||
// new handle families appear in the handover automatically.
|
|
||||||
"handles": handles_for_schema(spec),
|
|
||||||
},
|
|
||||||
"families": families,
|
|
||||||
// Curated wire vocabulary (not a complete binary codec schema).
|
|
||||||
"layouts": registry,
|
|
||||||
});
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&schema).expect("serialize binding schema")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the manually maintained wire vocabulary; malformed snapshots fail the build.
|
|
||||||
fn read_layouts() -> serde_json::Value {
|
|
||||||
let src = fs::read_to_string(layouts_snapshot_path()).expect("read layouts.json");
|
|
||||||
serde_json::from_str(&src).expect("parse layouts.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layouts_snapshot_path() -> PathBuf {
|
|
||||||
PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/gen/layouts.json")
|
|
||||||
}
|
|
||||||
@@ -1,710 +0,0 @@
|
|||||||
//! Shared in-memory `DocApiBackend` for the runnable examples.
|
|
||||||
//!
|
|
||||||
//! Lets the examples run without the host binary: an in-process mock document
|
|
||||||
//! (HashMap<ObjectId, Body> + a kind table) behind the `InProcess` transport.
|
|
||||||
//! Not for production use — the real backend lives in the host (`src/app/doc_api.rs`).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use ocs_doc_api::backend::{DocApiBackend, KernelBody};
|
|
||||||
use ocs_doc_api::{
|
|
||||||
normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind,
|
|
||||||
GeometryRevision, LayerInfo, ObjectId, PlacementSpec,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct MockBackend {
|
|
||||||
next_id: u64,
|
|
||||||
revision: u64,
|
|
||||||
undo_steps: u32,
|
|
||||||
bodies: HashMap<ObjectId, KernelBody>,
|
|
||||||
kinds: HashMap<ObjectId, String>,
|
|
||||||
/// Stored 2D curve specs so `bounds()` works on non-solid entities too.
|
|
||||||
curves: HashMap<ObjectId, Curve2Spec>,
|
|
||||||
/// Stored text content for Text/MText annotations.
|
|
||||||
text_values: HashMap<ObjectId, String>,
|
|
||||||
/// Stored hatch boundary loops.
|
|
||||||
hatch_boundaries: HashMap<ObjectId, Vec<Vec<[f64; 2]>>>,
|
|
||||||
/// Stored dimension measurements.
|
|
||||||
dimension_measurements: HashMap<ObjectId, f64>,
|
|
||||||
/// Stored insert attributes.
|
|
||||||
attributes_store: HashMap<ObjectId, Vec<(String, String)>>,
|
|
||||||
/// Stored viewport views (target, height).
|
|
||||||
viewport_views: HashMap<ObjectId, ([f64; 3], f64)>,
|
|
||||||
/// Stored XDATA records keyed by (entity id, application name).
|
|
||||||
xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>,
|
|
||||||
/// Stored XRECORD payloads keyed by entity id.
|
|
||||||
xrecord_store: HashMap<ObjectId, ocs_doc_api::XRecordSpec>,
|
|
||||||
/// Stored layer table.
|
|
||||||
layers: HashMap<String, LayerInfo>,
|
|
||||||
/// Layer name assigned to each entity.
|
|
||||||
entity_layers: HashMap<ObjectId, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MockBackend {
|
|
||||||
fn default() -> Self {
|
|
||||||
let mut backend = Self {
|
|
||||||
next_id: 0,
|
|
||||||
revision: 0,
|
|
||||||
undo_steps: 0,
|
|
||||||
bodies: HashMap::new(),
|
|
||||||
kinds: HashMap::new(),
|
|
||||||
curves: HashMap::new(),
|
|
||||||
text_values: HashMap::new(),
|
|
||||||
hatch_boundaries: HashMap::new(),
|
|
||||||
dimension_measurements: HashMap::new(),
|
|
||||||
attributes_store: HashMap::new(),
|
|
||||||
viewport_views: HashMap::new(),
|
|
||||||
xdata_store: HashMap::new(),
|
|
||||||
xrecord_store: HashMap::new(),
|
|
||||||
layers: HashMap::new(),
|
|
||||||
entity_layers: HashMap::new(),
|
|
||||||
};
|
|
||||||
backend.layers.insert("0".to_string(), LayerInfo::new("0"));
|
|
||||||
backend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockBackend {
|
|
||||||
fn alloc(&mut self, kind: &str) -> ObjectId {
|
|
||||||
self.next_id += 1;
|
|
||||||
let id = ObjectId::from_u64(self.next_id);
|
|
||||||
self.kinds.insert(id, kind.to_string());
|
|
||||||
id
|
|
||||||
}
|
|
||||||
fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> {
|
|
||||||
self.bodies.get(&id).ok_or(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn undo_steps(&self) -> u32 {
|
|
||||||
self.undo_steps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds).
|
|
||||||
fn curve_bounds(spec: &Curve2Spec) -> Aabb {
|
|
||||||
match spec {
|
|
||||||
Curve2Spec::Line { start, end, .. } => Aabb {
|
|
||||||
min: [
|
|
||||||
start[0].min(end[0]),
|
|
||||||
start[1].min(end[1]),
|
|
||||||
start[2].min(end[2]),
|
|
||||||
],
|
|
||||||
max: [
|
|
||||||
start[0].max(end[0]),
|
|
||||||
start[1].max(end[1]),
|
|
||||||
start[2].max(end[2]),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Curve2Spec::Circle { centre, radius, .. } => Aabb {
|
|
||||||
min: [centre[0] - radius, centre[1] - radius, centre[2]],
|
|
||||||
max: [centre[0] + radius, centre[1] + radius, centre[2]],
|
|
||||||
},
|
|
||||||
Curve2Spec::Point { position, .. } => Aabb {
|
|
||||||
min: *position,
|
|
||||||
max: *position,
|
|
||||||
},
|
|
||||||
Curve2Spec::Polyline { points, .. } => {
|
|
||||||
let mut min = [f64::INFINITY; 3];
|
|
||||||
let mut max = [f64::NEG_INFINITY; 3];
|
|
||||||
for p in points {
|
|
||||||
for i in 0..3 {
|
|
||||||
min[i] = min[i].min(p[i]);
|
|
||||||
max[i] = max[i].max(p[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Aabb { min, max }
|
|
||||||
}
|
|
||||||
Curve2Spec::Arc { centre, radius, .. } => Aabb {
|
|
||||||
min: [centre[0] - radius, centre[1] - radius, centre[2]],
|
|
||||||
max: [centre[0] + radius, centre[1] + radius, centre[2]],
|
|
||||||
},
|
|
||||||
Curve2Spec::Ellipse {
|
|
||||||
centre, major_axis, ..
|
|
||||||
} => {
|
|
||||||
let r = (major_axis[0] * major_axis[0]
|
|
||||||
+ major_axis[1] * major_axis[1]
|
|
||||||
+ major_axis[2] * major_axis[2])
|
|
||||||
.sqrt();
|
|
||||||
Aabb {
|
|
||||||
min: [centre[0] - r, centre[1] - r, centre[2] - r],
|
|
||||||
max: [centre[0] + r, centre[1] + r, centre[2] + r],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Curve2Spec::Spline { control_points, .. } => {
|
|
||||||
let mut min = [f64::INFINITY; 3];
|
|
||||||
let mut max = [f64::NEG_INFINITY; 3];
|
|
||||||
for p in control_points {
|
|
||||||
for i in 0..3 {
|
|
||||||
min[i] = min[i].min(p[i]);
|
|
||||||
max[i] = max[i].max(p[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Aabb { min, max }
|
|
||||||
}
|
|
||||||
Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb {
|
|
||||||
min: *origin,
|
|
||||||
max: *origin,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocApiBackend for MockBackend {
|
|
||||||
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody> {
|
|
||||||
Ok(self.body(id)?.clone())
|
|
||||||
}
|
|
||||||
fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Solid3D");
|
|
||||||
self.bodies.insert(id, body.clone());
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> {
|
|
||||||
if self.bodies.insert(id, body.clone()).is_none() {
|
|
||||||
return Err(ApiError::UnknownId(id));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> {
|
|
||||||
// Validate per-spec layers up front so the op is atomic.
|
|
||||||
for spec in specs {
|
|
||||||
if let Some(name) = spec.layer() {
|
|
||||||
self.lookup_layer(name).map_err(|e| ApiError::validation(
|
|
||||||
"CreateMany",
|
|
||||||
e.to_string(),
|
|
||||||
))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut next = self.clone();
|
|
||||||
let ids = specs
|
|
||||||
.iter()
|
|
||||||
.map(|spec| match spec {
|
|
||||||
ocs_doc_api::EntitySpec::Solid(spec) => {
|
|
||||||
next.store_solid(&ocs_doc_api::geom::make_solid(spec)?)
|
|
||||||
}
|
|
||||||
ocs_doc_api::EntitySpec::Curve(spec) => {
|
|
||||||
ocs_doc_api::convert::curve_spec_to_entity(spec)?;
|
|
||||||
next.add_curve(spec)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<ApiResult<Vec<_>>>()?;
|
|
||||||
next.push_undo("CreateMany");
|
|
||||||
next.finalize_op();
|
|
||||||
*self = next;
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> {
|
|
||||||
let mut next = self.clone();
|
|
||||||
for &id in ids {
|
|
||||||
next.transform_entity(id, placement)?;
|
|
||||||
}
|
|
||||||
next.push_undo("TransformMany");
|
|
||||||
next.finalize_op();
|
|
||||||
*self = next;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
|
|
||||||
let kind = match spec {
|
|
||||||
Curve2Spec::Line { .. } => "Line",
|
|
||||||
Curve2Spec::Circle { .. } => "Circle",
|
|
||||||
Curve2Spec::Polyline { .. } => "LwPolyline",
|
|
||||||
Curve2Spec::Point { .. } => "Point",
|
|
||||||
Curve2Spec::Arc { .. } => "Arc",
|
|
||||||
Curve2Spec::Ellipse { .. } => "Ellipse",
|
|
||||||
Curve2Spec::Spline { .. } => "Spline",
|
|
||||||
Curve2Spec::Ray { .. } => "Ray",
|
|
||||||
Curve2Spec::XLine { .. } => "XLine",
|
|
||||||
};
|
|
||||||
let layer = spec
|
|
||||||
.layer()
|
|
||||||
.map(|name| {
|
|
||||||
self.lookup_layer(name).map(|info| info.normalized_name())
|
|
||||||
})
|
|
||||||
.transpose()?;
|
|
||||||
let id = self.alloc(kind);
|
|
||||||
self.curves.insert(id, spec.clone());
|
|
||||||
self.entity_layers
|
|
||||||
.insert(id, layer.unwrap_or_else(|| "0".to_string()));
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lookup_layer(&self, name: &str) -> ApiResult<LayerInfo> {
|
|
||||||
let norm = normalize_layer_name(name);
|
|
||||||
if norm.is_empty() {
|
|
||||||
return Err(ApiError::validation("LookupLayer", "empty layer name"));
|
|
||||||
}
|
|
||||||
self.layers
|
|
||||||
.get(&norm)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist")))
|
|
||||||
}
|
|
||||||
fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult<ObjectId> {
|
|
||||||
if spec.block_name.is_empty() {
|
|
||||||
return Err(ApiError::validation("CreateInsert", "empty block name"));
|
|
||||||
}
|
|
||||||
Ok(self.alloc("Insert"))
|
|
||||||
}
|
|
||||||
fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Viewport");
|
|
||||||
self.viewport_views
|
|
||||||
.insert(id, (spec.view_target, spec.view_height));
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Text");
|
|
||||||
self.text_values.insert(id, spec.value.clone());
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("MText");
|
|
||||||
self.text_values.insert(id, spec.value.clone());
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> {
|
|
||||||
if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) {
|
|
||||||
e.insert(value.to_string());
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn text_content(&self, id: ObjectId) -> ApiResult<String> {
|
|
||||||
self.text_values
|
|
||||||
.get(&id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Hatch");
|
|
||||||
self.hatch_boundaries
|
|
||||||
.insert(id, vec![spec.boundary.clone()]);
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> {
|
|
||||||
self.hatch_boundaries
|
|
||||||
.get(&id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
fn add_dimension_linear(
|
|
||||||
&mut self,
|
|
||||||
spec: &ocs_doc_api::ops::DimensionSpec,
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Dimension");
|
|
||||||
let d = ((spec.first_point[0] - spec.second_point[0]).powi(2)
|
|
||||||
+ (spec.first_point[1] - spec.second_point[1]).powi(2)
|
|
||||||
+ (spec.first_point[2] - spec.second_point[2]).powi(2))
|
|
||||||
.sqrt();
|
|
||||||
self.dimension_measurements.insert(id, d);
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn add_dimension_radius(
|
|
||||||
&mut self,
|
|
||||||
spec: &ocs_doc_api::ops::DimensionRadialSpec,
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Dimension");
|
|
||||||
let d = ((spec.center[0] - spec.point[0]).powi(2)
|
|
||||||
+ (spec.center[1] - spec.point[1]).powi(2)
|
|
||||||
+ (spec.center[2] - spec.point[2]).powi(2))
|
|
||||||
.sqrt();
|
|
||||||
self.dimension_measurements.insert(id, d);
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn add_dimension_diameter(
|
|
||||||
&mut self,
|
|
||||||
spec: &ocs_doc_api::ops::DimensionRadialSpec,
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
self.add_dimension_radius(spec)
|
|
||||||
}
|
|
||||||
fn add_dimension_angular(
|
|
||||||
&mut self,
|
|
||||||
_spec: &ocs_doc_api::ops::DimensionAngularSpec,
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Dimension");
|
|
||||||
self.dimension_measurements.insert(id, 90.0);
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64> {
|
|
||||||
self.dimension_measurements
|
|
||||||
.get(&id)
|
|
||||||
.copied()
|
|
||||||
.ok_or(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
fn add_attribute_definition(
|
|
||||||
&mut self,
|
|
||||||
spec: &ocs_doc_api::ops::AttributeDefinitionSpec,
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
if spec.tag.is_empty() {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"CreateAttributeDefinition",
|
|
||||||
"empty tag",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(self.alloc("AttributeDefinition"))
|
|
||||||
}
|
|
||||||
fn add_dimension_angular2ln(
|
|
||||||
&mut self,
|
|
||||||
_spec: &ocs_doc_api::ops::DimensionAngularSpec,
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("Dimension");
|
|
||||||
self.dimension_measurements.insert(id, 90.0);
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult<ObjectId> {
|
|
||||||
let cols = spec.data.first().map(|r| r.len()).unwrap_or(0);
|
|
||||||
if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"CreateTable",
|
|
||||||
"table needs a non-empty rectangular grid",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(self.alloc("Table"))
|
|
||||||
}
|
|
||||||
fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> {
|
|
||||||
if !self.entity_exists(id) {
|
|
||||||
return Err(ApiError::UnknownId(id));
|
|
||||||
}
|
|
||||||
let attrs = self.attributes_store.entry(id).or_default();
|
|
||||||
if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) {
|
|
||||||
a.1 = value.to_string();
|
|
||||||
} else {
|
|
||||||
attrs.push((tag.to_string(), value.to_string()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn attributes(&self, id: ObjectId) -> ApiResult<Vec<(String, String)>> {
|
|
||||||
self.attributes_store
|
|
||||||
.get(&id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
fn block_entities(&self, _block_name: &str) -> ApiResult<Vec<EntityView>> {
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
fn add_raster_image(
|
|
||||||
&mut self,
|
|
||||||
_spec: &ocs_doc_api::ops::RasterImageSpec,
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
Ok(self.alloc("RasterImage"))
|
|
||||||
}
|
|
||||||
fn loft(
|
|
||||||
&mut self,
|
|
||||||
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
|
|
||||||
) -> ApiResult<ObjectId> {
|
|
||||||
if sections.len() < 2 {
|
|
||||||
return Err(ApiError::validation("Loft", "loft needs >= 2 profiles"));
|
|
||||||
}
|
|
||||||
let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3])
|
|
||||||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?;
|
|
||||||
self.store_solid(&body)
|
|
||||||
}
|
|
||||||
fn set_viewport_view(
|
|
||||||
&mut self,
|
|
||||||
id: ObjectId,
|
|
||||||
view_target: [f64; 3],
|
|
||||||
view_height: f64,
|
|
||||||
) -> ApiResult<()> {
|
|
||||||
if !self.entity_exists(id) {
|
|
||||||
return Err(ApiError::UnknownId(id));
|
|
||||||
}
|
|
||||||
self.viewport_views.insert(id, (view_target, view_height));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> {
|
|
||||||
self.viewport_views
|
|
||||||
.get(&id)
|
|
||||||
.copied()
|
|
||||||
.ok_or(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
fn set_xdata(
|
|
||||||
&mut self,
|
|
||||||
id: ObjectId,
|
|
||||||
application_name: &str,
|
|
||||||
record: Option<&ocs_doc_api::XDataRecord>,
|
|
||||||
) -> ApiResult<()> {
|
|
||||||
if !self.entity_exists(id) {
|
|
||||||
return Err(ApiError::UnknownId(id));
|
|
||||||
}
|
|
||||||
let key = (id, application_name.to_string());
|
|
||||||
match record {
|
|
||||||
Some(r) => {
|
|
||||||
self.xdata_store.insert(key, r.clone());
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.xdata_store.remove(&key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn xdata(
|
|
||||||
&self,
|
|
||||||
id: ObjectId,
|
|
||||||
application_name: &str,
|
|
||||||
) -> ApiResult<Option<ocs_doc_api::XDataRecord>> {
|
|
||||||
if !self.entity_exists(id) {
|
|
||||||
return Err(ApiError::UnknownId(id));
|
|
||||||
}
|
|
||||||
Ok(self
|
|
||||||
.xdata_store
|
|
||||||
.get(&(id, application_name.to_string()))
|
|
||||||
.cloned())
|
|
||||||
}
|
|
||||||
fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<ObjectId> {
|
|
||||||
let id = self.alloc("XRecord");
|
|
||||||
self.xrecord_store.insert(id, spec.clone());
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> {
|
|
||||||
if !self.object_exists(id) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"SetXRecord",
|
|
||||||
format!("unknown ObjectId {id:?}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.xrecord_store.insert(id, spec.clone());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn xrecord(&self, id: ObjectId) -> ApiResult<Option<ocs_doc_api::XRecordSpec>> {
|
|
||||||
if !self.object_exists(id) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"GetXRecord",
|
|
||||||
format!("unknown ObjectId {id:?}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(self.xrecord_store.get(&id).cloned())
|
|
||||||
}
|
|
||||||
fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> {
|
|
||||||
let name = normalize_layer_name(&info.name);
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err(ApiError::validation("CreateLayer", "empty layer name"));
|
|
||||||
}
|
|
||||||
if self.layers.contains_key(&name) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"CreateLayer",
|
|
||||||
format!("layer '{name}' already exists"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut stored = info.clone();
|
|
||||||
stored.name = name.clone();
|
|
||||||
self.layers.insert(name, stored);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> {
|
|
||||||
let name_upper = normalize_layer_name(name);
|
|
||||||
if !self.layers.contains_key(&name_upper) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"UpdateLayer",
|
|
||||||
format!("layer '{name}' does not exist"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut stored = info.clone();
|
|
||||||
stored.name = name_upper.clone();
|
|
||||||
self.layers.insert(name_upper, stored);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn delete_layer(&mut self, name: &str) -> ApiResult<()> {
|
|
||||||
let name_upper = normalize_layer_name(name);
|
|
||||||
if name_upper == "0" {
|
|
||||||
return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0"));
|
|
||||||
}
|
|
||||||
if self.entity_layers.values().any(|l| normalize_layer_name(l) == name_upper) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"DeleteLayer",
|
|
||||||
format!("layer '{name_upper}' still in use"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.layers.remove(&name_upper).is_none() {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"DeleteLayer",
|
|
||||||
format!("layer '{name}' does not exist"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> {
|
|
||||||
if !self.entity_exists(id) {
|
|
||||||
return Err(ApiError::UnknownId(id));
|
|
||||||
}
|
|
||||||
let layer_upper = normalize_layer_name(layer);
|
|
||||||
let target = self
|
|
||||||
.layers
|
|
||||||
.get(&layer_upper)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::validation(
|
|
||||||
"SetEntityLayer",
|
|
||||||
format!("layer '{layer_upper}' does not exist"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if target.flags.locked {
|
|
||||||
return Err(ApiError::Unsupported(format!(
|
|
||||||
"entity {id:?} cannot be moved to locked layer '{layer_upper}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
self.entity_layers.insert(id, layer_upper);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn layers(&self) -> ApiResult<Vec<LayerInfo>> {
|
|
||||||
let mut v: Vec<_> = self.layers.values().cloned().collect();
|
|
||||||
v.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
Ok(v)
|
|
||||||
}
|
|
||||||
fn entity_layer(&self, id: ObjectId) -> ApiResult<String> {
|
|
||||||
self.entity_layers
|
|
||||||
.get(&id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
|
|
||||||
if self.entity_exists(id) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> {
|
|
||||||
self.bodies.remove(&id);
|
|
||||||
self.xdata_store.retain(|(k, _), _| *k != id);
|
|
||||||
self.xrecord_store.remove(&id);
|
|
||||||
self.entity_layers.remove(&id);
|
|
||||||
Ok(self.kinds.remove(&id).is_some())
|
|
||||||
}
|
|
||||||
fn enumerate_entities(
|
|
||||||
&self,
|
|
||||||
kind: Option<&str>,
|
|
||||||
layer: Option<&str>,
|
|
||||||
include_bounds: bool,
|
|
||||||
) -> ApiResult<Vec<EntityView>> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for (id, kind_name) in &self.kinds {
|
|
||||||
if let Some(k) = kind {
|
|
||||||
if !k.eq_ignore_ascii_case(kind_name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let entity_layer = self
|
|
||||||
.entity_layers
|
|
||||||
.get(id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "0".to_string());
|
|
||||||
if let Some(l) = layer {
|
|
||||||
if !l.eq_ignore_ascii_case(&entity_layer) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let bounds = if include_bounds {
|
|
||||||
self.curves.get(id).map(curve_bounds)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
out.push(EntityView {
|
|
||||||
id: *id,
|
|
||||||
kind: kind_name.clone(),
|
|
||||||
layer: entity_layer,
|
|
||||||
bounds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> {
|
|
||||||
let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?;
|
|
||||||
match spec {
|
|
||||||
Curve2Spec::Point { position, .. } => Ok(*position),
|
|
||||||
_ => Err(ApiError::Unsupported(
|
|
||||||
"GetPointPosition is only for Point entities".into(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> {
|
|
||||||
let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?;
|
|
||||||
match spec {
|
|
||||||
Curve2Spec::Line { start, end, .. } => Ok((*start, *end)),
|
|
||||||
_ => Err(ApiError::Unsupported(
|
|
||||||
"GetLineGeometry is only for Line entities".into(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
|
|
||||||
let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone();
|
|
||||||
let layer = self
|
|
||||||
.entity_layers
|
|
||||||
.get(&id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "0".to_string());
|
|
||||||
let bounds = self.bodies.get(&id).and_then(|b| {
|
|
||||||
cadkernel::brep::body_bounds(b).map(|bb| Aabb {
|
|
||||||
min: bb.min,
|
|
||||||
max: bb.max,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
Ok(EntityView {
|
|
||||||
id,
|
|
||||||
kind,
|
|
||||||
layer,
|
|
||||||
bounds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> {
|
|
||||||
if !self.entity_exists(id) {
|
|
||||||
return Err(ApiError::UnknownId(id));
|
|
||||||
}
|
|
||||||
if let Some(body) = self.bodies.get(&id).cloned() {
|
|
||||||
let place = cadkernel::brep::Placement {
|
|
||||||
x_axis: placement.x_axis,
|
|
||||||
y_axis: placement.y_axis,
|
|
||||||
z_axis: placement.z_axis,
|
|
||||||
origin: placement.origin,
|
|
||||||
};
|
|
||||||
let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| {
|
|
||||||
ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed")
|
|
||||||
})?;
|
|
||||||
self.bodies.insert(id, moved);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn profile_curves(&self, _id: ObjectId) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
|
|
||||||
Err(ApiError::Unsupported("profiles not mocked".into()))
|
|
||||||
}
|
|
||||||
fn bounds(&mut self, id: ObjectId) -> ApiResult<Aabb> {
|
|
||||||
// Solids via the kernel body; 2D curves from their stored spec.
|
|
||||||
if let Some(body) = self.bodies.get(&id) {
|
|
||||||
let bb = cadkernel::brep::body_bounds(body)
|
|
||||||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?;
|
|
||||||
return Ok(Aabb {
|
|
||||||
min: bb.min,
|
|
||||||
max: bb.max,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(spec) = self.curves.get(&id) {
|
|
||||||
return Ok(curve_bounds(spec));
|
|
||||||
}
|
|
||||||
Err(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> {
|
|
||||||
let body = self.body(id)?;
|
|
||||||
Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1)
|
|
||||||
}
|
|
||||||
fn volume(&mut self, id: ObjectId) -> ApiResult<f64> {
|
|
||||||
let body = self.body(id)?;
|
|
||||||
Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0)
|
|
||||||
}
|
|
||||||
fn entity_exists(&self, id: ObjectId) -> bool {
|
|
||||||
self.kinds.contains_key(&id)
|
|
||||||
}
|
|
||||||
fn object_exists(&self, id: ObjectId) -> bool {
|
|
||||||
self.xrecord_store.contains_key(&id)
|
|
||||||
}
|
|
||||||
fn revision(&self) -> GeometryRevision {
|
|
||||||
GeometryRevision(self.revision)
|
|
||||||
}
|
|
||||||
fn push_undo(&mut self, _label: &str) {}
|
|
||||||
fn finalize_op(&mut self) {
|
|
||||||
self.undo_steps += 1;
|
|
||||||
self.revision += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signed volume + centroid of a closed mesh — delegates to the crate's single
|
|
||||||
/// source of truth (divergence theorem) so the example mock can't drift.
|
|
||||||
fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
|
|
||||||
ocs_doc_api::geom::mesh_volume_centroid(mesh)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
//! Example 1 — Create solid primitives and query their geometry.
|
|
||||||
//!
|
|
||||||
//! Run: `cargo run --example e1_primitives_and_queries --features host`
|
|
||||||
//!
|
|
||||||
//! Shows: `doc.solids().create_*` for the supported primitives, and the read
|
|
||||||
//! queries `bounds()` / `volume()` / `centroid()` on a `Solid`.
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use ocs_doc_api::DocApi;
|
|
||||||
|
|
||||||
fn main() -> ocs_doc_api::ApiResult<()> {
|
|
||||||
// In-process document over the mock backend (no host binary needed).
|
|
||||||
let api = DocApi::in_process(common::MockBackend::default(), 0);
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
|
|
||||||
// The supported solid primitives.
|
|
||||||
let block = doc
|
|
||||||
.solids()
|
|
||||||
.create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])?;
|
|
||||||
let ball = doc.solids().create_sphere([5.0, 5.0, 5.0], 6.0)?;
|
|
||||||
let cyl = doc.solids().create_cylinder([0.0, 0.0, 0.0], 4.0, 12.0)?;
|
|
||||||
|
|
||||||
// Geometric queries are entity methods — one host call each.
|
|
||||||
let bb = block.bounds()?;
|
|
||||||
let vol = ball.volume()?;
|
|
||||||
let cg = cyl.centroid()?;
|
|
||||||
|
|
||||||
println!("cuboid bounds : {bb:?}");
|
|
||||||
println!("sphere volume : {vol:.2} (analytic ~904.78)");
|
|
||||||
println!("cylinder centroid: {cg:?}");
|
|
||||||
|
|
||||||
// Read-only batching: same queries, ONE round-trip.
|
|
||||||
let res = doc.query_batch(|q| {
|
|
||||||
q.bounds(&block);
|
|
||||||
q.volume(&ball);
|
|
||||||
q.centroid(&cyl);
|
|
||||||
})?;
|
|
||||||
let (bb2, vol2, cg2) = (res.bounds(0)?, res.volume(1)?, res.centroid(2)?);
|
|
||||||
assert_eq!(bb, bb2);
|
|
||||||
assert!((vol - vol2).abs() < 1e-9);
|
|
||||||
assert_eq!(cg, cg2);
|
|
||||||
println!("query_batch matches the per-call results");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
//! Example 2 — Construct two solids and intersect them (the canonical boolean).
|
|
||||||
//!
|
|
||||||
//! Run: `cargo run --example e2_intersect_two_solids --features host`
|
|
||||||
//!
|
|
||||||
//! Shows: `intersect` / `union` / `subtract` on `Solid`, `erase_sources`
|
|
||||||
//! semantics (the result lives at the first input's id, the second is erased),
|
|
||||||
//! and `OpGroup` cleanup on failure.
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use ocs_doc_api::{DocApi, HasId, OpGroup};
|
|
||||||
|
|
||||||
fn main() -> ocs_doc_api::ApiResult<()> {
|
|
||||||
let api = DocApi::in_process(common::MockBackend::default(), 0);
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
let mut grp = OpGroup::new();
|
|
||||||
|
|
||||||
// Two overlapping boxes: [0,10]^3 and [5,15]^3.
|
|
||||||
let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]))?;
|
|
||||||
let b = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]))?;
|
|
||||||
|
|
||||||
// intersect -> SolidBoolean{Intersection, erase_sources:true}. The result is
|
|
||||||
// returned AT `a`'s id; `b` is erased. [5,10]^3 = volume 125.
|
|
||||||
let lens = match a.intersect(&b) {
|
|
||||||
Ok(l) => {
|
|
||||||
grp.commit(); // keep everything
|
|
||||||
l
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
grp.compensate(&doc)?; // best-effort: delete the two boxes
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(lens.id(), a.id(), "result lives at the first input's id");
|
|
||||||
let vol = lens.volume()?;
|
|
||||||
let bb = lens.bounds()?;
|
|
||||||
println!("intersection volume = {vol:.2} (expected 125)");
|
|
||||||
println!("intersection bounds = {bb:?}");
|
|
||||||
|
|
||||||
// union / subtract follow the same shape. Fresh inputs (b was erased above).
|
|
||||||
let c = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?;
|
|
||||||
let d = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?;
|
|
||||||
let uni = c.union(&d)?;
|
|
||||||
// union volume = 1000 + 1000 - 125 (overlap) = 1875.
|
|
||||||
println!("union volume = {:.2} (expected 1875)", uni.volume()?);
|
|
||||||
|
|
||||||
let e = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?;
|
|
||||||
let f = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?;
|
|
||||||
let diff = e.subtract(&f)?;
|
|
||||||
println!("difference volume = {:.2} (expected 875)", diff.volume()?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//! Example 3 — Transform a solid and chain geometric operations.
|
|
||||||
//!
|
|
||||||
//! Run: `cargo run --example e3_transform_and_chain --features host`
|
|
||||||
//!
|
|
||||||
//! Shows: `transform(PlacementSpec)` (in-place, same ObjectId), how bounds shift
|
|
||||||
//! exactly by the translation, and chaining intersect -> transform.
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use ocs_doc_api::{DocApi, PlacementSpec};
|
|
||||||
|
|
||||||
fn main() -> ocs_doc_api::ApiResult<()> {
|
|
||||||
let api = DocApi::in_process(common::MockBackend::default(), 0);
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
|
|
||||||
let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?;
|
|
||||||
let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?;
|
|
||||||
|
|
||||||
// Boolean first: lens = [5,10]^3.
|
|
||||||
let lens = a.intersect(&b)?;
|
|
||||||
let before = lens.bounds()?;
|
|
||||||
println!("lens bounds before transform: {before:?}");
|
|
||||||
|
|
||||||
// Transform is in-place: the SAME ObjectId is preserved; bounds shift by the
|
|
||||||
// translation. [5,10]^3 moved +100 in X -> [105,110] x [5,10] x [5,10].
|
|
||||||
lens.transform(PlacementSpec::at([100.0, 0.0, 0.0]))?;
|
|
||||||
let after = lens.bounds()?;
|
|
||||||
println!("lens bounds after +100 X : {after:?}");
|
|
||||||
assert!((after.min[0] - 105.0).abs() < 1e-4);
|
|
||||||
assert!((after.max[0] - 110.0).abs() < 1e-4);
|
|
||||||
assert_eq!(after.min[1], before.min[1], "Y unchanged");
|
|
||||||
|
|
||||||
// Volume is invariant under a rigid transform.
|
|
||||||
let vol = lens.volume()?;
|
|
||||||
println!("lens volume after transform = {vol:.2} (still ~125)");
|
|
||||||
assert!((vol - 125.0).abs() < 1.0);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
//! Example 4 — 2D entities and bulk creation.
|
|
||||||
//!
|
|
||||||
//! Run: `cargo run --example e4_2d_entities_and_bulk --features host`
|
|
||||||
//!
|
|
||||||
//! Shows: the minimal-2D constructors (`create_line`/`create_circle`/
|
|
||||||
//! `create_polyline`/`create_point`), `bounds()` on 2D entities, and the bulk op
|
|
||||||
//! `create_points` (one op, all-or-nothing, one undo step).
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use ocs_doc_api::{DocApi, HasId};
|
|
||||||
|
|
||||||
fn main() -> ocs_doc_api::ApiResult<()> {
|
|
||||||
let api = DocApi::in_process(common::MockBackend::default(), 0);
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
|
|
||||||
// Individual 2D constructors.
|
|
||||||
let line = doc
|
|
||||||
.curves()
|
|
||||||
.create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?;
|
|
||||||
let circle = doc.curves().create_circle([5.0, 5.0, 0.0], 3.0)?;
|
|
||||||
let poly = doc.curves().create_polyline(
|
|
||||||
&[
|
|
||||||
[0.0, 0.0, 0.0],
|
|
||||||
[1.0, 0.0, 0.0],
|
|
||||||
[1.0, 1.0, 0.0],
|
|
||||||
[0.0, 1.0, 0.0],
|
|
||||||
],
|
|
||||||
true,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
println!("line bounds: {:?}", line.bounds()?); // [0,10] in X
|
|
||||||
println!("circle bounds: {:?}", circle.bounds()?); // centre ± radius
|
|
||||||
println!("poly bounds: {:?}", poly.bounds()?); // unit square
|
|
||||||
|
|
||||||
// Bulk: 1000 points in ONE op (all-or-nothing, one undo step — not 1000).
|
|
||||||
let coords: Vec<[f64; 3]> = (0..1000)
|
|
||||||
.map(|i| [i as f64, (i % 10) as f64, 0.0])
|
|
||||||
.collect();
|
|
||||||
let pts = doc.curves().create_points(&coords)?;
|
|
||||||
println!("created {} points in one bulk op", pts.len());
|
|
||||||
assert_eq!(pts.len(), 1000);
|
|
||||||
|
|
||||||
// Generic lookup: any id -> Entity -> kind.
|
|
||||||
let e = doc.entities().get(line.id())?;
|
|
||||||
println!("line entity kind: {}", e.view()?.kind);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
//! Example 5 — Failure semantics: atomicity, OpGroup compensation, revision guard.
|
|
||||||
//!
|
|
||||||
//! Run: `cargo run --example e5_failure_and_guards --features host`
|
|
||||||
//!
|
|
||||||
//! Shows: per-op atomicity (a failed op applies nothing and moves no revision),
|
|
||||||
//! `OpGroup` best-effort cleanup, `ApiError` handling, and the `assert_revision`
|
|
||||||
//! read-guard.
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use ocs_doc_api::{ApiError, DocApi, HasId, ObjectId, OpGroup};
|
|
||||||
|
|
||||||
fn main() -> ocs_doc_api::ApiResult<()> {
|
|
||||||
let api = DocApi::in_process(common::MockBackend::default(), 0);
|
|
||||||
let doc = api.document(api.active_tab());
|
|
||||||
|
|
||||||
// ── Per-op atomicity: a boolean against a deleted id fails cleanly ──
|
|
||||||
let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?;
|
|
||||||
let ghost = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)?;
|
|
||||||
ghost.delete()?; // now a stale handle
|
|
||||||
|
|
||||||
let rev_before = doc.revision()?;
|
|
||||||
match a.intersect(&ghost) {
|
|
||||||
Err(ApiError::UnknownId(id)) => println!("boolean failed as expected: unknown id {id:?}"),
|
|
||||||
Err(e) => println!("boolean failed: {e}"),
|
|
||||||
Ok(_) => panic!("expected the boolean to fail"),
|
|
||||||
}
|
|
||||||
// `a` is still live (failed op mutated nothing) and the revision did not move.
|
|
||||||
assert!(a.bounds().is_ok(), "input solid survives a failed boolean");
|
|
||||||
assert_eq!(
|
|
||||||
doc.revision()?,
|
|
||||||
rev_before,
|
|
||||||
"failed op records no revision bump"
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── OpGroup: clean up a partially-built logical operation on failure ──
|
|
||||||
let mut grp = OpGroup::new();
|
|
||||||
let p1 = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [4.0; 3]))?;
|
|
||||||
let p2 = grp.track(doc.solids().create_sphere([2.0, 2.0, 2.0], 3.0))?;
|
|
||||||
// Simulate a downstream failure: abandon the group and delete what we made.
|
|
||||||
grp.compensate(&doc)?;
|
|
||||||
for h in [p1.id(), p2.id()] {
|
|
||||||
assert!(doc.entities().get(h).is_err(), "compensated entity is gone");
|
|
||||||
}
|
|
||||||
println!("OpGroup compensated: created entities deleted");
|
|
||||||
|
|
||||||
// ── assert_revision: read-modify-write guard ──
|
|
||||||
let rev = doc.revision()?;
|
|
||||||
doc.assert_revision(rev)?; // unchanged -> ok
|
|
||||||
doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3])?; // moves the revision
|
|
||||||
match doc.assert_revision(rev) {
|
|
||||||
Err(ApiError::Validation { reason, .. }) => println!("revision guard caught: {reason}"),
|
|
||||||
other => panic!("expected the revision guard to fail, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// A totally unknown id also surfaces a structured error.
|
|
||||||
assert!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err());
|
|
||||||
println!("failure semantics verified");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
# Curated source of truth for ocs_doc_api codegen (plan §10, decision #6).
|
|
||||||
# Maps entity families + their construction ops, geometric actions, and queries
|
|
||||||
# to kernel fns / acadrust variants. build.rs regenerates src/gen/* from this;
|
|
||||||
# CI fails on drift. New op/query variants append at the END only (bincode
|
|
||||||
# discriminant stability).
|
|
||||||
#
|
|
||||||
# `status` values: "supported" (default), "unsupported" (present but errors),
|
|
||||||
# "planned" (roadmap; no callable surface). Generic handle methods
|
|
||||||
# (bounds/transform/delete) are documented once in build.rs and apply to every
|
|
||||||
# family unless overridden here.
|
|
||||||
|
|
||||||
[meta]
|
|
||||||
envelope_version = 1
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SOLID FAMILY (phase 1) — B-rep solids via cadkernel
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Solid"
|
|
||||||
acadrust_variant = "Solid3D"
|
|
||||||
handle = "Solid"
|
|
||||||
collection = "solids"
|
|
||||||
description = "B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT)."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_cuboid"
|
|
||||||
args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateSolid"
|
|
||||||
fixed = { primitive = "Cuboid" }
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_sphere"
|
|
||||||
args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }]
|
|
||||||
op = "CreateSolid"
|
|
||||||
fixed = { primitive = "Sphere" }
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_cylinder"
|
|
||||||
args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }]
|
|
||||||
op = "CreateSolid"
|
|
||||||
fixed = { primitive = "Cylinder" }
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_cone"
|
|
||||||
args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }]
|
|
||||||
op = "CreateSolid"
|
|
||||||
fixed = { primitive = "Cone" }
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_torus"
|
|
||||||
args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_radius", ty = "f64" }, { name = "minor_radius", ty = "f64" }]
|
|
||||||
op = "CreateSolid"
|
|
||||||
fixed = { primitive = "Torus" }
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_wedge"
|
|
||||||
args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateSolid"
|
|
||||||
fixed = { primitive = "Wedge" }
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "extrude"
|
|
||||||
args = [{ name = "profile", ty = "EntityRef" }, { name = "direction", ty = "[f64; 3]" }]
|
|
||||||
op = "Extrude"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "revolve"
|
|
||||||
args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }]
|
|
||||||
op = "Revolve"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "loft"
|
|
||||||
args = [{ name = "profiles", ty = "&[EntityRef]" }]
|
|
||||||
op = "Loft"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "intersect"
|
|
||||||
kind = "op"
|
|
||||||
op = "SolidBoolean"
|
|
||||||
args = [{ name = "other", ty = "Solid" }]
|
|
||||||
returns = "Solid"
|
|
||||||
fixed = { op = "Intersection", erase_sources = true }
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "union"
|
|
||||||
kind = "op"
|
|
||||||
op = "SolidBoolean"
|
|
||||||
args = [{ name = "other", ty = "Solid" }]
|
|
||||||
returns = "Solid"
|
|
||||||
fixed = { op = "Union", erase_sources = true }
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "subtract"
|
|
||||||
kind = "op"
|
|
||||||
op = "SolidBoolean"
|
|
||||||
args = [{ name = "other", ty = "Solid" }]
|
|
||||||
returns = "Solid"
|
|
||||||
fixed = { op = "Difference", erase_sources = true }
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "volume"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetVolume"
|
|
||||||
returns = "f64"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "centroid"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetCentroid"
|
|
||||||
returns = "[f64; 3]"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "intersects"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetIntersects"
|
|
||||||
args = [{ name = "other", ty = "Solid" }]
|
|
||||||
returns = "bool"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CURVE FAMILY (phase 1, minimal 2D)
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Line"
|
|
||||||
acadrust_variant = "Line"
|
|
||||||
handle = "Line"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_line"
|
|
||||||
args = [{ name = "start", ty = "[f64; 3]" }, { name = "end", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Line" }
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Circle"
|
|
||||||
acadrust_variant = "Circle"
|
|
||||||
handle = "Circle"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_circle"
|
|
||||||
args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Circle" }
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Polyline"
|
|
||||||
acadrust_variant = "LwPolyline"
|
|
||||||
handle = "Polyline"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_polyline"
|
|
||||||
args = [{ name = "points", ty = "&[[f64; 3]]" }, { name = "closed", ty = "bool" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Polyline" }
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "add_vertex"
|
|
||||||
kind = "op"
|
|
||||||
op = "AddVertex"
|
|
||||||
args = [{ name = "at", ty = "usize" }, { name = "point", ty = "[f64; 3]" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Point"
|
|
||||||
acadrust_variant = "Point"
|
|
||||||
handle = "Point"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_point"
|
|
||||||
args = [{ name = "position", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Point" }
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_points"
|
|
||||||
args = [{ name = "positions", ty = "&[[f64; 3]]" }]
|
|
||||||
op = "CreateMany"
|
|
||||||
fixed = { curve = "Point" }
|
|
||||||
bulk = true
|
|
||||||
returns = "Vec<Point>"
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Arc"
|
|
||||||
acadrust_variant = "Arc"
|
|
||||||
handle = "ArcCurve"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_arc"
|
|
||||||
args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "start_angle", ty = "f64" }, { name = "end_angle", ty = "f64" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Arc" }
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Ellipse"
|
|
||||||
acadrust_variant = "Ellipse"
|
|
||||||
handle = "Ellipse"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_ellipse"
|
|
||||||
args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_axis", ty = "[f64; 3]" }, { name = "ratio", ty = "f64" }, { name = "start", ty = "f64" }, { name = "end", ty = "f64" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Ellipse" }
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Spline"
|
|
||||||
acadrust_variant = "Spline"
|
|
||||||
handle = "Spline"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_spline"
|
|
||||||
args = [{ name = "degree", ty = "i32" }, { name = "control_points", ty = "&[[f64; 3]]" }, { name = "knots", ty = "&[f64]" }, { name = "weights", ty = "&[f64]" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Spline" }
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Ray"
|
|
||||||
acadrust_variant = "Ray"
|
|
||||||
handle = "Ray"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_ray"
|
|
||||||
args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "Ray" }
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "XLine"
|
|
||||||
acadrust_variant = "XLine"
|
|
||||||
handle = "XLine"
|
|
||||||
collection = "curves"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_xline"
|
|
||||||
args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateCurve"
|
|
||||||
fixed = { curve = "XLine" }
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# ANNOTATION FAMILY (phase 2b) — text annotations
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Text"
|
|
||||||
acadrust_variant = "Text"
|
|
||||||
handle = "Text"
|
|
||||||
collection = "curves"
|
|
||||||
description = "Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_text"
|
|
||||||
args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }]
|
|
||||||
op = "CreateText"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "content"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetTextContent"
|
|
||||||
returns = "String"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "set_content"
|
|
||||||
kind = "op"
|
|
||||||
op = "SetTextContent"
|
|
||||||
args = [{ name = "value", ty = "&str" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "MText"
|
|
||||||
acadrust_variant = "MText"
|
|
||||||
handle = "MText"
|
|
||||||
collection = "curves"
|
|
||||||
description = "Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_mtext"
|
|
||||||
args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }]
|
|
||||||
op = "CreateMText"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "content"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetTextContent"
|
|
||||||
returns = "String"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "set_content"
|
|
||||||
kind = "op"
|
|
||||||
op = "SetTextContent"
|
|
||||||
args = [{ name = "value", ty = "&str" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HATCH FAMILY (phase 2b) — filled regions
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Hatch"
|
|
||||||
acadrust_variant = "Hatch"
|
|
||||||
handle = "Entity"
|
|
||||||
collection = "curves"
|
|
||||||
description = "Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_hatch"
|
|
||||||
args = [{ name = "boundary", ty = "&[[f64; 2]]" }, { name = "solid", ty = "bool" }]
|
|
||||||
op = "CreateHatch"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "boundary"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetHatchBoundary"
|
|
||||||
returns = "Vec<Vec<[f64; 2]>>"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# DIMENSION FAMILY (phase 2c) — linear dimensions
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Dimension"
|
|
||||||
acadrust_variant = "Dimension"
|
|
||||||
handle = "Dimension"
|
|
||||||
collection = "curves"
|
|
||||||
description = "Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_dimension_linear"
|
|
||||||
args = [{ name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "definition_point", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateDimensionLinear"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_dimension_radius"
|
|
||||||
args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateDimensionRadius"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_dimension_diameter"
|
|
||||||
args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateDimensionDiameter"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_dimension_angular"
|
|
||||||
args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateDimensionAngular"
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_dimension_angular2ln"
|
|
||||||
args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }]
|
|
||||||
op = "CreateDimensionAngular2Ln"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "measurement"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetDimensionMeasurement"
|
|
||||||
returns = "f64"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CONTAINER FAMILY (phase 3) — block references
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Insert"
|
|
||||||
acadrust_variant = "Insert"
|
|
||||||
handle = "Entity"
|
|
||||||
collection = "entities"
|
|
||||||
description = "Block reference. Place an existing BlockRecord by name; transform moves its insert point."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_insert"
|
|
||||||
args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }]
|
|
||||||
op = "CreateInsert"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "attributes"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetAttributes"
|
|
||||||
returns = "Vec<(String, String)>"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "set_attribute"
|
|
||||||
kind = "op"
|
|
||||||
op = "SetAttribute"
|
|
||||||
args = [{ name = "tag", ty = "&str" }, { name = "value", ty = "&str" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# ATTRIBUTE-DEFINITION FAMILY (phase 3-ii) — in-block attribute definitions
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "AttributeDefinition"
|
|
||||||
acadrust_variant = "AttributeDefinition"
|
|
||||||
handle = "Entity"
|
|
||||||
collection = "entities"
|
|
||||||
description = "In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_attribute_definition"
|
|
||||||
args = [{ name = "tag", ty = "&str" }, { name = "prompt", ty = "&str" }, { name = "default_value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }]
|
|
||||||
op = "CreateAttributeDefinition"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# PAPER-SPACE FAMILY (phase 4) — viewports
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Viewport"
|
|
||||||
acadrust_variant = "Viewport"
|
|
||||||
handle = "Entity"
|
|
||||||
collection = "entities"
|
|
||||||
description = "Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_viewport"
|
|
||||||
args = [{ name = "center", ty = "[f64; 3]" }, { name = "width", ty = "f64" }, { name = "height", ty = "f64" }, { name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }]
|
|
||||||
op = "CreateViewport"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "viewport_view"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetViewportView"
|
|
||||||
returns = "([f64; 3], f64)"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "set_view"
|
|
||||||
kind = "op"
|
|
||||||
op = "SetViewportView"
|
|
||||||
args = [{ name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# MEDIA & MISC (phase 5, read-mostly) — read-only EntityView, no typed create.
|
|
||||||
# These families report a real `kind` via GetEntity and coarse bounds where
|
|
||||||
# computable; they have no typed construction/geometric ops in v1.
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "RasterImage"
|
|
||||||
acadrust_variant = "RasterImage"
|
|
||||||
handle = "Entity"
|
|
||||||
collection = "entities"
|
|
||||||
description = "Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_raster_image"
|
|
||||||
args = [{ name = "file_path", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "u_vector", ty = "[f64; 3]" }, { name = "v_vector", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 2]" }]
|
|
||||||
op = "CreateRasterImage"
|
|
||||||
|
|
||||||
[[family]]
|
|
||||||
name = "Table"
|
|
||||||
acadrust_variant = "Table"
|
|
||||||
handle = "Entity"
|
|
||||||
collection = "entities"
|
|
||||||
description = "Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_table"
|
|
||||||
args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec<String>]" }]
|
|
||||||
op = "CreateTable"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# XDATA / XRECORD FAMILY — application-specific extended data roundtrip
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "XRecord"
|
|
||||||
acadrust_variant = "XRecord"
|
|
||||||
handle = "XRecord"
|
|
||||||
collection = "entities"
|
|
||||||
description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload."
|
|
||||||
|
|
||||||
[[family.constructor]]
|
|
||||||
name = "create_xrecord"
|
|
||||||
args = [{ name = "spec", ty = "XRecordSpec" }]
|
|
||||||
op = "CreateXRecord"
|
|
||||||
returns = "XRecord"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "payload"
|
|
||||||
kind = "query"
|
|
||||||
query = "GetXRecord"
|
|
||||||
returns = "XRecordSpec"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "set_payload"
|
|
||||||
kind = "op"
|
|
||||||
op = "SetXRecord"
|
|
||||||
args = [{ name = "spec", ty = "XRecordSpec" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Cross-cutting ops (generic handles + bulk) — documented in build.rs
|
|
||||||
# ============================================================================
|
|
||||||
# - Generic handle methods on EVERY handle: bounds() -> Aabb, transform(placement),
|
|
||||||
# delete(), id() -> ObjectId. (transform is solids-only in v1.)
|
|
||||||
# - EntityCollection: get(id) -> Entity, delete(id), transform_many(&ids, pl),
|
|
||||||
# delete_many(&ids).
|
|
||||||
# - Bulk ops (CreateMany / TransformMany / DeleteMany): single op, all-or-nothing
|
|
||||||
# via upfront validation, item-capped (BULK_ITEM_CAP).
|
|
||||||
# - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump.
|
|
||||||
# - OpGroup: best-effort client-side failure cleanup (NOT a transaction).
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# LAYER family (named drawing layers)
|
|
||||||
# ============================================================================
|
|
||||||
[[family]]
|
|
||||||
name = "Layer"
|
|
||||||
handle = "Layer"
|
|
||||||
collection = "layers"
|
|
||||||
description = "Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers()."
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "list"
|
|
||||||
kind = "query"
|
|
||||||
query = "ListLayers"
|
|
||||||
returns = "Vec<LayerInfo>"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "create"
|
|
||||||
kind = "op"
|
|
||||||
op = "CreateLayer"
|
|
||||||
args = [{ name = "info", ty = "LayerInfo" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "update"
|
|
||||||
kind = "op"
|
|
||||||
op = "UpdateLayer"
|
|
||||||
args = [{ name = "name", ty = "String" }, { name = "info", ty = "LayerInfo" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
[[family.method]]
|
|
||||||
name = "delete"
|
|
||||||
kind = "op"
|
|
||||||
op = "DeleteLayer"
|
|
||||||
args = [{ name = "name", ty = "String" }]
|
|
||||||
returns = "()"
|
|
||||||
|
|
||||||
# ---- Kernel/op mapping (reference for the executor + docs) ----
|
|
||||||
[[kernel_map]]
|
|
||||||
op = "CreateSolid.Cuboid"
|
|
||||||
kernel = "cadkernel::brep::make::cuboid"
|
|
||||||
returns = "Option<Body>"
|
|
||||||
|
|
||||||
[[kernel_map]]
|
|
||||||
op = "CreateSolid.Sphere"
|
|
||||||
kernel = "cadkernel::brep::make::sphere"
|
|
||||||
returns = "Option<Body>"
|
|
||||||
|
|
||||||
[[kernel_map]]
|
|
||||||
op = "SolidBoolean"
|
|
||||||
kernel = "cadkernel::brep::combine"
|
|
||||||
returns = "Result<Body, Snag>"
|
|
||||||
|
|
||||||
[[kernel_map]]
|
|
||||||
op = "Transform"
|
|
||||||
kernel = "cadkernel::brep::transform"
|
|
||||||
returns = "Option<Body>"
|
|
||||||
|
|
||||||
[[kernel_map]]
|
|
||||||
op = "GetBounds"
|
|
||||||
kernel = "cadkernel::brep::body_bounds"
|
|
||||||
returns = "Option<Aabb>"
|
|
||||||
|
|
||||||
[[kernel_map]]
|
|
||||||
op = "GetVolume"
|
|
||||||
kernel = "cadkernel::brep::mesh_body + divergence"
|
|
||||||
returns = "computed"
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
//! The thin host hook. The host implements this trait;
|
|
||||||
//! the crate's [`crate::executor`] drives it. Both the in-process transport and
|
|
||||||
//! the host's IPC executor call into it. Logic lives in the versioned crate; the
|
|
||||||
//! host only provides scene primitives.
|
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
|
||||||
use crate::id::ObjectId;
|
|
||||||
use crate::ops::{Curve2Spec, LayerInfo};
|
|
||||||
use crate::query::{Aabb, EntityView};
|
|
||||||
use crate::revision::GeometryRevision;
|
|
||||||
|
|
||||||
/// A kernel B-rep solid body, opaque to the wire. On `host` this is
|
|
||||||
/// `cadkernel::brep::Body`; kernel keys never cross the API (decision #7).
|
|
||||||
pub type KernelBody = cadkernel::brep::Body;
|
|
||||||
|
|
||||||
/// The per-op host hook. Every method maps to an already-present scene primitive.
|
|
||||||
/// The executor calls `push_undo` BEFORE applying a write op and `finalize_op`
|
|
||||||
/// AFTER — recording exactly one undo step + one geometry bump per applied op.
|
|
||||||
pub trait DocApiBackend {
|
|
||||||
// ── identity / solid lookup ────────────────────────────────────────────
|
|
||||||
/// Resolve a live B-rep body for `id` (solid cache; re-lift from AcisData on
|
|
||||||
/// miss). `UnknownId` if `id` is not a live solid. NOTE: returns an OWNED body
|
|
||||||
/// (a clone of the cache entry); use `with_body` for read-only queries.
|
|
||||||
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody>;
|
|
||||||
|
|
||||||
/// Borrow the cached body read-only for a query, avoiding the `resolve_body`
|
|
||||||
/// deep clone. Default: falls back to `resolve_body` (clone). The host overrides
|
|
||||||
/// this to borrow the `solid_models` cache in place (O(1), no copy).
|
|
||||||
fn with_body<R>(
|
|
||||||
&mut self,
|
|
||||||
id: ObjectId,
|
|
||||||
f: &mut dyn FnMut(&KernelBody) -> ApiResult<R>,
|
|
||||||
) -> ApiResult<R> {
|
|
||||||
let body = self.resolve_body(id)?;
|
|
||||||
f(&body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a new solid body as a `Solid3D` entity; returns the fresh `ObjectId`.
|
|
||||||
/// Mirrors host `add_solid_model` (edge wires + `solid_to_sat` +
|
|
||||||
/// `set_sat_document` + `register_solid_model`).
|
|
||||||
fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Replace the body of an existing solid in place (same `ObjectId`).
|
|
||||||
/// Re-embeds SAT and refreshes the solid cache.
|
|
||||||
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()>;
|
|
||||||
|
|
||||||
// ── plain (2D) entities ────────────────────────────────────────────────
|
|
||||||
/// Add a 2D curve entity; returns the fresh `ObjectId`.
|
|
||||||
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Prepare every entity before recording one undo step and committing the batch.
|
|
||||||
fn create_many(&mut self, specs: &[crate::ops::EntitySpec]) -> ApiResult<Vec<ObjectId>>;
|
|
||||||
|
|
||||||
/// Prepare all transformed geometry before committing any entity.
|
|
||||||
fn transform_many(
|
|
||||||
&mut self,
|
|
||||||
ids: &[ObjectId],
|
|
||||||
placement: &crate::PlacementSpec,
|
|
||||||
) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// Add a block reference (`INSERT`). Validates `block_name` exists; returns
|
|
||||||
/// the fresh `ObjectId`. `Validation` if the block is unknown.
|
|
||||||
fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`.
|
|
||||||
fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a single-line `TEXT` annotation; returns the fresh `ObjectId`.
|
|
||||||
fn add_text(&mut self, spec: &crate::ops::TextSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a multi-line `MTEXT` annotation; returns the fresh `ObjectId`.
|
|
||||||
fn add_mtext(&mut self, spec: &crate::ops::MTextSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Set the text content of a Text/MText annotation in place.
|
|
||||||
fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// The text content of a Text/MText annotation. `Unsupported` for other kinds.
|
|
||||||
fn text_content(&self, id: ObjectId) -> ApiResult<String>;
|
|
||||||
|
|
||||||
/// Add a `HATCH` (single closed polyline boundary); returns the fresh `ObjectId`.
|
|
||||||
fn add_hatch(&mut self, spec: &crate::ops::HatchSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// A hatch's boundary loops (outer + islands) as closed polylines.
|
|
||||||
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>>;
|
|
||||||
|
|
||||||
/// Add a linear `DIMENSION`; returns the fresh `ObjectId`.
|
|
||||||
fn add_dimension_linear(&mut self, spec: &crate::ops::DimensionSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a radial `DIMENSION`; returns the fresh `ObjectId`.
|
|
||||||
fn add_dimension_radius(
|
|
||||||
&mut self,
|
|
||||||
spec: &crate::ops::DimensionRadialSpec,
|
|
||||||
) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a diameter `DIMENSION`; returns the fresh `ObjectId`.
|
|
||||||
fn add_dimension_diameter(
|
|
||||||
&mut self,
|
|
||||||
spec: &crate::ops::DimensionRadialSpec,
|
|
||||||
) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a 3-point angular `DIMENSION`; returns the fresh `ObjectId`.
|
|
||||||
fn add_dimension_angular(
|
|
||||||
&mut self,
|
|
||||||
spec: &crate::ops::DimensionAngularSpec,
|
|
||||||
) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`.
|
|
||||||
fn add_attribute_definition(
|
|
||||||
&mut self,
|
|
||||||
spec: &crate::ops::AttributeDefinitionSpec,
|
|
||||||
) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`.
|
|
||||||
fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Add a 2-line angular `DIMENSION`; returns the fresh `ObjectId`.
|
|
||||||
fn add_dimension_angular2ln(
|
|
||||||
&mut self,
|
|
||||||
spec: &crate::ops::DimensionAngularSpec,
|
|
||||||
) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// A dimension's measured value (distance for linear/radius, degrees for angular).
|
|
||||||
fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64>;
|
|
||||||
|
|
||||||
/// Set an attribute `value` for `tag` on an insert (adds if absent). Insert-only.
|
|
||||||
fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// An insert's attributes as (tag, value) pairs. Insert-only.
|
|
||||||
fn attributes(&self, id: ObjectId) -> ApiResult<Vec<(String, String)>>;
|
|
||||||
|
|
||||||
/// The entities inside a block definition (read-only traversal), as views.
|
|
||||||
fn block_entities(&self, block_name: &str) -> ApiResult<Vec<EntityView>>;
|
|
||||||
|
|
||||||
/// Set a viewport's view target + zoom height in place. Viewport-only.
|
|
||||||
fn set_viewport_view(
|
|
||||||
&mut self,
|
|
||||||
id: ObjectId,
|
|
||||||
view_target: [f64; 3],
|
|
||||||
view_height: f64,
|
|
||||||
) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// A viewport's view (target WCS + zoom height). Viewport-only.
|
|
||||||
fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>;
|
|
||||||
|
|
||||||
/// Add a `RASTER_IMAGE` (host auto-registers the ImageDefinition).
|
|
||||||
fn add_raster_image(&mut self, spec: &crate::ops::RasterImageSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Loft a solid through >= 2 profile curve sets (each already resolved to
|
|
||||||
/// `geom2d::Curve`s on the XY plane). Returns the fresh `ObjectId`.
|
|
||||||
fn loft(
|
|
||||||
&mut self,
|
|
||||||
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
|
|
||||||
) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Attach or replace an XDATA record for `application_name` on `id`.
|
|
||||||
/// `None` removes any existing record for that application.
|
|
||||||
fn set_xdata(
|
|
||||||
&mut self,
|
|
||||||
id: ObjectId,
|
|
||||||
application_name: &str,
|
|
||||||
record: Option<&crate::ops::XDataRecord>,
|
|
||||||
) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// Read the XDATA record for `application_name` on `id` (`None` if absent).
|
|
||||||
fn xdata(
|
|
||||||
&self,
|
|
||||||
id: ObjectId,
|
|
||||||
application_name: &str,
|
|
||||||
) -> ApiResult<Option<crate::ops::XDataRecord>>;
|
|
||||||
|
|
||||||
/// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`.
|
|
||||||
fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult<ObjectId>;
|
|
||||||
|
|
||||||
/// Replace an existing `XRECORD` object's payload in place.
|
|
||||||
fn set_xrecord(&mut self, id: ObjectId, spec: &crate::ops::XRecordSpec) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// Read an existing `XRECORD` object's payload (`None` if `id` is not an XRecord).
|
|
||||||
fn xrecord(&self, id: ObjectId) -> ApiResult<Option<crate::ops::XRecordSpec>>;
|
|
||||||
|
|
||||||
// ── layer CRUD ─────────────────────────────────────────────────────────
|
|
||||||
/// Create a new layer. Fails with `Validation` if the name already exists or is reserved.
|
|
||||||
fn create_layer(&mut self, info: &crate::ops::LayerInfo) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// Update an existing layer's properties by name. Fails with `Validation` if absent.
|
|
||||||
fn update_layer(&mut self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// Delete a layer by name. Fails with `Validation` if the layer is "0",
|
|
||||||
/// the current layer, does not exist, or has entities.
|
|
||||||
fn delete_layer(&mut self, name: &str) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// Look up a layer by raw name, normalizing it. Default implementation scans
|
|
||||||
/// `layers()`; backends with O(1) lookup should override.
|
|
||||||
fn lookup_layer(&self, name: &str) -> ApiResult<LayerInfo> {
|
|
||||||
let norm = crate::layer::normalize_layer_name(name);
|
|
||||||
if norm.is_empty() {
|
|
||||||
return Err(ApiError::validation("LookupLayer", "empty layer name"));
|
|
||||||
}
|
|
||||||
self.layers()?
|
|
||||||
.into_iter()
|
|
||||||
.find(|l| l.normalized_name() == norm)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::validation(
|
|
||||||
"LookupLayer",
|
|
||||||
format!("layer '{name}' does not exist"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move an entity to a different layer. The target layer must exist.
|
|
||||||
fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// List all layers in the document.
|
|
||||||
fn layers(&self) -> ApiResult<Vec<crate::ops::LayerInfo>>;
|
|
||||||
|
|
||||||
/// The layer name of an entity.
|
|
||||||
fn entity_layer(&self, id: ObjectId) -> ApiResult<String>;
|
|
||||||
|
|
||||||
/// Enumerate first-class entities, optionally filtered by kind and/or layer.
|
|
||||||
/// `include_bounds` controls whether `EntityView::bounds` is populated; skip it
|
|
||||||
/// for lightweight list views.
|
|
||||||
fn enumerate_entities(
|
|
||||||
&self,
|
|
||||||
kind: Option<&str>,
|
|
||||||
layer: Option<&str>,
|
|
||||||
include_bounds: bool,
|
|
||||||
) -> ApiResult<Vec<EntityView>>;
|
|
||||||
|
|
||||||
/// The WCS location of a Point entity.
|
|
||||||
fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>;
|
|
||||||
|
|
||||||
/// The start and end WCS points of a Line entity.
|
|
||||||
fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>;
|
|
||||||
|
|
||||||
/// Can `id` be modified in place right now (exists, is the expected family,
|
|
||||||
/// not on a locked layer)? Read-only pre-check used before mutations.
|
|
||||||
/// Default: existence + not-locked (backends narrow the family check).
|
|
||||||
fn can_modify(&self, id: ObjectId) -> ApiResult<()> {
|
|
||||||
if self.entity_exists(id) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove any first-level entity by id. Returns `false` if absent; returns
|
|
||||||
/// `Err` if the entity exists but cannot be removed (e.g. locked layer).
|
|
||||||
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool>;
|
|
||||||
|
|
||||||
/// Can `id` be removed right now (exists and is not blocked, e.g. by a locked
|
|
||||||
/// layer)? Read-only; used by the boolean-erase path to validate BEFORE any
|
|
||||||
/// mutation so a mid-op failure cannot leave partial state. Default: existence-only.
|
|
||||||
fn can_remove(&self, id: ObjectId) -> ApiResult<()> {
|
|
||||||
if self.entity_exists(id) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an object (named-object / XRecord / dictionary) exists. Distinct
|
|
||||||
/// from `entity_exists`, which is for first-class entities only.
|
|
||||||
fn object_exists(&self, id: ObjectId) -> bool;
|
|
||||||
|
|
||||||
/// A generic, untyped view of any entity (id + kind + coarse bounds).
|
|
||||||
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView>;
|
|
||||||
|
|
||||||
/// The 2D curve geometry of a profile entity (polyline/closed curve) for
|
|
||||||
/// sweep ops (`Extrude`/`Revolve`). `UnknownId`/`Unsupported` if not a profile.
|
|
||||||
fn profile_curves(&self, id: ObjectId) -> ApiResult<Vec<cadkernel::geom2d::Curve>>;
|
|
||||||
|
|
||||||
fn profile_plane(&self, _id: ObjectId) -> ApiResult<cadkernel::space::Plane> {
|
|
||||||
Ok(cadkernel::space::Plane::XY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a rigid similarity to any entity in place (same `ObjectId`).
|
|
||||||
fn transform_entity(
|
|
||||||
&mut self,
|
|
||||||
id: ObjectId,
|
|
||||||
placement: &crate::ops::PlacementSpec,
|
|
||||||
) -> ApiResult<()>;
|
|
||||||
|
|
||||||
/// Validate that `id` exists and can be transformed, WITHOUT mutating.
|
|
||||||
/// Used by `TransformMany` to pre-validate all ids before `push_undo` so the
|
|
||||||
/// apply loop cannot fail part-way (all-or-nothing, the bulk contract). Default:
|
|
||||||
/// existence-only (backends that restrict transform to a family override).
|
|
||||||
fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> {
|
|
||||||
if self.entity_exists(id) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::UnknownId(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a vertex into a polyline at index `at` (polyline family only).
|
|
||||||
fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()>;
|
|
||||||
|
|
||||||
// ── queries (no undo, no bump; `&mut` because solid resolution may
|
|
||||||
// populate the kernel body cache on miss) ─────────────────────────────
|
|
||||||
fn bounds(&mut self, id: ObjectId) -> ApiResult<Aabb>;
|
|
||||||
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]>;
|
|
||||||
fn volume(&mut self, id: ObjectId) -> ApiResult<f64>;
|
|
||||||
fn entity_exists(&self, id: ObjectId) -> bool;
|
|
||||||
fn revision(&self) -> GeometryRevision;
|
|
||||||
|
|
||||||
// ── per-op boundary ────────────────────────────────────────────────────
|
|
||||||
/// Called BEFORE applying a write op (records the before-state undo snapshot).
|
|
||||||
fn push_undo(&mut self, label: &str);
|
|
||||||
/// Called AFTER a successful write op (one DeltaSnapshot + one geometry bump
|
|
||||||
/// + one publish). NOT called on failure (a failed op records nothing).
|
|
||||||
fn finalize_op(&mut self);
|
|
||||||
/// Discard an undo capture after preparation or validation failed.
|
|
||||||
fn cancel_op(&mut self) {}
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
//! Entity conversions: map the crate's plain-data DTOs to acadrust
|
|
||||||
//! `EntityType` and back. Host feature only (uses the real acadrust types). This
|
|
||||||
//! module is host-side-capable but host-independent — it operates purely on
|
|
||||||
//! acadrust entities and crate DTOs, so any host can reuse it.
|
|
||||||
|
|
||||||
use acadrust::entities::{
|
|
||||||
Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine,
|
|
||||||
};
|
|
||||||
use acadrust::types::{Vector2, Vector3};
|
|
||||||
use acadrust::EntityType;
|
|
||||||
|
|
||||||
use crate::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, ObjectId};
|
|
||||||
|
|
||||||
/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for
|
|
||||||
/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane).
|
|
||||||
pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
|
|
||||||
use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine};
|
|
||||||
let curves = match entity {
|
|
||||||
EntityType::Line(l) => vec![KCurve::Line(KLine {
|
|
||||||
start: [l.start.x, l.start.y],
|
|
||||||
end: [l.end.x, l.end.y],
|
|
||||||
})],
|
|
||||||
EntityType::Circle(c) => vec![KCurve::Circle(KCircle {
|
|
||||||
centre: [c.center.x, c.center.y],
|
|
||||||
radius: c.radius,
|
|
||||||
})],
|
|
||||||
EntityType::Arc(a) => vec![KCurve::Arc(KArc {
|
|
||||||
centre: [a.center.x, a.center.y],
|
|
||||||
radius: a.radius,
|
|
||||||
start_angle: a.start_angle,
|
|
||||||
end_angle: a.end_angle,
|
|
||||||
})],
|
|
||||||
EntityType::LwPolyline(pl) => {
|
|
||||||
if pl.vertices.len() < 2 {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"profile",
|
|
||||||
"polyline profile needs at least two vertices",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
KCurve::Polyline(cadkernel::geom2d::Polyline {
|
|
||||||
vertices: pl
|
|
||||||
.vertices
|
|
||||||
.iter()
|
|
||||||
.map(|v| cadkernel::geom2d::PolylineVertex {
|
|
||||||
position: [v.location.x, v.location.y],
|
|
||||||
bulge: v.bulge,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
closed: pl.is_closed,
|
|
||||||
})
|
|
||||||
.segments()
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(ApiError::Unsupported(
|
|
||||||
"profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(curves)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid
|
|
||||||
/// entity in place. Supports the common 2D families; `Solid3D` goes through the
|
|
||||||
/// kernel path instead. Returns the transformed entity (same handle) for the
|
|
||||||
/// caller to `update_entity`.
|
|
||||||
pub fn transform_entity_geometry(
|
|
||||||
entity: &EntityType,
|
|
||||||
p: &crate::PlacementSpec,
|
|
||||||
) -> ApiResult<EntityType> {
|
|
||||||
crate::geom::validate_placement(p)?;
|
|
||||||
let matrix = acadrust::types::Matrix4 {
|
|
||||||
m: [
|
|
||||||
[p.x_axis[0], p.y_axis[0], p.z_axis[0], p.origin[0]],
|
|
||||||
[p.x_axis[1], p.y_axis[1], p.z_axis[1], p.origin[1]],
|
|
||||||
[p.x_axis[2], p.y_axis[2], p.z_axis[2], p.origin[2]],
|
|
||||||
[0.0, 0.0, 0.0, 1.0],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let mut moved = entity.clone();
|
|
||||||
moved.apply_transform(&acadrust::types::Transform::from_matrix(matrix));
|
|
||||||
Ok(moved)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn v3(p: [f64; 3]) -> Vector3 {
|
|
||||||
Vector3::new(p[0], p[1], p[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a
|
|
||||||
/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise.
|
|
||||||
pub fn bulge_arc_segment(
|
|
||||||
start: [f64; 2],
|
|
||||||
end: [f64; 2],
|
|
||||||
bulge: f64,
|
|
||||||
) -> ApiResult<cadkernel::geom2d::Curve> {
|
|
||||||
use cadkernel::geom2d::{Arc as KArc, Curve as KCurve};
|
|
||||||
let arc = cadkernel::geom2d::BulgeArc::from_bulge(start, end, bulge)
|
|
||||||
.ok_or_else(|| ApiError::validation("profile", "invalid bulge segment"))?;
|
|
||||||
// The kernel's Arc is counter-clockwise; reverse clockwise endpoints.
|
|
||||||
let (start_angle, end_angle) = if arc.sweep < 0.0 {
|
|
||||||
(arc.end_angle, arc.start_angle)
|
|
||||||
} else {
|
|
||||||
(arc.start_angle, arc.end_angle)
|
|
||||||
};
|
|
||||||
Ok(KCurve::Arc(KArc {
|
|
||||||
centre: arc.center,
|
|
||||||
radius: arc.radius,
|
|
||||||
start_angle,
|
|
||||||
end_angle,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an acadrust entity for a 2D curve construction spec.
|
|
||||||
pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
|
|
||||||
crate::validation::curve(spec)?;
|
|
||||||
let entity = match spec {
|
|
||||||
Curve2Spec::Line { start, end, .. } => {
|
|
||||||
let mut e = Line::new();
|
|
||||||
e.start = v3(*start);
|
|
||||||
e.end = v3(*end);
|
|
||||||
EntityType::Line(e)
|
|
||||||
}
|
|
||||||
Curve2Spec::Circle { centre, radius, .. } => {
|
|
||||||
let mut e = Circle::new();
|
|
||||||
e.center = v3(*centre);
|
|
||||||
e.radius = *radius;
|
|
||||||
EntityType::Circle(e)
|
|
||||||
}
|
|
||||||
Curve2Spec::Polyline { points, closed, .. } => {
|
|
||||||
let mut e = LwPolyline::new();
|
|
||||||
e.vertices = points
|
|
||||||
.iter()
|
|
||||||
.map(|p| LwVertex {
|
|
||||||
location: Vector2::new(p[0], p[1]),
|
|
||||||
bulge: 0.0,
|
|
||||||
start_width: 0.0,
|
|
||||||
end_width: 0.0,
|
|
||||||
vertex_id: 0,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
e.elevation = points.first().map_or(0.0, |p| p[2]);
|
|
||||||
if points.iter().any(|p| p[2] != e.elevation) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"CreateCurve",
|
|
||||||
"polyline points must share an elevation",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
e.is_closed = *closed;
|
|
||||||
EntityType::LwPolyline(e)
|
|
||||||
}
|
|
||||||
Curve2Spec::Point { position, .. } => {
|
|
||||||
let mut e = Point::new();
|
|
||||||
e.location = v3(*position);
|
|
||||||
EntityType::Point(e)
|
|
||||||
}
|
|
||||||
Curve2Spec::Arc {
|
|
||||||
centre,
|
|
||||||
radius,
|
|
||||||
start_angle,
|
|
||||||
end_angle,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mut e = Arc::new();
|
|
||||||
e.center = v3(*centre);
|
|
||||||
e.radius = *radius;
|
|
||||||
e.start_angle = *start_angle;
|
|
||||||
e.end_angle = *end_angle;
|
|
||||||
EntityType::Arc(e)
|
|
||||||
}
|
|
||||||
Curve2Spec::Ellipse {
|
|
||||||
centre,
|
|
||||||
major_axis,
|
|
||||||
ratio,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
// Validate the minor/major ratio so the (major-axis) bounds never
|
|
||||||
// under-bound: ratio must be in (0, 1].
|
|
||||||
if !(*ratio > 0.0 && *ratio <= 1.0) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"CreateCurve",
|
|
||||||
format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut e = Ellipse::new();
|
|
||||||
e.center = v3(*centre);
|
|
||||||
e.major_axis = v3(*major_axis);
|
|
||||||
e.minor_axis_ratio = *ratio;
|
|
||||||
e.start_parameter = *start;
|
|
||||||
e.end_parameter = *end;
|
|
||||||
EntityType::Ellipse(e)
|
|
||||||
}
|
|
||||||
Curve2Spec::Spline {
|
|
||||||
degree,
|
|
||||||
control_points,
|
|
||||||
knots,
|
|
||||||
weights,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mut e = Spline::new();
|
|
||||||
e.degree = *degree;
|
|
||||||
e.control_points = control_points.iter().map(|p| v3(*p)).collect();
|
|
||||||
e.knots = knots.clone();
|
|
||||||
e.weights = weights.clone();
|
|
||||||
EntityType::Spline(e)
|
|
||||||
}
|
|
||||||
Curve2Spec::Ray { origin, direction, .. } => {
|
|
||||||
EntityType::Ray(Ray::new(v3(*origin), v3(*direction)))
|
|
||||||
}
|
|
||||||
Curve2Spec::XLine { origin, direction, .. } => {
|
|
||||||
EntityType::XLine(XLine::new(v3(*origin), v3(*direction)))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(entity)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts).
|
|
||||||
pub fn entity_kind_name(entity: &EntityType) -> &'static str {
|
|
||||||
match entity {
|
|
||||||
EntityType::Solid3D(_) => "Solid3D",
|
|
||||||
EntityType::Line(_) => "Line",
|
|
||||||
EntityType::Circle(_) => "Circle",
|
|
||||||
EntityType::LwPolyline(_) => "LwPolyline",
|
|
||||||
EntityType::Polyline(_) => "Polyline",
|
|
||||||
EntityType::Point(_) => "Point",
|
|
||||||
EntityType::Arc(_) => "Arc",
|
|
||||||
EntityType::Ellipse(_) => "Ellipse",
|
|
||||||
EntityType::Spline(_) => "Spline",
|
|
||||||
EntityType::Ray(_) => "Ray",
|
|
||||||
EntityType::XLine(_) => "XLine",
|
|
||||||
EntityType::Insert(_) => "Insert",
|
|
||||||
EntityType::Viewport(_) => "Viewport",
|
|
||||||
EntityType::Text(_) => "Text",
|
|
||||||
EntityType::MText(_) => "MText",
|
|
||||||
EntityType::AttributeDefinition(_) => "AttributeDefinition",
|
|
||||||
EntityType::AttributeEntity(_) => "AttributeEntity",
|
|
||||||
// Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind.
|
|
||||||
EntityType::RasterImage(_) => "RasterImage",
|
|
||||||
EntityType::Table(_) => "Table",
|
|
||||||
EntityType::Leader(_) => "Leader",
|
|
||||||
EntityType::MultiLeader(_) => "MultiLeader",
|
|
||||||
EntityType::MLine(_) => "MLine",
|
|
||||||
EntityType::Mesh(_) => "Mesh",
|
|
||||||
EntityType::Helix(_) => "Helix",
|
|
||||||
EntityType::Region(_) => "Region",
|
|
||||||
EntityType::Body(_) => "Body",
|
|
||||||
EntityType::Surface(_) => "Surface",
|
|
||||||
EntityType::Face3D(_) => "Face3D",
|
|
||||||
EntityType::Dimension(_) => "Dimension",
|
|
||||||
EntityType::Hatch(_) => "Hatch",
|
|
||||||
_ => "Other",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Existing entity bounds; unbounded or empty geometry returns an error.
|
|
||||||
pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult<Aabb> {
|
|
||||||
let entity = entity.ok_or(ApiError::UnknownId(id))?;
|
|
||||||
if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) {
|
|
||||||
return Err(ApiError::Unsupported("unbounded entity".into()));
|
|
||||||
}
|
|
||||||
let bounds = entity.as_entity().bounding_box();
|
|
||||||
let min = [bounds.min.x, bounds.min.y, bounds.min.z];
|
|
||||||
let max = [bounds.max.x, bounds.max.y, bounds.max.z];
|
|
||||||
if min.iter().chain(&max).any(|v| !v.is_finite()) {
|
|
||||||
return Err(ApiError::geometry(
|
|
||||||
GeometryErrorKind::Empty,
|
|
||||||
"entity has no finite bounds",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Aabb { min, max })
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
//! The wire envelope. One request = ONE write op OR a query batch
|
|
||||||
//! (read-only batching is safe: no mutation, no undo, no rollback).
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::gen::{Operation, Query};
|
|
||||||
use crate::id::ObjectId;
|
|
||||||
use crate::query::QueryResult;
|
|
||||||
use crate::revision::GeometryRevision;
|
|
||||||
use crate::ENVELOPE_VERSION;
|
|
||||||
|
|
||||||
/// The single request unit crossing the transport.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct DocApiEnvelope {
|
|
||||||
/// Crate protocol version (`ENVELOPE_VERSION`). Bridges must check this.
|
|
||||||
pub version: u16,
|
|
||||||
pub body: EnvelopeBody,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocApiEnvelope {
|
|
||||||
pub fn validate_version(&self) -> crate::ApiResult<()> {
|
|
||||||
if self.version != ENVELOPE_VERSION {
|
|
||||||
return Err(crate::ApiError::Unsupported(format!(
|
|
||||||
"envelope version {}",
|
|
||||||
self.version
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn op(operation: Operation) -> Self {
|
|
||||||
Self {
|
|
||||||
version: ENVELOPE_VERSION,
|
|
||||||
body: EnvelopeBody::Op(operation),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn queries(queries: Vec<Query>) -> Self {
|
|
||||||
Self {
|
|
||||||
version: ENVELOPE_VERSION,
|
|
||||||
body: EnvelopeBody::Queries(queries),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum EnvelopeBody {
|
|
||||||
/// Exactly ONE write op (write batching removed, plan §13). Atomic by itself.
|
|
||||||
Op(Operation),
|
|
||||||
/// One-or-more read-only queries answered together (safe: no mutation/undo).
|
|
||||||
Queries(Vec<Query>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The outcome of an applied write op.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum OpOutcome {
|
|
||||||
/// A new entity was created; carries its fresh `ObjectId`.
|
|
||||||
NewId(ObjectId),
|
|
||||||
/// An existing entity was updated in place (same `ObjectId` preserved).
|
|
||||||
Updated(ObjectId),
|
|
||||||
/// Entities were deleted; carries the deleted ids (one for `Delete`, N for `DeleteMany`).
|
|
||||||
Deleted(Vec<ObjectId>),
|
|
||||||
/// Many entities were created (bulk `CreateMany`); carries all fresh ids in order.
|
|
||||||
NewIds(Vec<ObjectId>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpOutcome {
|
|
||||||
/// The single fresh id, for ops that create exactly one entity.
|
|
||||||
pub fn new_id(&self) -> Option<ObjectId> {
|
|
||||||
match self {
|
|
||||||
OpOutcome::NewId(id) => Some(*id),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// All fresh ids, for bulk creates.
|
|
||||||
pub fn new_ids(&self) -> &[ObjectId] {
|
|
||||||
match self {
|
|
||||||
OpOutcome::NewIds(ids) => ids,
|
|
||||||
OpOutcome::NewId(id) => std::slice::from_ref(id),
|
|
||||||
_ => &[],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The per-request result (and the query-batch answer carrier).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct Receipt {
|
|
||||||
/// Present for `EnvelopeBody::Op`; absent for pure queries.
|
|
||||||
pub outcome: Option<OpOutcome>,
|
|
||||||
/// One `QueryResult` per `Query` in a `Queries` batch (in order).
|
|
||||||
pub query_results: Vec<QueryResult>,
|
|
||||||
/// The geometry revision after this request (== before, for pure queries).
|
|
||||||
pub new_revision: GeometryRevision,
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//! Structured error model. Serializable so IPC returns the
|
|
||||||
//! same error the in-process executor produced.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use crate::id::ObjectId;
|
|
||||||
|
|
||||||
/// Which kind of kernel failure occurred (a mapped `cadkernel` `Snag`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum GeometryErrorKind {
|
|
||||||
/// The operation produced an empty / degenerate body.
|
|
||||||
Empty,
|
|
||||||
/// Boolean refused (coincident geometry, unsupported cut, …).
|
|
||||||
BooleanFailed,
|
|
||||||
/// Invalid input to a kernel constructor (e.g. non-positive radius).
|
|
||||||
InvalidInput,
|
|
||||||
/// ACIS lift/parse/lower failed.
|
|
||||||
Acis,
|
|
||||||
/// Any other kernel error (message carries detail).
|
|
||||||
Other,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The single error type for the whole API. `commit`/ops return
|
|
||||||
/// `Result<_, ApiError>`; on a failed op nothing is applied and no undo step is
|
|
||||||
/// recorded (per-op atomicity).
|
|
||||||
#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum ApiError {
|
|
||||||
/// Input failed validation before any mutation (names the op + reason).
|
|
||||||
#[error("validation failed on {op}: {reason}")]
|
|
||||||
Validation { op: String, reason: String },
|
|
||||||
|
|
||||||
/// The geometry kernel reported a failure (a mapped `Snag`).
|
|
||||||
#[error("geometry kernel: {kind:?}: {msg}")]
|
|
||||||
Geometry {
|
|
||||||
kind: GeometryErrorKind,
|
|
||||||
msg: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// An `ObjectId` does not resolve to a live entity (stale/deleted/never existed).
|
|
||||||
#[error("unknown ObjectId {0:?}")]
|
|
||||||
UnknownId(ObjectId),
|
|
||||||
|
|
||||||
/// The requested capability is not supported (e.g. over-cap bulk op, unimplemented family).
|
|
||||||
#[error("unsupported capability: {0}")]
|
|
||||||
Unsupported(String),
|
|
||||||
|
|
||||||
/// The transport (IPC / channel) failed — disconnected, oversized, timeout.
|
|
||||||
#[error("transport: {0}")]
|
|
||||||
Transport(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ApiResult<T> = Result<T, ApiError>;
|
|
||||||
|
|
||||||
impl ApiError {
|
|
||||||
pub fn validation(op: &'static str, reason: impl Into<String>) -> Self {
|
|
||||||
ApiError::Validation {
|
|
||||||
op: op.to_string(),
|
|
||||||
reason: reason.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn geometry(kind: GeometryErrorKind, msg: impl Into<String>) -> Self {
|
|
||||||
ApiError::Geometry {
|
|
||||||
kind,
|
|
||||||
msg: msg.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,616 +0,0 @@
|
|||||||
//! The op executor: crate-owned logic mapping each
|
|
||||||
//! `Operation`/`Query` to `DocApiBackend` calls. Both the in-process transport
|
|
||||||
//! and the host's IPC executor use this — one implementation, versioned in the
|
|
||||||
//! crate. Per-op atomicity: a write op validates its inputs and computes its
|
|
||||||
//! geometry BEFORE calling `push_undo`, mutates, then `finalize_op` once.
|
|
||||||
|
|
||||||
use crate::backend::DocApiBackend;
|
|
||||||
use crate::envelope::{OpOutcome, Receipt};
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
|
||||||
use crate::gen::{Operation, Query};
|
|
||||||
use crate::id::ObjectId;
|
|
||||||
use crate::ops::{BoolOp, EntitySpec, BULK_ITEM_CAP};
|
|
||||||
use crate::query::QueryResult;
|
|
||||||
|
|
||||||
/// Apply ONE write op atomically. On success: one undo step and an advanced revision.
|
|
||||||
/// On failure: nothing applied, no undo, no bump (the op validates before mutating).
|
|
||||||
pub fn apply_op<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt> {
|
|
||||||
let result = apply_op_inner(b, op);
|
|
||||||
if result.is_err() {
|
|
||||||
b.cancel_op();
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt> {
|
|
||||||
let name = op.op_name();
|
|
||||||
crate::validation::operation(&op)?;
|
|
||||||
match &op {
|
|
||||||
Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => {
|
|
||||||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
|
||||||
if ids.is_empty() || unique.len() != ids.len() {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
name,
|
|
||||||
"batch ids must be nonempty and unique",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Operation::SetXDataMany(records) => {
|
|
||||||
if records.is_empty() {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
name,
|
|
||||||
"SetXDataMany records must be nonempty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if records.len() > BULK_ITEM_CAP {
|
|
||||||
return Err(over_cap(name, records.len()));
|
|
||||||
}
|
|
||||||
let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect();
|
|
||||||
if unique.len() != records.len() {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
name,
|
|
||||||
"SetXDataMany records must have unique entity ids",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op
|
|
||||||
{
|
|
||||||
crate::geom::validate_placement(placement)?;
|
|
||||||
}
|
|
||||||
let outcome = match &op {
|
|
||||||
Operation::CreateSolid(prim) => {
|
|
||||||
let body = crate::geom::make_solid(prim)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.store_solid(&body)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateCurve(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_curve(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::Extrude { profile, direction } => {
|
|
||||||
let curves = profile_curves(b, *profile, name)?;
|
|
||||||
let body = crate::geom::extrude(b.profile_plane(*profile)?, &curves, *direction)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.store_solid(&body)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::Revolve {
|
|
||||||
profile,
|
|
||||||
axis,
|
|
||||||
angle,
|
|
||||||
} => {
|
|
||||||
let curves = profile_curves(b, *profile, name)?;
|
|
||||||
let (pivot, axis_dir) = *axis;
|
|
||||||
let body =
|
|
||||||
crate::geom::revolve(b.profile_plane(*profile)?, &curves, pivot, axis_dir, *angle)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.store_solid(&body)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::Transform { id, placement } => {
|
|
||||||
require_exists(b, *id, name)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.transform_entity(*id, placement)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
Operation::SolidBoolean {
|
|
||||||
op: bop,
|
|
||||||
a,
|
|
||||||
b: bid,
|
|
||||||
erase_sources,
|
|
||||||
} => {
|
|
||||||
// Resolve + combine BEFORE push_undo: pure computation, no mutation.
|
|
||||||
let ba = b.resolve_body(*a).map_err(|_| ApiError::UnknownId(*a))?;
|
|
||||||
let bb = b
|
|
||||||
.resolve_body(*bid)
|
|
||||||
.map_err(|_| ApiError::UnknownId(*bid))?;
|
|
||||||
let result = crate::geom::boolean(&ba, &bb, *bop)?;
|
|
||||||
// Validate the erase BEFORE mutating: if `b` cannot be removed (e.g.
|
|
||||||
// locked layer), fail now rather than after `update_solid(a)` has
|
|
||||||
// already committed a partial mutation.
|
|
||||||
if *erase_sources {
|
|
||||||
if a == bid {
|
|
||||||
return Err(ApiError::validation(name, "boolean source ids must differ"));
|
|
||||||
}
|
|
||||||
b.can_modify(*a)?;
|
|
||||||
b.can_remove(*bid)?;
|
|
||||||
}
|
|
||||||
b.push_undo(name);
|
|
||||||
// `erase_sources`: the result lives at `a` (updated in place), `b` erased.
|
|
||||||
// Otherwise store a fresh solid and leave both inputs untouched.
|
|
||||||
let out_id = if *erase_sources {
|
|
||||||
b.update_solid(*a, &result)?;
|
|
||||||
b.remove_entity(*bid)?;
|
|
||||||
*a
|
|
||||||
} else {
|
|
||||||
b.store_solid(&result)?
|
|
||||||
};
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(out_id)
|
|
||||||
}
|
|
||||||
Operation::AddVertex { id, at, point } => {
|
|
||||||
require_exists(b, *id, name)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.add_vertex(*id, *at, *point)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
Operation::Delete { id } => {
|
|
||||||
// can_remove validates existence AND removability (locked layer) before
|
|
||||||
// any mutation, so a locked entity errors cleanly instead of a no-op
|
|
||||||
// reported as Deleted.
|
|
||||||
b.can_remove(*id).map_err(|e| match e {
|
|
||||||
ApiError::UnknownId(_) => {
|
|
||||||
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
})?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.remove_entity(*id)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Deleted(vec![*id])
|
|
||||||
}
|
|
||||||
Operation::CreateMany(specs) => {
|
|
||||||
if specs.len() > BULK_ITEM_CAP {
|
|
||||||
return Err(over_cap(name, specs.len()));
|
|
||||||
}
|
|
||||||
if specs.is_empty() {
|
|
||||||
return Err(ApiError::validation(name, "batch must not be empty"));
|
|
||||||
}
|
|
||||||
for spec in specs {
|
|
||||||
if let EntitySpec::Curve(curve) = spec {
|
|
||||||
crate::validation::curve(curve)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ids = b.create_many(specs)?;
|
|
||||||
OpOutcome::NewIds(ids)
|
|
||||||
}
|
|
||||||
Operation::TransformMany { ids, placement } => {
|
|
||||||
if ids.len() > BULK_ITEM_CAP {
|
|
||||||
return Err(over_cap(name, ids.len()));
|
|
||||||
}
|
|
||||||
// Pre-validate EVERYTHING fallible before push_undo (all-or-nothing,
|
|
||||||
// the bulk contract): every id must exist AND be transformable, so the apply
|
|
||||||
// loop below cannot fail part-way. `transform_entity` after this is
|
|
||||||
// infallible-by-construction for the validated ids.
|
|
||||||
for (i, id) in ids.iter().enumerate() {
|
|
||||||
b.ensure_transformable(*id).map_err(|e| match e {
|
|
||||||
ApiError::UnknownId(_) => stale_index(name, i, *id),
|
|
||||||
other => match other {
|
|
||||||
ApiError::Unsupported(msg) => ApiError::Validation {
|
|
||||||
op: name.to_string(),
|
|
||||||
reason: format!("index {i}: {msg}"),
|
|
||||||
},
|
|
||||||
o => o,
|
|
||||||
},
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
b.transform_many(ids, placement)?;
|
|
||||||
OpOutcome::Updated(*ids.first().unwrap_or(&ObjectId::NULL))
|
|
||||||
}
|
|
||||||
Operation::DeleteMany(ids) => {
|
|
||||||
if ids.len() > BULK_ITEM_CAP {
|
|
||||||
return Err(over_cap(name, ids.len()));
|
|
||||||
}
|
|
||||||
// Pre-validate existence AND removability (locked layer) for every id
|
|
||||||
// before push_undo, so the apply loop cannot fail part-way.
|
|
||||||
for (i, id) in ids.iter().enumerate() {
|
|
||||||
b.can_remove(*id).map_err(|e| match e {
|
|
||||||
ApiError::UnknownId(_) => stale_index(name, i, *id),
|
|
||||||
ApiError::Unsupported(msg) => ApiError::Validation {
|
|
||||||
op: name.to_string(),
|
|
||||||
reason: format!("index {i}: {msg}"),
|
|
||||||
},
|
|
||||||
other => other,
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
b.push_undo(name);
|
|
||||||
for id in ids {
|
|
||||||
b.remove_entity(*id)?;
|
|
||||||
}
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Deleted(ids.clone())
|
|
||||||
}
|
|
||||||
Operation::CreateInsert(spec) => {
|
|
||||||
// Validate the block name exists before committing (no rollback needed).
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_insert(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateViewport(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_viewport(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateText(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_text(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateMText(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_mtext(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::SetTextContent { id, value } => {
|
|
||||||
b.can_modify(*id).map_err(|e| match e {
|
|
||||||
ApiError::UnknownId(_) => {
|
|
||||||
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
})?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.set_text_content(*id, value)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
Operation::CreateHatch(spec) => {
|
|
||||||
// Validate the boundary (>= 3 points) before committing.
|
|
||||||
if spec.boundary.len() < 3 {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
name,
|
|
||||||
"hatch boundary needs >= 3 points",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_hatch(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateDimensionLinear(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_dimension_linear(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::SetAttribute { id, tag, value } => {
|
|
||||||
b.can_modify(*id).map_err(|e| match e {
|
|
||||||
ApiError::UnknownId(_) => {
|
|
||||||
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
})?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.set_attribute(*id, tag, value)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
Operation::SetViewportView {
|
|
||||||
id,
|
|
||||||
view_target,
|
|
||||||
view_height,
|
|
||||||
} => {
|
|
||||||
b.can_modify(*id).map_err(|e| match e {
|
|
||||||
ApiError::UnknownId(_) => {
|
|
||||||
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
})?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.set_viewport_view(*id, *view_target, *view_height)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
Operation::CreateRasterImage(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_raster_image(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::Loft { profiles } => {
|
|
||||||
// Bound the section count like the bulk ops (one envelope can carry many
|
|
||||||
// ids; an uncapped loft would be an unbounded kernel call on the UI thread).
|
|
||||||
if profiles.len() > BULK_ITEM_CAP {
|
|
||||||
return Err(over_cap(name, profiles.len()));
|
|
||||||
}
|
|
||||||
// Resolve every profile to its curve set BEFORE push_undo (all-or-nothing).
|
|
||||||
if profiles.len() < 2 {
|
|
||||||
return Err(ApiError::validation(name, "loft needs >= 2 profiles"));
|
|
||||||
}
|
|
||||||
let mut sections = Vec::with_capacity(profiles.len());
|
|
||||||
for (i, p) in profiles.iter().enumerate() {
|
|
||||||
let curves = profile_curves(b, *p, name).map_err(|e| at_index(name, i, e))?;
|
|
||||||
sections.push((b.profile_plane(*p)?, curves));
|
|
||||||
}
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.loft(§ions)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateDimensionRadius(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_dimension_radius(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateDimensionDiameter(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_dimension_diameter(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateDimensionAngular(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_dimension_angular(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateAttributeDefinition(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_attribute_definition(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateTable(spec) => {
|
|
||||||
// Validate a non-empty, rectangular grid before committing.
|
|
||||||
let cols = spec.data.first().map(|r| r.len()).unwrap_or(0);
|
|
||||||
if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
name,
|
|
||||||
"table needs a non-empty rectangular grid",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_table(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateDimensionAngular2Ln(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_dimension_angular2ln(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::CreateLayer(info) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
b.create_layer(info)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(ObjectId::NULL)
|
|
||||||
}
|
|
||||||
Operation::UpdateLayer { name, info } => {
|
|
||||||
b.push_undo(name);
|
|
||||||
b.update_layer(name, info)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(ObjectId::NULL)
|
|
||||||
}
|
|
||||||
Operation::DeleteLayer { name } => {
|
|
||||||
b.push_undo(name);
|
|
||||||
b.delete_layer(name)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(ObjectId::NULL)
|
|
||||||
}
|
|
||||||
Operation::SetEntityLayer { id, layer } => {
|
|
||||||
require_exists(b, *id, name)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.set_entity_layer(*id, layer)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
Operation::SetXData {
|
|
||||||
id,
|
|
||||||
application_name,
|
|
||||||
record,
|
|
||||||
} => {
|
|
||||||
require_exists(b, *id, name)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.set_xdata(*id, application_name, record.as_ref())?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
Operation::SetXDataMany(records) => {
|
|
||||||
// A later locked entity must not leave earlier records changed.
|
|
||||||
for (i, (id, _, _)) in records.iter().enumerate() {
|
|
||||||
b.can_modify(*id).map_err(|e| match e {
|
|
||||||
ApiError::UnknownId(_) => stale_index(name, i, *id),
|
|
||||||
ApiError::Unsupported(reason) => {
|
|
||||||
ApiError::validation(name, format!("index {i}: {reason}"))
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
b.push_undo(name);
|
|
||||||
for (id, application_name, record) in records {
|
|
||||||
b.set_xdata(*id, application_name, record.as_ref())?;
|
|
||||||
}
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(ObjectId::NULL)
|
|
||||||
}
|
|
||||||
Operation::CreateXRecord(spec) => {
|
|
||||||
b.push_undo(name);
|
|
||||||
let id = b.add_xrecord(spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::NewId(id)
|
|
||||||
}
|
|
||||||
Operation::SetXRecord { id, spec } => {
|
|
||||||
require_object_exists(b, *id, name)?;
|
|
||||||
b.push_undo(name);
|
|
||||||
b.set_xrecord(*id, spec)?;
|
|
||||||
b.finalize_op();
|
|
||||||
OpOutcome::Updated(*id)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(Receipt {
|
|
||||||
outcome: Some(outcome),
|
|
||||||
query_results: Vec::new(),
|
|
||||||
new_revision: b.revision(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a batch of read-only queries (no undo, no bump; `&mut` because solid
|
|
||||||
/// resolution may populate the kernel body cache on miss). Bounded like the bulk
|
|
||||||
/// ops: a batch over `BULK_ITEM_CAP` is rejected before any work (the 64 MiB frame
|
|
||||||
/// cap bounds message size, not count).
|
|
||||||
pub fn apply_queries<B: DocApiBackend>(b: &mut B, queries: Vec<Query>) -> ApiResult<Receipt> {
|
|
||||||
if queries.len() > BULK_ITEM_CAP {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"Queries",
|
|
||||||
format!(
|
|
||||||
"query batch over cap: {} > {}",
|
|
||||||
queries.len(),
|
|
||||||
BULK_ITEM_CAP
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut results = Vec::with_capacity(queries.len());
|
|
||||||
for q in &queries {
|
|
||||||
let qname = q.query_name();
|
|
||||||
let r = match q {
|
|
||||||
Query::GetEntity { id } => {
|
|
||||||
QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::GetBounds { id } => {
|
|
||||||
QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::GetCentroid { id } => {
|
|
||||||
QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::GetVolume { id } => {
|
|
||||||
QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::GetIntersects { a, b: bid } => {
|
|
||||||
let ba = b.bounds(*a).map_err(|e| label(qname, e))?;
|
|
||||||
let bb = b.bounds(*bid).map_err(|e| label(qname, e))?;
|
|
||||||
QueryResult::Intersects(ba.overlaps(&bb))
|
|
||||||
}
|
|
||||||
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
|
|
||||||
Query::GetTextContent { id } => {
|
|
||||||
QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::GetHatchBoundary { id } => {
|
|
||||||
QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(
|
|
||||||
b.dimension_measurement(*id).map_err(|e| label(qname, e))?,
|
|
||||||
),
|
|
||||||
Query::GetAttributes { id } => {
|
|
||||||
QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(
|
|
||||||
b.block_entities(block_name).map_err(|e| label(qname, e))?,
|
|
||||||
),
|
|
||||||
Query::GetViewportView { id } => {
|
|
||||||
let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?;
|
|
||||||
QueryResult::ViewportView { target, height }
|
|
||||||
}
|
|
||||||
Query::GetXData {
|
|
||||||
id,
|
|
||||||
application_name,
|
|
||||||
} => QueryResult::XData(
|
|
||||||
b.xdata(*id, application_name)
|
|
||||||
.map_err(|e| label(qname, e))?
|
|
||||||
.clone(),
|
|
||||||
),
|
|
||||||
Query::GetXRecord { id } => {
|
|
||||||
QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else(
|
|
||||||
|| ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")),
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?),
|
|
||||||
Query::GetEntityLayer { id } => {
|
|
||||||
QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?)
|
|
||||||
}
|
|
||||||
Query::EnumerateEntities {
|
|
||||||
kind,
|
|
||||||
layer,
|
|
||||||
include_bounds,
|
|
||||||
} => QueryResult::Entities(
|
|
||||||
b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds)
|
|
||||||
.map_err(|e| label(qname, e))?,
|
|
||||||
),
|
|
||||||
Query::GetPointPosition { id } => QueryResult::PointPosition(
|
|
||||||
b.point_position(*id).map_err(|e| label(qname, e))?,
|
|
||||||
),
|
|
||||||
Query::GetLineGeometry { id } => {
|
|
||||||
let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?;
|
|
||||||
QueryResult::LineGeometry((start, end))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
results.push(r);
|
|
||||||
}
|
|
||||||
Ok(Receipt {
|
|
||||||
outcome: None,
|
|
||||||
query_results: results,
|
|
||||||
new_revision: b.revision(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach the query name to an error for better diagnostics (uses `query_name`).
|
|
||||||
fn label(query: &'static str, e: ApiError) -> ApiError {
|
|
||||||
match e {
|
|
||||||
ApiError::Validation { reason, .. } => ApiError::validation(query, reason),
|
|
||||||
ApiError::UnknownId(id) => ApiError::validation(query, format!("unknown ObjectId {id:?}")),
|
|
||||||
other => other,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn profile_curves<B: DocApiBackend>(
|
|
||||||
b: &B,
|
|
||||||
profile: ObjectId,
|
|
||||||
op: &'static str,
|
|
||||||
) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
|
|
||||||
if !b.entity_exists(profile) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
op,
|
|
||||||
format!("unknown ObjectId {profile:?}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
b.profile_curves(profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that an entity exists before performing an operation.
|
|
||||||
fn require_exists<B: DocApiBackend>(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> {
|
|
||||||
if b.entity_exists(id) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::validation(op, format!("unknown ObjectId {id:?}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that a named object exists before performing an operation.
|
|
||||||
fn require_object_exists<B: DocApiBackend>(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> {
|
|
||||||
if b.object_exists(id) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::validation(op, format!("unknown ObjectId {id:?}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn over_cap(op: &'static str, n: usize) -> ApiError {
|
|
||||||
ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError {
|
|
||||||
ApiError::validation(op, format!("stale ObjectId at index {i}: {id:?}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError {
|
|
||||||
match e {
|
|
||||||
ApiError::Validation { reason, .. } => {
|
|
||||||
ApiError::validation(op, format!("index {i}: {reason}"))
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boolean op kind → kernel `Operation` (host feature only).
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub(crate) fn kernel_bool_op(op: BoolOp) -> cadkernel::brep::Operation {
|
|
||||||
match op {
|
|
||||||
BoolOp::Union => cadkernel::brep::Operation::Union,
|
|
||||||
BoolOp::Intersection => cadkernel::brep::Operation::Intersection,
|
|
||||||
BoolOp::Difference => cadkernel::brep::Operation::Difference,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
# ocs_doc_api — API reference (auto-generated)
|
|
||||||
|
|
||||||
Envelope version: `1`
|
|
||||||
|
|
||||||
Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.
|
|
||||||
|
|
||||||
## `Solid` (acadrust `Solid3D`, collection `solids`)
|
|
||||||
|
|
||||||
B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT).
|
|
||||||
|
|
||||||
### Constructors (`doc.solids()`)
|
|
||||||
|
|
||||||
- **create_cuboid**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Cuboid"
|
|
||||||
- **create_sphere**(centre: [f64; 3], radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Sphere"
|
|
||||||
- **create_cylinder**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cylinder"
|
|
||||||
- **create_cone**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cone"
|
|
||||||
- **create_torus**(centre: [f64; 3], major_radius: f64, minor_radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Torus"
|
|
||||||
- **create_wedge**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Wedge"
|
|
||||||
- **extrude**(profile: EntityRef, direction: [f64; 3]) -> `Solid` — `Extrude`
|
|
||||||
- **revolve**(profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64) -> `Solid` — `Revolve`
|
|
||||||
- **loft**(profiles: &[EntityRef]) -> `Solid` — `Loft`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **intersect**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Intersection"
|
|
||||||
- **union**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Union"
|
|
||||||
- **subtract**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Difference"
|
|
||||||
- **volume**() -> `f64` — query `GetVolume`
|
|
||||||
- **centroid**() -> `[f64; 3]` — query `GetCentroid`
|
|
||||||
- **intersects**(other: Solid) -> `bool` — query `GetIntersects`
|
|
||||||
|
|
||||||
## `Line` (acadrust `Line`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_line**(start: [f64; 3], end: [f64; 3]) -> `Line` — `CreateCurve`; fixed curve="Line"
|
|
||||||
|
|
||||||
## `Circle` (acadrust `Circle`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_circle**(centre: [f64; 3], radius: f64) -> `Circle` — `CreateCurve`; fixed curve="Circle"
|
|
||||||
|
|
||||||
## `Polyline` (acadrust `LwPolyline`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_polyline**(points: &[[f64; 3]], closed: bool) -> `Polyline` — `CreateCurve`; fixed curve="Polyline"
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **add_vertex**(at: usize, point: [f64; 3]) -> `()` — op `AddVertex`
|
|
||||||
|
|
||||||
## `Point` (acadrust `Point`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_point**(position: [f64; 3]) -> `Point` — `CreateCurve`; fixed curve="Point"
|
|
||||||
- **create_points**(positions: &[[f64; 3]]) -> `Vec<Point>` *(bulk op)* — `CreateMany`; fixed curve="Point"
|
|
||||||
|
|
||||||
## `ArcCurve` (acadrust `Arc`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_arc**(centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64) -> `ArcCurve` — `CreateCurve`; fixed curve="Arc"
|
|
||||||
|
|
||||||
## `Ellipse` (acadrust `Ellipse`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_ellipse**(centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64) -> `Ellipse` — `CreateCurve`; fixed curve="Ellipse"
|
|
||||||
|
|
||||||
## `Spline` (acadrust `Spline`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_spline**(degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]) -> `Spline` — `CreateCurve`; fixed curve="Spline"
|
|
||||||
|
|
||||||
## `Ray` (acadrust `Ray`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_ray**(origin: [f64; 3], direction: [f64; 3]) -> `Ray` — `CreateCurve`; fixed curve="Ray"
|
|
||||||
|
|
||||||
## `XLine` (acadrust `XLine`, collection `curves`)
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_xline**(origin: [f64; 3], direction: [f64; 3]) -> `XLine` — `CreateCurve`; fixed curve="XLine"
|
|
||||||
|
|
||||||
## `Text` (acadrust `Text`, collection `curves`)
|
|
||||||
|
|
||||||
Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point.
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_text**(value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Text` — `CreateText`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **content**() -> `String` — query `GetTextContent`
|
|
||||||
- **set_content**(value: &str) -> `()` — op `SetTextContent`
|
|
||||||
|
|
||||||
## `MText` (acadrust `MText`, collection `curves`)
|
|
||||||
|
|
||||||
Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point.
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_mtext**(value: &str, insertion_point: [f64; 3], height: f64) -> `MText` — `CreateMText`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **content**() -> `String` — query `GetTextContent`
|
|
||||||
- **set_content**(value: &str) -> `()` — op `SetTextContent`
|
|
||||||
|
|
||||||
## `Entity` (acadrust `Hatch`, collection `curves`)
|
|
||||||
|
|
||||||
Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete.
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_hatch**(boundary: &[[f64; 2]], solid: bool) -> `Entity` — `CreateHatch`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **boundary**() -> `Vec<Vec<[f64; 2]>>` — query `GetHatchBoundary`
|
|
||||||
|
|
||||||
## `Dimension` (acadrust `Dimension`, collection `curves`)
|
|
||||||
|
|
||||||
Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points.
|
|
||||||
|
|
||||||
### Constructors (`doc.curves()`)
|
|
||||||
|
|
||||||
- **create_dimension_linear**(first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]) -> `Dimension` — `CreateDimensionLinear`
|
|
||||||
- **create_dimension_radius**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionRadius`
|
|
||||||
- **create_dimension_diameter**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionDiameter`
|
|
||||||
- **create_dimension_angular**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular`
|
|
||||||
- **create_dimension_angular2ln**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular2Ln`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **measurement**() -> `f64` — query `GetDimensionMeasurement`
|
|
||||||
|
|
||||||
## `Entity` (acadrust `Insert`, collection `entities`)
|
|
||||||
|
|
||||||
Block reference. Place an existing BlockRecord by name; transform moves its insert point.
|
|
||||||
|
|
||||||
### Constructors (`doc.entities()`)
|
|
||||||
|
|
||||||
- **create_insert**(block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> `Entity` — `CreateInsert`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **attributes**() -> `Vec<(String, String)>` — query `GetAttributes`
|
|
||||||
- **set_attribute**(tag: &str, value: &str) -> `()` — op `SetAttribute`
|
|
||||||
|
|
||||||
## `Entity` (acadrust `AttributeDefinition`, collection `entities`)
|
|
||||||
|
|
||||||
In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point.
|
|
||||||
|
|
||||||
### Constructors (`doc.entities()`)
|
|
||||||
|
|
||||||
- **create_attribute_definition**(tag: &str, prompt: &str, default_value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Entity` — `CreateAttributeDefinition`
|
|
||||||
|
|
||||||
## `Entity` (acadrust `Viewport`, collection `entities`)
|
|
||||||
|
|
||||||
Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center.
|
|
||||||
|
|
||||||
### Constructors (`doc.entities()`)
|
|
||||||
|
|
||||||
- **create_viewport**(center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64) -> `Entity` — `CreateViewport`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **viewport_view**() -> `([f64; 3], f64)` — query `GetViewportView`
|
|
||||||
- **set_view**(view_target: [f64; 3], view_height: f64) -> `()` — op `SetViewportView`
|
|
||||||
|
|
||||||
## `Entity` (acadrust `RasterImage`, collection `entities`)
|
|
||||||
|
|
||||||
Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete.
|
|
||||||
|
|
||||||
### Constructors (`doc.entities()`)
|
|
||||||
|
|
||||||
- **create_raster_image**(file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]) -> `Entity` — `CreateRasterImage`
|
|
||||||
|
|
||||||
## `Entity` (acadrust `Table`, collection `entities`)
|
|
||||||
|
|
||||||
Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete.
|
|
||||||
|
|
||||||
### Constructors (`doc.entities()`)
|
|
||||||
|
|
||||||
- **create_table**(insertion_point: [f64; 3], data: &[Vec<String>]) -> `Entity` — `CreateTable`
|
|
||||||
|
|
||||||
## `XRecord` (acadrust `XRecord`, collection `entities`)
|
|
||||||
|
|
||||||
Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload.
|
|
||||||
|
|
||||||
### Constructors (`doc.entities()`)
|
|
||||||
|
|
||||||
- **create_xrecord**(spec: XRecordSpec) -> `XRecord` — `CreateXRecord`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **payload**() -> `XRecordSpec` — query `GetXRecord`
|
|
||||||
- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord`
|
|
||||||
|
|
||||||
## `Layer` (acadrust `-`, collection `layers`)
|
|
||||||
|
|
||||||
Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers().
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **list**() -> `Vec<LayerInfo>` — query `ListLayers`
|
|
||||||
- **create**(info: LayerInfo) -> `()` — op `CreateLayer`
|
|
||||||
- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer`
|
|
||||||
- **delete**(name: String) -> `()` — op `DeleteLayer`
|
|
||||||
|
|
||||||
## Generic methods (every handle)
|
|
||||||
|
|
||||||
Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`,
|
|
||||||
`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a
|
|
||||||
`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports:
|
|
||||||
|
|
||||||
- **`id()` -> `ObjectId`** — the stable entity identity.
|
|
||||||
- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities
|
|
||||||
computed; later families may be `Unsupported`).
|
|
||||||
- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1:
|
|
||||||
solids only.**
|
|
||||||
- **`delete()`** — remove the entity (one undo step).
|
|
||||||
- **`layer()` -> `String`** — the name of the layer the entity is on.
|
|
||||||
- **`set_layer(layer)`** — move the entity to an existing layer (one undo step;
|
|
||||||
blocked if the entity or target layer is locked).
|
|
||||||
|
|
||||||
`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed
|
|
||||||
downcast when `kind == "Solid3D"`), and XDATA access through
|
|
||||||
`xdata(application_name)` / `set_xdata(application_name, record)`.
|
|
||||||
|
|
||||||
## Layer table
|
|
||||||
|
|
||||||
Layers are a named document table, not an entity family. `Document::layers()`
|
|
||||||
lists all layers as `Vec<LayerInfo>`. Table-level ops are `CreateLayer(info)`,
|
|
||||||
`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()`
|
|
||||||
returns the layer name and `Entity::set_layer(layer)` moves the entity to an
|
|
||||||
existing layer. The host rejects duplicate names, deletion of layer "0" or the
|
|
||||||
current layer, and removing a layer that still has entities assigned.
|
|
||||||
|
|
||||||
## Transports
|
|
||||||
|
|
||||||
- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host.
|
|
||||||
- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the
|
|
||||||
`ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes
|
|
||||||
`DocApiRequest { tab_id, bytes }` to the same executor.
|
|
||||||
- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that
|
|
||||||
builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a
|
|
||||||
`PluginRequestSender` (out-of-process / worker-thread plugins).
|
|
||||||
|
|
||||||
## Collections & cross-cutting
|
|
||||||
|
|
||||||
- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`.
|
|
||||||
- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup,
|
|
||||||
`revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only
|
|
||||||
batch, one round-trip, **no revision bump**).
|
|
||||||
- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`,
|
|
||||||
`transform_many(&ids, placement)`, `delete_many(&ids)`.
|
|
||||||
- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` /
|
|
||||||
`compensate`). **Not** a transaction: no atomicity, no rollback.
|
|
||||||
|
|
||||||
## Bulk ops — single op, all-or-nothing, item-capped
|
|
||||||
|
|
||||||
`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo
|
|
||||||
step, one publish, and **all-or-nothing via upfront validation** (no rollback).
|
|
||||||
Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for
|
|
||||||
bulk data (N ≳ a few hundred); per-op autocommit is the right tool for
|
|
||||||
modeling-scale work.
|
|
||||||
|
|
||||||
## Semantics
|
|
||||||
|
|
||||||
- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host
|
|
||||||
call = one undo step. Multi-op write transactions were removed (security/
|
|
||||||
integrity review).
|
|
||||||
- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a
|
|
||||||
failed op applies nothing and records no undo step. `erase_sources` booleans run
|
|
||||||
only on success.
|
|
||||||
- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported,
|
|
||||||
Transport}`; bulk validation names the failing index; a stale handle returns
|
|
||||||
`UnknownId` (never a panic).
|
|
||||||
|
|
||||||
## Kernel mapping
|
|
||||||
|
|
||||||
| op | kernel call | returns |
|
|
||||||
|---|---|---|
|
|
||||||
| CreateSolid.Cuboid | `cadkernel::brep::make::cuboid` | Option<Body> |
|
|
||||||
| CreateSolid.Sphere | `cadkernel::brep::make::sphere` | Option<Body> |
|
|
||||||
| SolidBoolean | `cadkernel::brep::combine` | Result<Body, Snag> |
|
|
||||||
| Transform | `cadkernel::brep::transform` | Option<Body> |
|
|
||||||
| GetBounds | `cadkernel::brep::body_bounds` | Option<Aabb> |
|
|
||||||
| GetVolume | `cadkernel::brep::mesh_body + divergence` | computed |
|
|
||||||
|
|
||||||
## Wire vocabulary
|
|
||||||
|
|
||||||
Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec<Query>)` for a read batch.
|
|
||||||
|
|
||||||
### `Operation`
|
|
||||||
|
|
||||||
- `CreateSolid(SolidPrimitive)`
|
|
||||||
- `CreateCurve(Curve2Spec)`
|
|
||||||
- `Extrude { profile: ObjectId, direction: [f64; 3] }`
|
|
||||||
- `Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }`
|
|
||||||
- `Transform { id: ObjectId, placement: PlacementSpec }`
|
|
||||||
- `SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }`
|
|
||||||
- `AddVertex { id: ObjectId, at: usize, point: [f64; 3] }`
|
|
||||||
- `Delete { id: ObjectId }`
|
|
||||||
- `CreateMany(Vec<EntitySpec>)`
|
|
||||||
- `TransformMany { ids: Vec<ObjectId>, placement: PlacementSpec }`
|
|
||||||
- `DeleteMany(Vec<ObjectId>)`
|
|
||||||
- `CreateInsert(InsertSpec)`
|
|
||||||
- `CreateViewport(ViewportSpec)`
|
|
||||||
- `CreateText(TextSpec)`
|
|
||||||
- `CreateMText(MTextSpec)`
|
|
||||||
- `SetTextContent { id: ObjectId, value: String }`
|
|
||||||
- `CreateHatch(HatchSpec)`
|
|
||||||
- `CreateDimensionLinear(DimensionSpec)`
|
|
||||||
- `SetAttribute { id: ObjectId, tag: String, value: String }`
|
|
||||||
- `SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }`
|
|
||||||
- `CreateRasterImage(RasterImageSpec)`
|
|
||||||
- `Loft { profiles: Vec<ObjectId> }`
|
|
||||||
- `CreateDimensionRadius(DimensionRadialSpec)`
|
|
||||||
- `CreateDimensionDiameter(DimensionRadialSpec)`
|
|
||||||
- `CreateDimensionAngular(DimensionAngularSpec)`
|
|
||||||
- `CreateAttributeDefinition(AttributeDefinitionSpec)`
|
|
||||||
- `CreateTable(TableSpec)`
|
|
||||||
- `CreateDimensionAngular2Ln(DimensionAngularSpec)`
|
|
||||||
- `SetXData { id: ObjectId, application_name: String, record: Option<XDataRecord> }`
|
|
||||||
- `CreateXRecord(XRecordSpec)`
|
|
||||||
- `SetXRecord { id: ObjectId, spec: XRecordSpec }`
|
|
||||||
- `CreateLayer(LayerInfo)`
|
|
||||||
- `UpdateLayer { name: String, info: LayerInfo }`
|
|
||||||
- `DeleteLayer { name: String }`
|
|
||||||
- `SetEntityLayer { id: ObjectId, layer: String }`
|
|
||||||
- `SetXDataMany(Vec<(ObjectId, String, Option<XDataRecord)>)`
|
|
||||||
|
|
||||||
### `Query`
|
|
||||||
|
|
||||||
- `GetEntity { id: ObjectId }`
|
|
||||||
- `GetBounds { id: ObjectId }`
|
|
||||||
- `GetCentroid { id: ObjectId }`
|
|
||||||
- `GetVolume { id: ObjectId }`
|
|
||||||
- `GetIntersects { a: ObjectId, b: ObjectId }`
|
|
||||||
- `GetGeometryRevision`
|
|
||||||
- `GetTextContent { id: ObjectId }`
|
|
||||||
- `GetHatchBoundary { id: ObjectId }`
|
|
||||||
- `GetDimensionMeasurement { id: ObjectId }`
|
|
||||||
- `GetAttributes { id: ObjectId }`
|
|
||||||
- `GetBlockEntities { block_name: String }`
|
|
||||||
- `GetViewportView { id: ObjectId }`
|
|
||||||
- `GetXData { id: ObjectId, application_name: String }`
|
|
||||||
- `GetXRecord { id: ObjectId }`
|
|
||||||
- `ListLayers`
|
|
||||||
- `GetEntityLayer { id: ObjectId }`
|
|
||||||
|
|
||||||
## Errors (`ApiError`)
|
|
||||||
|
|
||||||
| Variant | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `Validation { op, reason }` | Input rejected before any document mutation. |
|
|
||||||
| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). |
|
|
||||||
| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). |
|
|
||||||
| `Unsupported(String)` | Capability not supported for this entity or backend. |
|
|
||||||
| `Transport(String)` | The channel failed (disconnected / oversized / timeout). |
|
|
||||||
|
|
||||||
`ApiError` is serializable, so IPC returns the same structured error the in-process
|
|
||||||
executor produced.
|
|
||||||
|
|
||||||
## Envelope & transport
|
|
||||||
|
|
||||||
`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or
|
|
||||||
`Queries(Vec<Query>)` (a read batch). bincode-serialized; `version` is the crate's
|
|
||||||
envelope version (bridges must check it). Transports: `InProcess` (host) and
|
|
||||||
`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport:
|
|
||||||
Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries
|
|
||||||
`outcome` (per-op result), `query_results`, and `new_revision`.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"ObjectId": { "struct": [ { "name": "0", "type": "u64" } ] },
|
|
||||||
"Operation": {
|
|
||||||
"enum": [
|
|
||||||
"CreateSolid(SolidPrimitive)",
|
|
||||||
"CreateCurve(Curve2Spec)",
|
|
||||||
"Extrude { profile: ObjectId, direction: [f64; 3] }",
|
|
||||||
"Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }",
|
|
||||||
"Transform { id: ObjectId, placement: PlacementSpec }",
|
|
||||||
"SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }",
|
|
||||||
"AddVertex { id: ObjectId, at: usize, point: [f64; 3] }",
|
|
||||||
"Delete { id: ObjectId }",
|
|
||||||
"CreateMany(Vec<EntitySpec>)",
|
|
||||||
"TransformMany { ids: Vec<ObjectId>, placement: PlacementSpec }",
|
|
||||||
"DeleteMany(Vec<ObjectId>)",
|
|
||||||
"CreateInsert(InsertSpec)",
|
|
||||||
"CreateViewport(ViewportSpec)",
|
|
||||||
"CreateText(TextSpec)",
|
|
||||||
"CreateMText(MTextSpec)",
|
|
||||||
"SetTextContent { id: ObjectId, value: String }",
|
|
||||||
"CreateHatch(HatchSpec)",
|
|
||||||
"CreateDimensionLinear(DimensionSpec)",
|
|
||||||
"SetAttribute { id: ObjectId, tag: String, value: String }",
|
|
||||||
"SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }",
|
|
||||||
"CreateRasterImage(RasterImageSpec)",
|
|
||||||
"Loft { profiles: Vec<ObjectId> }",
|
|
||||||
"CreateDimensionRadius(DimensionRadialSpec)",
|
|
||||||
"CreateDimensionDiameter(DimensionRadialSpec)",
|
|
||||||
"CreateDimensionAngular(DimensionAngularSpec)",
|
|
||||||
"CreateAttributeDefinition(AttributeDefinitionSpec)",
|
|
||||||
"CreateTable(TableSpec)",
|
|
||||||
"CreateDimensionAngular2Ln(DimensionAngularSpec)",
|
|
||||||
"SetXData { id: ObjectId, application_name: String, record: Option<XDataRecord> }",
|
|
||||||
"CreateXRecord(XRecordSpec)",
|
|
||||||
"SetXRecord { id: ObjectId, spec: XRecordSpec }",
|
|
||||||
"CreateLayer(LayerInfo)",
|
|
||||||
"UpdateLayer { name: String, info: LayerInfo }",
|
|
||||||
"DeleteLayer { name: String }",
|
|
||||||
"SetEntityLayer { id: ObjectId, layer: String }",
|
|
||||||
"SetXDataMany(Vec<(ObjectId, String, Option<XDataRecord)>)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Query": {
|
|
||||||
"enum": [
|
|
||||||
"GetEntity { id: ObjectId }",
|
|
||||||
"GetBounds { id: ObjectId }",
|
|
||||||
"GetCentroid { id: ObjectId }",
|
|
||||||
"GetVolume { id: ObjectId }",
|
|
||||||
"GetIntersects { a: ObjectId, b: ObjectId }",
|
|
||||||
"GetGeometryRevision",
|
|
||||||
"GetTextContent { id: ObjectId }",
|
|
||||||
"GetHatchBoundary { id: ObjectId }",
|
|
||||||
"GetDimensionMeasurement { id: ObjectId }",
|
|
||||||
"GetAttributes { id: ObjectId }",
|
|
||||||
"GetBlockEntities { block_name: String }",
|
|
||||||
"GetViewportView { id: ObjectId }",
|
|
||||||
"GetXData { id: ObjectId, application_name: String }",
|
|
||||||
"GetXRecord { id: ObjectId }",
|
|
||||||
"ListLayers",
|
|
||||||
"GetEntityLayer { id: ObjectId }"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// HAND-MAINTAINED wire vocabulary (the Operation enum). This file is NOT derived
|
|
||||||
// from spec/entities.toml by build.rs — the enum is the canonical append-only
|
|
||||||
// wire vocabulary; the spec's `op` names must each map to a variant here
|
|
||||||
// (enforced by a test). Append new variants at the END only (bincode discriminant
|
|
||||||
// stability). Do NOT reorder or remove variants.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::id::ObjectId;
|
|
||||||
use crate::ops::{
|
|
||||||
AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec,
|
|
||||||
DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec,
|
|
||||||
SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A typed write operation. Each variant is ONE atomic host call = one
|
|
||||||
/// undo step. Append new variants at the END only.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum Operation {
|
|
||||||
// construction
|
|
||||||
CreateSolid(SolidPrimitive),
|
|
||||||
CreateCurve(Curve2Spec),
|
|
||||||
Extrude {
|
|
||||||
profile: ObjectId,
|
|
||||||
direction: [f64; 3],
|
|
||||||
},
|
|
||||||
Revolve {
|
|
||||||
profile: ObjectId,
|
|
||||||
axis: ([f64; 3], [f64; 3]),
|
|
||||||
angle: f64,
|
|
||||||
},
|
|
||||||
// modification
|
|
||||||
Transform {
|
|
||||||
id: ObjectId,
|
|
||||||
placement: PlacementSpec,
|
|
||||||
},
|
|
||||||
SolidBoolean {
|
|
||||||
op: BoolOp,
|
|
||||||
a: ObjectId,
|
|
||||||
b: ObjectId,
|
|
||||||
erase_sources: bool,
|
|
||||||
},
|
|
||||||
AddVertex {
|
|
||||||
id: ObjectId,
|
|
||||||
at: usize,
|
|
||||||
point: [f64; 3],
|
|
||||||
},
|
|
||||||
Delete {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
// Bulk ops (single op, upfront-validation all-or-nothing — NOT a transaction, §5.3)
|
|
||||||
CreateMany(Vec<EntitySpec>),
|
|
||||||
TransformMany {
|
|
||||||
ids: Vec<ObjectId>,
|
|
||||||
placement: PlacementSpec,
|
|
||||||
},
|
|
||||||
DeleteMany(Vec<ObjectId>),
|
|
||||||
// containers
|
|
||||||
CreateInsert(InsertSpec),
|
|
||||||
// paper-space
|
|
||||||
CreateViewport(ViewportSpec),
|
|
||||||
// annotations
|
|
||||||
CreateText(TextSpec),
|
|
||||||
CreateMText(MTextSpec),
|
|
||||||
SetTextContent {
|
|
||||||
id: ObjectId,
|
|
||||||
value: String,
|
|
||||||
},
|
|
||||||
// hatch
|
|
||||||
CreateHatch(HatchSpec),
|
|
||||||
// dimensions
|
|
||||||
CreateDimensionLinear(DimensionSpec),
|
|
||||||
// attributes
|
|
||||||
SetAttribute {
|
|
||||||
id: ObjectId,
|
|
||||||
tag: String,
|
|
||||||
value: String,
|
|
||||||
},
|
|
||||||
// viewport view
|
|
||||||
SetViewportView {
|
|
||||||
id: ObjectId,
|
|
||||||
view_target: [f64; 3],
|
|
||||||
view_height: f64,
|
|
||||||
},
|
|
||||||
// media
|
|
||||||
CreateRasterImage(RasterImageSpec),
|
|
||||||
// multi-profile solid
|
|
||||||
Loft {
|
|
||||||
profiles: Vec<ObjectId>,
|
|
||||||
},
|
|
||||||
// dimension sub-types
|
|
||||||
CreateDimensionRadius(DimensionRadialSpec),
|
|
||||||
CreateDimensionDiameter(DimensionRadialSpec),
|
|
||||||
CreateDimensionAngular(DimensionAngularSpec),
|
|
||||||
// attribute definitions
|
|
||||||
CreateAttributeDefinition(AttributeDefinitionSpec),
|
|
||||||
// tables
|
|
||||||
CreateTable(TableSpec),
|
|
||||||
// dimension sub-types (2-line angular)
|
|
||||||
CreateDimensionAngular2Ln(DimensionAngularSpec),
|
|
||||||
// xdata / xrecord roundtrip
|
|
||||||
SetXData {
|
|
||||||
id: ObjectId,
|
|
||||||
application_name: String,
|
|
||||||
record: Option<XDataRecord>,
|
|
||||||
},
|
|
||||||
SetXDataMany(Vec<(ObjectId, String, Option<XDataRecord>)>),
|
|
||||||
CreateXRecord(XRecordSpec),
|
|
||||||
SetXRecord {
|
|
||||||
id: ObjectId,
|
|
||||||
spec: XRecordSpec,
|
|
||||||
},
|
|
||||||
// layer CRUD
|
|
||||||
CreateLayer(crate::ops::LayerInfo),
|
|
||||||
UpdateLayer {
|
|
||||||
name: String,
|
|
||||||
info: crate::ops::LayerInfo,
|
|
||||||
},
|
|
||||||
DeleteLayer {
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
SetEntityLayer {
|
|
||||||
id: ObjectId,
|
|
||||||
layer: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// HAND-MAINTAINED wire vocabulary (the Query enum). NOT derived from the spec by
|
|
||||||
// build.rs — the enum is the canonical append-only wire vocabulary; the spec's
|
|
||||||
// `query` names must each map to a variant here (enforced by a test). Append new
|
|
||||||
// variants at the END only (bincode discriminant stability).
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::id::ObjectId;
|
|
||||||
|
|
||||||
/// A typed read query. Read-only: no mutation, no undo, no revision
|
|
||||||
/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only.
|
|
||||||
/// (Not `Copy`: some variants carry owned data like a block name.)
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum Query {
|
|
||||||
GetEntity {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
GetBounds {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
GetCentroid {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
GetVolume {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// Whether two entities' bounds overlap (two-phase conditional modelling).
|
|
||||||
GetIntersects {
|
|
||||||
a: ObjectId,
|
|
||||||
b: ObjectId,
|
|
||||||
},
|
|
||||||
GetGeometryRevision,
|
|
||||||
/// The text content of a Text/MText annotation.
|
|
||||||
GetTextContent {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// The boundary loops of a Hatch (outer + islands) as polylines.
|
|
||||||
GetHatchBoundary {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// The measured value of a Dimension (distance or angle).
|
|
||||||
GetDimensionMeasurement {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// An insert's attributes as (tag, value) pairs.
|
|
||||||
GetAttributes {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// The entity ids + kinds inside a block definition (read-only traversal).
|
|
||||||
GetBlockEntities {
|
|
||||||
block_name: String,
|
|
||||||
},
|
|
||||||
/// A viewport's view (target + zoom height).
|
|
||||||
GetViewportView {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// The XDATA record for `application_name` attached to an entity (`None` if absent).
|
|
||||||
GetXData {
|
|
||||||
id: ObjectId,
|
|
||||||
application_name: String,
|
|
||||||
},
|
|
||||||
/// The XRECORD object payload by id.
|
|
||||||
GetXRecord {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// List all layers in the document.
|
|
||||||
ListLayers,
|
|
||||||
/// The layer name of an entity.
|
|
||||||
GetEntityLayer {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// Enumerate first-class entities, optionally filtered by kind and/or layer.
|
|
||||||
/// `include_bounds` populates `EntityView::bounds`; leave it false for a
|
|
||||||
/// lightweight list view.
|
|
||||||
EnumerateEntities {
|
|
||||||
kind: Option<String>,
|
|
||||||
layer: Option<String>,
|
|
||||||
include_bounds: bool,
|
|
||||||
},
|
|
||||||
/// The WCS location of a Point entity.
|
|
||||||
GetPointPosition {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
/// The start and end WCS points of a Line entity.
|
|
||||||
GetLineGeometry {
|
|
||||||
id: ObjectId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
//! Kernel geometry helpers (host feature, plan §10 code-mapping table). Thin
|
|
||||||
//! wrappers over `cadkernel::brep::*` mapping the crate's plain-data DTOs to the
|
|
||||||
//! non-serde kernel types. Kernel keys never cross the API (decision #7).
|
|
||||||
|
|
||||||
use cadkernel::geom2d::Curve as Curve2;
|
|
||||||
use cadkernel::space::Plane;
|
|
||||||
|
|
||||||
use crate::backend::KernelBody;
|
|
||||||
use crate::error::{ApiError, ApiResult, GeometryErrorKind};
|
|
||||||
use crate::ops::{BoolOp, SolidPrimitive};
|
|
||||||
|
|
||||||
fn geom_err(kind: GeometryErrorKind, msg: impl Into<String>) -> ApiError {
|
|
||||||
ApiError::geometry(kind, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signed volume and centroid of a closed triangle mesh via the divergence
|
|
||||||
/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source
|
|
||||||
/// of truth shared by the host backend and the test/example mock backends.
|
|
||||||
pub fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
|
|
||||||
mesh.mass_properties().unwrap_or((0.0, [0.0; 3]))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_placement(p: &crate::PlacementSpec) -> ApiResult<()> {
|
|
||||||
let placement = cadkernel::brep::Placement {
|
|
||||||
origin: p.origin,
|
|
||||||
x_axis: p.x_axis,
|
|
||||||
y_axis: p.y_axis,
|
|
||||||
z_axis: p.z_axis,
|
|
||||||
};
|
|
||||||
if placement.scale().is_none() || p.origin.iter().any(|v| !v.is_finite()) {
|
|
||||||
return Err(ApiError::validation(
|
|
||||||
"Transform",
|
|
||||||
"placement must be a finite, nonzero similarity",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct a solid primitive.
|
|
||||||
pub fn make_solid(p: &SolidPrimitive) -> ApiResult<KernelBody> {
|
|
||||||
crate::validation::solid(p)?;
|
|
||||||
use cadkernel::brep::make;
|
|
||||||
let body = match *p {
|
|
||||||
SolidPrimitive::Cuboid { origin, size } => make::cuboid(origin, size),
|
|
||||||
SolidPrimitive::Sphere { centre, radius } => make::sphere(centre, radius),
|
|
||||||
SolidPrimitive::Cylinder {
|
|
||||||
base,
|
|
||||||
radius,
|
|
||||||
height,
|
|
||||||
} => make::cylinder(base, radius, height),
|
|
||||||
SolidPrimitive::Cone {
|
|
||||||
base,
|
|
||||||
radius,
|
|
||||||
height,
|
|
||||||
} => make::cone(base, radius, height),
|
|
||||||
SolidPrimitive::Torus {
|
|
||||||
centre,
|
|
||||||
major_radius,
|
|
||||||
minor_radius,
|
|
||||||
} => make::torus(centre, major_radius, minor_radius),
|
|
||||||
SolidPrimitive::Wedge { origin, size } => make::wedge(origin, size[0], size[1], size[2]),
|
|
||||||
};
|
|
||||||
body.ok_or_else(|| {
|
|
||||||
geom_err(
|
|
||||||
GeometryErrorKind::InvalidInput,
|
|
||||||
"kernel make returned empty body",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boolean combine (pure): `brep::combine(a, b, op, operation_tolerance(&[&a,&b]))`.
|
|
||||||
/// Maps the kernel `Snag` to `ApiError::Geometry`. Does NOT mutate the inputs.
|
|
||||||
pub(crate) fn boolean(a: &KernelBody, b: &KernelBody, op: BoolOp) -> ApiResult<KernelBody> {
|
|
||||||
let kernel_op = crate::executor::kernel_bool_op(op);
|
|
||||||
let tol = cadkernel::brep::operation_tolerance(&[a, b]);
|
|
||||||
cadkernel::brep::combine(a.clone(), b.clone(), kernel_op, tol)
|
|
||||||
.map_err(|e| geom_err(GeometryErrorKind::BooleanFailed, format!("{e:?}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extrude a closed 2D profile into a solid (`brep::extrude`).
|
|
||||||
pub(crate) fn extrude(
|
|
||||||
plane: Plane,
|
|
||||||
profile: &[Curve2],
|
|
||||||
direction: [f64; 3],
|
|
||||||
) -> ApiResult<KernelBody> {
|
|
||||||
cadkernel::brep::extrude(plane, profile, direction)
|
|
||||||
.ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "extrude failed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Revolve a closed 2D profile about an axis into a solid (`brep::revolve`).
|
|
||||||
pub(crate) fn revolve(
|
|
||||||
plane: Plane,
|
|
||||||
profile: &[Curve2],
|
|
||||||
pivot: [f64; 3],
|
|
||||||
axis: [f64; 3],
|
|
||||||
angle: f64,
|
|
||||||
) -> ApiResult<KernelBody> {
|
|
||||||
cadkernel::brep::revolve(plane, profile, pivot, axis, angle)
|
|
||||||
.ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "revolve failed"))
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
//! Stable entity identity.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// A stable u64 handle to a first-level document entity, decoupled from any
|
|
||||||
/// internal engine/kernel pointer. Value-compatible with `acadrust::Handle` —
|
|
||||||
/// kernel keys never cross the API boundary.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct ObjectId(pub u64);
|
|
||||||
|
|
||||||
impl ObjectId {
|
|
||||||
/// The null / absent handle (`acadrust::Handle::NULL` value `0`).
|
|
||||||
pub const NULL: ObjectId = ObjectId(0);
|
|
||||||
|
|
||||||
pub const fn as_u64(self) -> u64 {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build from a raw u64 (e.g. an `acadrust::Handle`'s value).
|
|
||||||
pub const fn from_u64(v: u64) -> Self {
|
|
||||||
Self(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build from an `acadrust::Handle` (host/ipc feature).
|
|
||||||
#[cfg(any(feature = "host", feature = "ipc"))]
|
|
||||||
pub fn from_handle(h: acadrust::Handle) -> Self {
|
|
||||||
Self(h.value())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to an `acadrust::Handle` (host/ipc feature).
|
|
||||||
#[cfg(any(feature = "host", feature = "ipc"))]
|
|
||||||
pub fn to_handle(self) -> acadrust::Handle {
|
|
||||||
acadrust::Handle::new(self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
//! Shared layer-name normalization rules.
|
|
||||||
//!
|
|
||||||
//! Layer names in OpenCADStudio/acadrust are case-insensitive, leading/trailing
|
|
||||||
//! whitespace is ignored, and empty names are invalid. The host (`normalize_name`
|
|
||||||
//! in `acadrust::tables`) implements the canonical rule set; this module re-exports
|
|
||||||
//! a compatible, dependency-free function for the wire side and for backends that
|
|
||||||
//! do not link against `acadrust` directly.
|
|
||||||
|
|
||||||
/// Normalize a raw layer name into its case-insensitive lookup key.
|
|
||||||
///
|
|
||||||
/// This mirrors `acadrust::tables::normalize_name` used by the host:
|
|
||||||
/// - trims leading and trailing ASCII whitespace
|
|
||||||
/// - converts the result to uppercase
|
|
||||||
/// - returns `""` if the name is empty after trimming
|
|
||||||
pub fn normalize_layer_name(name: &str) -> String {
|
|
||||||
name.trim().to_ascii_uppercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A normalized layer name is non-empty and matches the host lookup key.
|
|
||||||
pub fn is_valid_layer_name(name: &str) -> bool {
|
|
||||||
!normalize_layer_name(name).is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalization_trims_and_uppercases() {
|
|
||||||
assert_eq!(normalize_layer_name(" Walls "), "WALLS");
|
|
||||||
assert_eq!(normalize_layer_name("walls"), "WALLS");
|
|
||||||
assert_eq!(normalize_layer_name("WALLS"), "WALLS");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_and_whitespace_names_are_invalid() {
|
|
||||||
assert_eq!(normalize_layer_name(""), "");
|
|
||||||
assert_eq!(normalize_layer_name(" "), "");
|
|
||||||
assert!(!is_valid_layer_name(""));
|
|
||||||
assert!(!is_valid_layer_name(" "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
//! Typed document operations, queries and facades over in-process or IPC transports.
|
|
||||||
|
|
||||||
pub mod error;
|
|
||||||
pub mod id;
|
|
||||||
pub mod revision;
|
|
||||||
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub mod backend;
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub mod convert;
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub mod executor;
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub mod geom;
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
mod validation;
|
|
||||||
|
|
||||||
pub mod envelope;
|
|
||||||
pub mod facade;
|
|
||||||
pub mod layer;
|
|
||||||
pub mod ops;
|
|
||||||
pub mod query;
|
|
||||||
pub mod transport;
|
|
||||||
|
|
||||||
// Append-only wire enums, maintained alongside the DTOs and spec.
|
|
||||||
pub mod gen {
|
|
||||||
pub mod ops {
|
|
||||||
include!("gen/ops_gen.rs");
|
|
||||||
}
|
|
||||||
pub mod query {
|
|
||||||
include!("gen/query_gen.rs");
|
|
||||||
}
|
|
||||||
pub use ops::Operation;
|
|
||||||
pub use query::Query;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use error::{ApiError, ApiResult, GeometryErrorKind};
|
|
||||||
pub use id::ObjectId;
|
|
||||||
pub use revision::GeometryRevision;
|
|
||||||
|
|
||||||
pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt};
|
|
||||||
pub use facade::{
|
|
||||||
ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity,
|
|
||||||
EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray,
|
|
||||||
Solid, SolidCollection, Spline, Text, XLine, XRecord,
|
|
||||||
};
|
|
||||||
pub use layer::{is_valid_layer_name, normalize_layer_name};
|
|
||||||
pub use ops::{
|
|
||||||
BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation,
|
|
||||||
PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry,
|
|
||||||
XRecordSpec, XRecordValue,
|
|
||||||
};
|
|
||||||
pub use query::{Aabb, EntityView, Query, QueryResult};
|
|
||||||
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub use convert::{
|
|
||||||
bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name,
|
|
||||||
entity_to_profile_curves, transform_entity_geometry,
|
|
||||||
};
|
|
||||||
#[cfg(feature = "doc_api_host")]
|
|
||||||
pub use transport::ipc::doc_api_for_host;
|
|
||||||
|
|
||||||
pub use transport::Transport;
|
|
||||||
|
|
||||||
/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`).
|
|
||||||
/// Bump when the envelope layout or `Operation`/`Query` discriminants change.
|
|
||||||
/// Bridges must check this before speaking (see `bindings/README.md`).
|
|
||||||
pub const ENVELOPE_VERSION: u16 = 1;
|
|
||||||
|
|
||||||
/// The generated binding handover schema: object model + method
|
|
||||||
/// signatures + op/query mapping with curated wire vocabulary. Single,
|
|
||||||
/// self-contained, versioned with [`ENVELOPE_VERSION`].
|
|
||||||
pub fn binding_schema_json() -> &'static str {
|
|
||||||
include_str!("gen/binding_schema.json")
|
|
||||||
}
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
//! Write operations — the typed, transport-agnostic vocabulary for changing the
|
|
||||||
//! document. The `Operation` enum is **hand-maintained** (append-only)
|
|
||||||
//! in `src/gen/ops_gen.rs` — it is NOT derived from the spec by build.rs; the
|
|
||||||
//! spec's `op` names must each map to a variant here (asserted by a test). This
|
|
||||||
//! module re-exports it and holds the hand-written payload mirrors.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub use crate::gen::Operation;
|
|
||||||
|
|
||||||
/// An XDATA record payload: one registered application name plus its typed
|
|
||||||
/// value list. This is a plain-data, serde-compatible mirror of
|
|
||||||
/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of
|
|
||||||
/// the host dependency.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct XDataRecord {
|
|
||||||
pub application_name: String,
|
|
||||||
pub values: Vec<XDataValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single XDATA value. Mirrors `acadrust::xdata::XDataValue`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum XDataValue {
|
|
||||||
String(String),
|
|
||||||
ControlString(String),
|
|
||||||
LayerName(String),
|
|
||||||
BinaryData(Vec<u8>),
|
|
||||||
Handle(u64),
|
|
||||||
Point3D([f64; 3]),
|
|
||||||
Position3D([f64; 3]),
|
|
||||||
Displacement3D([f64; 3]),
|
|
||||||
Direction3D([f64; 3]),
|
|
||||||
Real(f64),
|
|
||||||
Distance(f64),
|
|
||||||
ScaleFactor(f64),
|
|
||||||
Integer16(i16),
|
|
||||||
Integer32(i32),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for an `XRECORD` object. Mirrors the acadrust
|
|
||||||
/// `XRecord` payload (name + cloning flags + group-code entries) in a
|
|
||||||
/// serde-compatible, host-independent form.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct XRecordSpec {
|
|
||||||
pub name: String,
|
|
||||||
pub cloning_flags: XRecordCloningFlags,
|
|
||||||
pub entries: Vec<XRecordEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// XRecord cloning behavior flags. Mirror of
|
|
||||||
/// `acadrust::objects::DictionaryCloningFlags`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
||||||
pub enum XRecordCloningFlags {
|
|
||||||
#[default]
|
|
||||||
NotApplicable,
|
|
||||||
KeepExisting,
|
|
||||||
UseClone,
|
|
||||||
XrefName,
|
|
||||||
Name,
|
|
||||||
UnmangleName,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One XRECORD group-code entry.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct XRecordEntry {
|
|
||||||
pub code: i32,
|
|
||||||
pub value: XRecordValue,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single XRECORD value. Mirrors `acadrust::objects::XRecordValue`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum XRecordValue {
|
|
||||||
String(String),
|
|
||||||
Double(f64),
|
|
||||||
Int16(i16),
|
|
||||||
Int32(i32),
|
|
||||||
Int64(i64),
|
|
||||||
Byte(u8),
|
|
||||||
Bool(bool),
|
|
||||||
Handle(u64),
|
|
||||||
Point3D([f64; 3]),
|
|
||||||
Chunk(Vec<u8>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boolean operation kind for [`Operation::SolidBoolean`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum BoolOp {
|
|
||||||
Union,
|
|
||||||
Intersection,
|
|
||||||
Difference,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A plain-data color value. Mirrors `acadrust::types::Color` for wire
|
|
||||||
/// independence from the host dependency.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum Color {
|
|
||||||
ByLayer,
|
|
||||||
None,
|
|
||||||
ByBlock,
|
|
||||||
Index(u8),
|
|
||||||
Rgb { r: u8, g: u8, b: u8 },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Color {
|
|
||||||
pub const WHITE: Color = Color::Index(7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
||||||
pub enum LineWeight {
|
|
||||||
#[default]
|
|
||||||
ByLayer,
|
|
||||||
ByBlock,
|
|
||||||
Default,
|
|
||||||
/// Specific line weight in 1/100 mm.
|
|
||||||
Value(i16),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Layer visibility/lock flags.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
||||||
pub struct LayerFlags {
|
|
||||||
pub frozen: bool,
|
|
||||||
pub locked: bool,
|
|
||||||
pub frozen_in_new_viewport: bool,
|
|
||||||
pub off: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct LayerInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub flags: LayerFlags,
|
|
||||||
pub color: Color,
|
|
||||||
pub line_type: String,
|
|
||||||
pub line_weight: LineWeight,
|
|
||||||
pub plot_style: String,
|
|
||||||
pub is_plottable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LayerInfo {
|
|
||||||
pub fn new(name: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.into(),
|
|
||||||
flags: LayerFlags::default(),
|
|
||||||
color: Color::WHITE,
|
|
||||||
line_type: "Continuous".to_string(),
|
|
||||||
line_weight: LineWeight::Default,
|
|
||||||
plot_style: String::new(),
|
|
||||||
is_plottable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The normalized (trimmed + uppercased) lookup key for this layer name.
|
|
||||||
pub fn normalized_name(&self) -> String {
|
|
||||||
crate::layer::normalize_layer_name(&self.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a B-rep solid primitive (plain-data mirror of the
|
|
||||||
/// non-serde `cadkernel::brep::make::*` arguments).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum SolidPrimitive {
|
|
||||||
Cuboid {
|
|
||||||
origin: [f64; 3],
|
|
||||||
size: [f64; 3],
|
|
||||||
},
|
|
||||||
Sphere {
|
|
||||||
centre: [f64; 3],
|
|
||||||
radius: f64,
|
|
||||||
},
|
|
||||||
Cylinder {
|
|
||||||
base: [f64; 3],
|
|
||||||
radius: f64,
|
|
||||||
height: f64,
|
|
||||||
},
|
|
||||||
Cone {
|
|
||||||
base: [f64; 3],
|
|
||||||
radius: f64,
|
|
||||||
height: f64,
|
|
||||||
},
|
|
||||||
Torus {
|
|
||||||
centre: [f64; 3],
|
|
||||||
major_radius: f64,
|
|
||||||
minor_radius: f64,
|
|
||||||
},
|
|
||||||
Wedge {
|
|
||||||
origin: [f64; 3],
|
|
||||||
size: [f64; 3],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a minimal 2D curve entity (phase-1 set; profiles/inputs).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum Curve2Spec {
|
|
||||||
Line {
|
|
||||||
start: [f64; 3],
|
|
||||||
end: [f64; 3],
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
Circle {
|
|
||||||
centre: [f64; 3],
|
|
||||||
radius: f64,
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
Polyline {
|
|
||||||
points: Vec<[f64; 3]>,
|
|
||||||
closed: bool,
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
Point {
|
|
||||||
position: [f64; 3],
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
/// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians).
|
|
||||||
Arc {
|
|
||||||
centre: [f64; 3],
|
|
||||||
radius: f64,
|
|
||||||
start_angle: f64,
|
|
||||||
end_angle: f64,
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
/// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`;
|
|
||||||
/// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π).
|
|
||||||
Ellipse {
|
|
||||||
centre: [f64; 3],
|
|
||||||
major_axis: [f64; 3],
|
|
||||||
ratio: f64,
|
|
||||||
start: f64,
|
|
||||||
end: f64,
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
/// NURBS spline (control points + knots + weights; `degree` typically 3).
|
|
||||||
Spline {
|
|
||||||
degree: i32,
|
|
||||||
control_points: Vec<[f64; 3]>,
|
|
||||||
knots: Vec<f64>,
|
|
||||||
weights: Vec<f64>,
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
/// A ray from `origin` along `direction` (bounded at origin only).
|
|
||||||
Ray {
|
|
||||||
origin: [f64; 3],
|
|
||||||
direction: [f64; 3],
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
/// An infinite construction line through `origin` along `direction`.
|
|
||||||
XLine {
|
|
||||||
origin: [f64; 3],
|
|
||||||
direction: [f64; 3],
|
|
||||||
/// Target layer name. `None` places the entity on the current layer.
|
|
||||||
layer: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Curve2Spec {
|
|
||||||
/// The target layer name, if any.
|
|
||||||
pub fn layer(&self) -> Option<&str> {
|
|
||||||
match self {
|
|
||||||
Curve2Spec::Line { layer, .. }
|
|
||||||
| Curve2Spec::Circle { layer, .. }
|
|
||||||
| Curve2Spec::Polyline { layer, .. }
|
|
||||||
| Curve2Spec::Point { layer, .. }
|
|
||||||
| Curve2Spec::Arc { layer, .. }
|
|
||||||
| Curve2Spec::Ellipse { layer, .. }
|
|
||||||
| Curve2Spec::Spline { layer, .. }
|
|
||||||
| Curve2Spec::Ray { layer, .. }
|
|
||||||
| Curve2Spec::XLine { layer, .. } => layer.as_deref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A generic entity-construction payload used by the bulk op
|
|
||||||
/// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction
|
|
||||||
/// specs so one bulk op can carry many homogeneous (or mixed) creations.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum EntitySpec {
|
|
||||||
Curve(Curve2Spec),
|
|
||||||
Solid(SolidPrimitive),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EntitySpec {
|
|
||||||
/// The target layer name for this spec, if explicitly set.
|
|
||||||
pub fn layer(&self) -> Option<&str> {
|
|
||||||
match self {
|
|
||||||
EntitySpec::Curve(c) => c.layer(),
|
|
||||||
EntitySpec::Solid(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a block reference (`INSERT`): place the block
|
|
||||||
/// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform
|
|
||||||
/// `scale` and `rotation` (radians, about Z).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct InsertSpec {
|
|
||||||
pub block_name: String,
|
|
||||||
pub insert_point: [f64; 3],
|
|
||||||
pub scale: f64,
|
|
||||||
pub rotation: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a paper-space `VIEWPORT`: a `width`×`height` viewport
|
|
||||||
/// centered at `center` (paper space), looking at `view_target` (model space,
|
|
||||||
/// WCS) with `view_height` (model-space height visible; the viewport's zoom).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ViewportSpec {
|
|
||||||
pub center: [f64; 3],
|
|
||||||
pub width: f64,
|
|
||||||
pub height: f64,
|
|
||||||
pub view_target: [f64; 3],
|
|
||||||
pub view_height: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a single-line `TEXT` annotation.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct TextSpec {
|
|
||||||
pub value: String,
|
|
||||||
pub insertion_point: [f64; 3],
|
|
||||||
pub height: f64,
|
|
||||||
pub rotation: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a multi-line `MTEXT` annotation.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct MTextSpec {
|
|
||||||
pub value: String,
|
|
||||||
pub insertion_point: [f64; 3],
|
|
||||||
pub height: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a `HATCH` with a single closed polyline boundary
|
|
||||||
/// (straight segments). `solid` selects solid fill vs a pattern (pattern naming
|
|
||||||
/// is a later refinement).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct HatchSpec {
|
|
||||||
/// Closed boundary as an ordered polyline (first==last implied; closed).
|
|
||||||
pub boundary: Vec<[f64; 2]>,
|
|
||||||
pub solid: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a linear `DIMENSION`: the two measured points and the
|
|
||||||
/// dimension-line definition point.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct DimensionSpec {
|
|
||||||
pub first_point: [f64; 3],
|
|
||||||
pub second_point: [f64; 3],
|
|
||||||
pub definition_point: [f64; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a radial/diameter `DIMENSION`: the circle center and a
|
|
||||||
/// point on the circle (radius) or a chord point (diameter).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct DimensionRadialSpec {
|
|
||||||
/// Center of the arc/circle (radius) or first chord point (diameter).
|
|
||||||
pub center: [f64; 3],
|
|
||||||
/// A point on the arc/circle (radius) or the far chord point (diameter).
|
|
||||||
pub point: [f64; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a 3-point angular `DIMENSION`: vertex + one point on
|
|
||||||
/// each leg, plus the dimension-arc location.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct DimensionAngularSpec {
|
|
||||||
pub vertex: [f64; 3],
|
|
||||||
pub first_point: [f64; 3],
|
|
||||||
pub second_point: [f64; 3],
|
|
||||||
pub arc_location: [f64; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for an `ATTDEF` (attribute definition): the tag, prompt,
|
|
||||||
/// and default value for an in-block attribute, at an insertion point.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct AttributeDefinitionSpec {
|
|
||||||
pub tag: String,
|
|
||||||
pub prompt: String,
|
|
||||||
pub default_value: String,
|
|
||||||
pub insertion_point: [f64; 3],
|
|
||||||
pub height: f64,
|
|
||||||
pub rotation: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a `TABLE`: an insertion point plus a grid of cell
|
|
||||||
/// strings (`data[row][column]`).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct TableSpec {
|
|
||||||
pub insertion_point: [f64; 3],
|
|
||||||
/// Grid of cell text values, rows × columns.
|
|
||||||
pub data: Vec<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construction spec for a `RASTER_IMAGE`: an image file placed at
|
|
||||||
/// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and
|
|
||||||
/// pixel `size` (width, height). The host auto-registers the ImageDefinition.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct RasterImageSpec {
|
|
||||||
pub file_path: String,
|
|
||||||
pub insertion_point: [f64; 3],
|
|
||||||
pub u_vector: [f64; 3],
|
|
||||||
pub v_vector: [f64; 3],
|
|
||||||
/// Pixel size (width, height).
|
|
||||||
pub size: [f64; 2],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A rigid similarity (rotation + translation + optional uniform scale/reflection)
|
|
||||||
/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement`
|
|
||||||
/// (non-serde). Columns are the new basis axes; `origin` is the translation.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct PlacementSpec {
|
|
||||||
pub x_axis: [f64; 3],
|
|
||||||
pub y_axis: [f64; 3],
|
|
||||||
pub z_axis: [f64; 3],
|
|
||||||
pub origin: [f64; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlacementSpec {
|
|
||||||
/// Identity placement (no transform).
|
|
||||||
pub const IDENTITY: PlacementSpec = PlacementSpec {
|
|
||||||
x_axis: [1.0, 0.0, 0.0],
|
|
||||||
y_axis: [0.0, 1.0, 0.0],
|
|
||||||
z_axis: [0.0, 0.0, 1.0],
|
|
||||||
origin: [0.0, 0.0, 0.0],
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Pure translation to `origin` (axes unchanged).
|
|
||||||
pub fn at(origin: [f64; 3]) -> Self {
|
|
||||||
Self {
|
|
||||||
origin,
|
|
||||||
..Self::IDENTITY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The item-count cap for a single bulk op envelope: bounds
|
|
||||||
/// host-side execution time and payload. Over-cap → `ApiError::Validation`.
|
|
||||||
pub const BULK_ITEM_CAP: usize = 100_000;
|
|
||||||
|
|
||||||
/// Convenience accessors used by the facade/executor.
|
|
||||||
impl crate::gen::Operation {
|
|
||||||
/// The op name used in `ApiError::Validation { op, .. }`.
|
|
||||||
pub fn op_name(&self) -> &'static str {
|
|
||||||
use crate::gen::Operation::*;
|
|
||||||
match self {
|
|
||||||
CreateSolid(_) => "CreateSolid",
|
|
||||||
CreateCurve(_) => "CreateCurve",
|
|
||||||
Extrude { .. } => "Extrude",
|
|
||||||
Revolve { .. } => "Revolve",
|
|
||||||
Transform { .. } => "Transform",
|
|
||||||
SolidBoolean { .. } => "SolidBoolean",
|
|
||||||
AddVertex { .. } => "AddVertex",
|
|
||||||
Delete { .. } => "Delete",
|
|
||||||
CreateMany(_) => "CreateMany",
|
|
||||||
TransformMany { .. } => "TransformMany",
|
|
||||||
DeleteMany(_) => "DeleteMany",
|
|
||||||
CreateInsert(_) => "CreateInsert",
|
|
||||||
CreateViewport(_) => "CreateViewport",
|
|
||||||
CreateText(_) => "CreateText",
|
|
||||||
CreateMText(_) => "CreateMText",
|
|
||||||
SetTextContent { .. } => "SetTextContent",
|
|
||||||
CreateHatch(_) => "CreateHatch",
|
|
||||||
CreateDimensionLinear(_) => "CreateDimensionLinear",
|
|
||||||
SetAttribute { .. } => "SetAttribute",
|
|
||||||
SetViewportView { .. } => "SetViewportView",
|
|
||||||
CreateRasterImage(_) => "CreateRasterImage",
|
|
||||||
Loft { .. } => "Loft",
|
|
||||||
CreateDimensionRadius(_) => "CreateDimensionRadius",
|
|
||||||
CreateDimensionDiameter(_) => "CreateDimensionDiameter",
|
|
||||||
CreateDimensionAngular(_) => "CreateDimensionAngular",
|
|
||||||
CreateAttributeDefinition(_) => "CreateAttributeDefinition",
|
|
||||||
CreateTable(_) => "CreateTable",
|
|
||||||
CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln",
|
|
||||||
SetXData { .. } => "SetXData",
|
|
||||||
SetXDataMany(_) => "SetXDataMany",
|
|
||||||
CreateXRecord(_) => "CreateXRecord",
|
|
||||||
SetXRecord { .. } => "SetXRecord",
|
|
||||||
CreateLayer(_) => "CreateLayer",
|
|
||||||
UpdateLayer { .. } => "UpdateLayer",
|
|
||||||
DeleteLayer { .. } => "DeleteLayer",
|
|
||||||
SetEntityLayer { .. } => "SetEntityLayer",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
//! Read queries — typed, transport-agnostic reads of entity state.
|
|
||||||
//! The `Query` enum is **hand-maintained** (append-only) in `src/gen/query_gen.rs`
|
|
||||||
//! — NOT derived from the spec by build.rs (the spec's `query` names must map to
|
|
||||||
//! a variant here, asserted by a test). This module re-exports it and holds the
|
|
||||||
//! hand-written result DTOs.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::id::ObjectId;
|
|
||||||
use crate::revision::GeometryRevision;
|
|
||||||
|
|
||||||
pub use crate::gen::Query;
|
|
||||||
|
|
||||||
/// Axis-aligned bounding box (plain-data mirror of the kernel's non-serde `Aabb`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct Aabb {
|
|
||||||
pub min: [f64; 3],
|
|
||||||
pub max: [f64; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Aabb {
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub fn overlaps(&self, other: &Aabb) -> bool {
|
|
||||||
cadkernel::brep::Aabb {
|
|
||||||
min: self.min,
|
|
||||||
max: self.max,
|
|
||||||
}
|
|
||||||
.overlaps(&cadkernel::brep::Aabb {
|
|
||||||
min: other.min,
|
|
||||||
max: other.max,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A generic, untyped view of an entity returned by [`Query::GetEntity`].
|
|
||||||
/// Family-specific data lives in the typed construction specs; this carries
|
|
||||||
/// identity + kind + a coarse bounding box so any first-level entity is readable.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct EntityView {
|
|
||||||
pub id: ObjectId,
|
|
||||||
/// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line").
|
|
||||||
pub kind: String,
|
|
||||||
pub bounds: Option<Aabb>,
|
|
||||||
/// The normalized (uppercase) layer name the entity is on.
|
|
||||||
pub layer: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The result of a single [`Query`] (one per query in a `Queries` batch).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub enum QueryResult {
|
|
||||||
Entity(EntityView),
|
|
||||||
Bounds(Aabb),
|
|
||||||
Centroid([f64; 3]),
|
|
||||||
Volume(f64),
|
|
||||||
/// Whether two entities' bounds overlap (two-phase conditional modelling).
|
|
||||||
Intersects(bool),
|
|
||||||
Revision(GeometryRevision),
|
|
||||||
/// The text content of a Text/MText annotation.
|
|
||||||
TextContent(String),
|
|
||||||
/// A hatch's boundary loops (outer + islands), each a closed polyline.
|
|
||||||
HatchBoundary(Vec<Vec<[f64; 2]>>),
|
|
||||||
/// A dimension's measured value (distance for linear/radius, degrees for angular).
|
|
||||||
DimensionMeasurement(f64),
|
|
||||||
/// An insert's attributes as (tag, value) pairs.
|
|
||||||
Attributes(Vec<(String, String)>),
|
|
||||||
/// The entities inside a block definition (read-only traversal).
|
|
||||||
BlockEntities(Vec<EntityView>),
|
|
||||||
/// A viewport's view: target point (WCS) + zoom height.
|
|
||||||
ViewportView {
|
|
||||||
target: [f64; 3],
|
|
||||||
height: f64,
|
|
||||||
},
|
|
||||||
/// An XDATA record for a single application (`None` if no such record).
|
|
||||||
XData(Option<crate::ops::XDataRecord>),
|
|
||||||
/// An XRECORD object payload.
|
|
||||||
XRecord(crate::ops::XRecordSpec),
|
|
||||||
/// All layers in the document.
|
|
||||||
Layers(Vec<crate::ops::LayerInfo>),
|
|
||||||
/// The layer name of an entity.
|
|
||||||
EntityLayer(String),
|
|
||||||
/// A filtered list of entity views.
|
|
||||||
Entities(Vec<EntityView>),
|
|
||||||
/// The WCS location of a Point entity.
|
|
||||||
PointPosition([f64; 3]),
|
|
||||||
/// The start and end WCS points of a Line entity.
|
|
||||||
LineGeometry(([f64; 3], [f64; 3])),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience: the query name for diagnostics.
|
|
||||||
impl crate::gen::Query {
|
|
||||||
pub fn query_name(&self) -> &'static str {
|
|
||||||
use crate::gen::Query::*;
|
|
||||||
match self {
|
|
||||||
GetEntity { .. } => "GetEntity",
|
|
||||||
GetBounds { .. } => "GetBounds",
|
|
||||||
GetCentroid { .. } => "GetCentroid",
|
|
||||||
GetVolume { .. } => "GetVolume",
|
|
||||||
GetIntersects { .. } => "GetIntersects",
|
|
||||||
GetGeometryRevision => "GetGeometryRevision",
|
|
||||||
GetTextContent { .. } => "GetTextContent",
|
|
||||||
GetHatchBoundary { .. } => "GetHatchBoundary",
|
|
||||||
GetDimensionMeasurement { .. } => "GetDimensionMeasurement",
|
|
||||||
GetAttributes { .. } => "GetAttributes",
|
|
||||||
GetBlockEntities { .. } => "GetBlockEntities",
|
|
||||||
GetViewportView { .. } => "GetViewportView",
|
|
||||||
GetXData { .. } => "GetXData",
|
|
||||||
GetXRecord { .. } => "GetXRecord",
|
|
||||||
ListLayers => "ListLayers",
|
|
||||||
GetEntityLayer { .. } => "GetEntityLayer",
|
|
||||||
EnumerateEntities { .. } => "EnumerateEntities",
|
|
||||||
GetPointPosition { .. } => "GetPointPosition",
|
|
||||||
GetLineGeometry { .. } => "GetLineGeometry",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//! Geometry revision counter (mirrors the host scene's `geometry_epoch`).
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Monotonic revision of a document's geometry. Bumped exactly once per applied
|
|
||||||
/// write op (`push_undo` + `finalize_op`). Pure queries return the current
|
|
||||||
/// revision without bumping it.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct GeometryRevision(pub u64);
|
|
||||||
|
|
||||||
impl GeometryRevision {
|
|
||||||
pub const fn as_u64(self) -> u64 {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
//! In-process transport: drives a `DocApiBackend` directly, inline.
|
|
||||||
//! Used by in-tree/built-in plugins and host tests. Thread-safe via a mutex so
|
|
||||||
//! many handles across many threads share it (calls are serialized).
|
|
||||||
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::backend::DocApiBackend;
|
|
||||||
use crate::envelope::{DocApiEnvelope, EnvelopeBody, Receipt};
|
|
||||||
use crate::error::ApiResult;
|
|
||||||
use crate::executor;
|
|
||||||
use crate::transport::Transport;
|
|
||||||
|
|
||||||
/// In-process transport: holds the backend behind a mutex (reentrant `&self`).
|
|
||||||
pub struct InProcess<B: DocApiBackend> {
|
|
||||||
backend: Mutex<B>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<B: DocApiBackend> InProcess<B> {
|
|
||||||
pub fn new(backend: B) -> Self {
|
|
||||||
Self {
|
|
||||||
backend: Mutex::new(backend),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Borrow the backend immutably (test/introspection helper).
|
|
||||||
pub fn backend(&self) -> std::sync::MutexGuard<'_, B> {
|
|
||||||
self.backend.lock().expect("in-process backend poisoned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<B: DocApiBackend + Send> Transport for InProcess<B> {
|
|
||||||
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> {
|
|
||||||
req.validate_version()?;
|
|
||||||
let mut backend = self
|
|
||||||
.backend
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| crate::error::ApiError::Transport("in-process backend poisoned".into()))?;
|
|
||||||
match req.body {
|
|
||||||
EnvelopeBody::Op(op) => executor::apply_op(&mut *backend, op),
|
|
||||||
EnvelopeBody::Queries(queries) => executor::apply_queries(&mut *backend, queries),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
//! IPC transport: serializes `DocApiEnvelope` into the
|
|
||||||
//! append-only `PluginRequest::DocApiRequest { tab_id, bytes }` variant and ships
|
|
||||||
//! it through the plugin's `PluginRequestSender`. The host routes that variant to
|
|
||||||
//! the same crate executor (`ocs_doc_api::executor`) — one implementation.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use ocs_plugin_api::host::{HostApi, PluginRequestError, PluginRequestSender};
|
|
||||||
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
|
|
||||||
|
|
||||||
use crate::envelope::{DocApiEnvelope, Receipt};
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
|
||||||
use crate::facade::DocApi;
|
|
||||||
use crate::transport::Transport;
|
|
||||||
|
|
||||||
/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`;
|
|
||||||
/// the underlying `PluginRequestSender` already serializes + correlates (V4).
|
|
||||||
pub struct OcsPluginApiIpc {
|
|
||||||
sender: Arc<dyn PluginRequestSender>,
|
|
||||||
tab_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OcsPluginApiIpc {
|
|
||||||
pub fn new(sender: Arc<dyn PluginRequestSender>, tab_id: u64) -> Self {
|
|
||||||
Self { sender, tab_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transport_err(e: PluginRequestError) -> ApiError {
|
|
||||||
ApiError::Transport(e.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a typed [`DocApi`] from any host that can provide a
|
|
||||||
/// [`PluginRequestSender`] (out-of-process / V4 worker plugins).
|
|
||||||
///
|
|
||||||
/// Returns `None` for hosts that do not expose a request sender (e.g. in-process
|
|
||||||
/// hosts). In-process code should use [`crate::DocApi::in_process`] with a
|
|
||||||
/// concrete [`crate::backend::DocApiBackend`].
|
|
||||||
pub fn doc_api_for_host(host: &dyn HostApi) -> Option<DocApi> {
|
|
||||||
let sender = host.plugin_request_sender()?;
|
|
||||||
let tab_id = host.tab_id();
|
|
||||||
Some(DocApi::connect(
|
|
||||||
Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)),
|
|
||||||
tab_id,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Transport for OcsPluginApiIpc {
|
|
||||||
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> {
|
|
||||||
let bytes = bincode::serialize(&req)
|
|
||||||
.map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?;
|
|
||||||
let resp = self
|
|
||||||
.sender
|
|
||||||
.request(PluginRequest::DocApiRequest {
|
|
||||||
tab_id: self.tab_id,
|
|
||||||
bytes,
|
|
||||||
})
|
|
||||||
.map_err(Self::transport_err)?;
|
|
||||||
match resp {
|
|
||||||
PluginResponse::DocApiResponse { bytes } => {
|
|
||||||
// The host serializes `ApiResult<Receipt>` so op/query errors
|
|
||||||
// (validation, geometry, unknown id) surface as the SAME
|
|
||||||
// structured `ApiError` the in-process executor produced.
|
|
||||||
let result: ApiResult<Receipt> = bincode::deserialize(&bytes)
|
|
||||||
.map_err(|e| ApiError::Transport(format!("receipt deserialize: {e}")))?;
|
|
||||||
result
|
|
||||||
}
|
|
||||||
PluginResponse::Error(msg) => Err(ApiError::Transport(msg)),
|
|
||||||
other => Err(ApiError::Transport(format!(
|
|
||||||
"unexpected DocApi response variant: {other:?}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::facade::HasId;
|
|
||||||
use crate::ObjectId;
|
|
||||||
use ocs_plugin_api::host::{
|
|
||||||
CadDocument, EntityType, ExtendedDataRecord, Handle, HostApi,
|
|
||||||
};
|
|
||||||
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
|
|
||||||
use std::any::Any;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct MockSender {
|
|
||||||
captured: Arc<Mutex<Vec<(u64, PluginRequest)>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginRequestSender for MockSender {
|
|
||||||
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
|
|
||||||
let tab_id = match &req {
|
|
||||||
PluginRequest::DocApiRequest { tab_id, .. } => *tab_id,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
self.captured.lock().unwrap().push((tab_id, req));
|
|
||||||
Ok(PluginResponse::DocApiResponse {
|
|
||||||
bytes: bincode::serialize(&crate::ApiResult::Ok(Receipt {
|
|
||||||
outcome: Some(crate::envelope::OpOutcome::NewId(ObjectId::from_u64(7))),
|
|
||||||
query_results: vec![],
|
|
||||||
new_revision: crate::revision::GeometryRevision(1),
|
|
||||||
}))
|
|
||||||
.unwrap(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MockHost {
|
|
||||||
sender: Option<MockSender>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostApi for MockHost {
|
|
||||||
fn tab_index(&self) -> usize {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
fn document(&self) -> &CadDocument {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
fn document_mut(&mut self) -> &mut CadDocument {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
fn add_entity(&mut self, _entity: EntityType) -> Handle {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
fn bump_geometry(&mut self) {}
|
|
||||||
fn push_undo(&mut self, _label: &str) {}
|
|
||||||
fn set_dirty(&mut self) {}
|
|
||||||
fn push_info(&mut self, _msg: &str) {}
|
|
||||||
fn push_output(&mut self, _msg: &str) {}
|
|
||||||
fn push_error(&mut self, _msg: &str) {}
|
|
||||||
fn start_interactive(&mut self, _command: Box<dyn ocs_plugin_api::host::InteractiveCommand>) {}
|
|
||||||
fn read_record(&self, _handle: Handle, _app_name: &str) -> Option<&ExtendedDataRecord> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
fn write_record(&mut self, _handle: Handle, _record: ExtendedDataRecord) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
fn ensure_plugin_state_any(
|
|
||||||
&mut self,
|
|
||||||
_plugin_id: &'static str,
|
|
||||||
_init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
|
|
||||||
) -> &mut (dyn Any + Send + Sync) {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
fn document_reader(&self) -> Box<dyn ocs_plugin_api::host::DocumentReader + '_> {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
fn plugin_request_sender(&self) -> Option<Box<dyn PluginRequestSender>> {
|
|
||||||
self.sender.as_ref().map(|s| Box::new(s.clone()) as Box<dyn PluginRequestSender>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_api_for_host_builds_doc_api_and_routes_doc_api_request() {
|
|
||||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let host = MockHost {
|
|
||||||
sender: Some(MockSender {
|
|
||||||
captured: Arc::clone(&captured),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
let doc_api = doc_api_for_host(&host).expect("host exposes a sender");
|
|
||||||
let doc = doc_api.document(host.tab_id());
|
|
||||||
let id = doc
|
|
||||||
.solids()
|
|
||||||
.create_cuboid([0.0; 3], [1.0; 3])
|
|
||||||
.expect("mock returns a NewId receipt");
|
|
||||||
assert_eq!(id.id(), ObjectId::from_u64(7));
|
|
||||||
|
|
||||||
let calls = captured.lock().unwrap();
|
|
||||||
assert_eq!(calls.len(), 1);
|
|
||||||
let (tab_id, ref req) = &calls[0];
|
|
||||||
assert_eq!(*tab_id, host.tab_id());
|
|
||||||
match req {
|
|
||||||
PluginRequest::DocApiRequest { tab_id, bytes } => {
|
|
||||||
assert_eq!(*tab_id, host.tab_id());
|
|
||||||
assert!(!bytes.is_empty());
|
|
||||||
}
|
|
||||||
other => panic!("expected DocApiRequest, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn doc_api_for_host_returns_none_without_sender() {
|
|
||||||
let host = MockHost { sender: None };
|
|
||||||
assert!(doc_api_for_host(&host).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
//! The transport boundary — the ONLY plugin↔host boundary.
|
|
||||||
//!
|
|
||||||
//! Portability contract (SPSC / flume / shared-memory safe): a transport impl
|
|
||||||
//! must (a) accept `&self` calls from many threads and serialize sends internally,
|
|
||||||
//! (b) demultiplex responses to the right caller by a correlation id, (c)
|
|
||||||
//! block-with-timeout, (d) expose liveness. This matches the existing V4 channel
|
|
||||||
//! shape, so swapping socket → shm changes only the impl, never the facade.
|
|
||||||
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub mod inproc;
|
|
||||||
#[cfg(feature = "ipc")]
|
|
||||||
pub mod ipc;
|
|
||||||
|
|
||||||
#[cfg(feature = "host")]
|
|
||||||
pub use inproc::InProcess;
|
|
||||||
#[cfg(feature = "ipc")]
|
|
||||||
pub use ipc::OcsPluginApiIpc;
|
|
||||||
|
|
||||||
use crate::envelope::{DocApiEnvelope, Receipt};
|
|
||||||
use crate::error::ApiResult;
|
|
||||||
|
|
||||||
/// The only plugin↔host boundary. Object-centric: one method per unit of work —
|
|
||||||
/// a single write op, or a query batch. `&self` + `Send + Sync` so many entity
|
|
||||||
/// handles across many threads share one transport concurrently.
|
|
||||||
pub trait Transport: Send + Sync {
|
|
||||||
/// Apply `req` (ONE write op, or a query batch) and return its `Receipt`.
|
|
||||||
/// A write request carries exactly one `Operation`; a read request carries
|
|
||||||
/// one-or-more `Query` items (read-only batching is safe). Blocks up to an
|
|
||||||
/// internal timeout. Reentrant: safe to call concurrently from many threads.
|
|
||||||
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt>;
|
|
||||||
|
|
||||||
/// Cheap host-liveness probe (shm has no socket-disconnect to detect a dead peer).
|
|
||||||
fn alive(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
//! Reject invalid numeric inputs before the backend starts an undo operation.
|
|
||||||
use crate::{id::ObjectId, ops::*, ApiError, ApiResult};
|
|
||||||
|
|
||||||
fn finite(values: &[f64]) -> bool {
|
|
||||||
values.iter().all(|v| v.is_finite())
|
|
||||||
}
|
|
||||||
fn positive(value: f64) -> bool {
|
|
||||||
value.is_finite() && value > 0.0
|
|
||||||
}
|
|
||||||
fn direction(value: &[f64; 3]) -> bool {
|
|
||||||
finite(value) && value.iter().any(|v| *v != 0.0)
|
|
||||||
}
|
|
||||||
fn check(valid: bool, op: &'static str) -> ApiResult<()> {
|
|
||||||
if valid {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::validation(
|
|
||||||
op,
|
|
||||||
"invalid or nonfinite geometry input",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_payload(valid: bool, op: &'static str, reason: &'static str) -> ApiResult<()> {
|
|
||||||
if valid {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::validation(op, reason))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_application_name(name: &str) -> bool {
|
|
||||||
!name.trim().is_empty() && !name.contains('\0')
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_xdata_record(app: &str, record: Option<&XDataRecord>) -> bool {
|
|
||||||
valid_application_name(app)
|
|
||||||
&& record.is_none_or(|record| {
|
|
||||||
record.application_name == app
|
|
||||||
&& record.values.iter().all(|value| match value {
|
|
||||||
XDataValue::String(value)
|
|
||||||
| XDataValue::ControlString(value)
|
|
||||||
| XDataValue::LayerName(value) => !value.contains('\0'),
|
|
||||||
XDataValue::Point3D(value)
|
|
||||||
| XDataValue::Position3D(value)
|
|
||||||
| XDataValue::Displacement3D(value)
|
|
||||||
| XDataValue::Direction3D(value) => finite(value),
|
|
||||||
XDataValue::Real(value)
|
|
||||||
| XDataValue::Distance(value)
|
|
||||||
| XDataValue::ScaleFactor(value) => value.is_finite(),
|
|
||||||
XDataValue::BinaryData(_)
|
|
||||||
| XDataValue::Handle(_)
|
|
||||||
| XDataValue::Integer16(_)
|
|
||||||
| XDataValue::Integer32(_) => true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_xrecord(spec: &XRecordSpec) -> bool {
|
|
||||||
!spec.name.trim().is_empty()
|
|
||||||
&& !spec.name.contains('\0')
|
|
||||||
&& spec.entries.len() <= BULK_ITEM_CAP
|
|
||||||
&& spec.entries.iter().all(|entry| {
|
|
||||||
let code = entry.code;
|
|
||||||
match &entry.value {
|
|
||||||
XRecordValue::String(value) => {
|
|
||||||
matches!(
|
|
||||||
code,
|
|
||||||
0..=4
|
|
||||||
| 6..=9
|
|
||||||
| 100..=102
|
|
||||||
| 300..=309
|
|
||||||
| 410..=419
|
|
||||||
| 430..=439
|
|
||||||
| 470..=479
|
|
||||||
| 999
|
|
||||||
| 1000..=1003
|
|
||||||
) && !value.contains('\0')
|
|
||||||
&& value.encode_utf16().count() <= u16::MAX as usize
|
|
||||||
}
|
|
||||||
XRecordValue::Point3D(value) => {
|
|
||||||
matches!(code, 10..=37 | 110..=139 | 210..=269 | 1010..=1039 | 1043..=1069)
|
|
||||||
&& finite(value)
|
|
||||||
}
|
|
||||||
XRecordValue::Double(value) => {
|
|
||||||
matches!(code, 38..=59 | 140..=149 | 460..=469 | 1040..=1042)
|
|
||||||
&& value.is_finite()
|
|
||||||
}
|
|
||||||
XRecordValue::Int16(_) => {
|
|
||||||
matches!(code, 60..=79 | 170..=179 | 270..=279 | 370..=389 | 400..=409 | 1070)
|
|
||||||
}
|
|
||||||
XRecordValue::Int32(_) => matches!(code, 80..=99 | 420..=429 | 440..=459 | 1071),
|
|
||||||
XRecordValue::Int64(_) => matches!(code, 150..=169),
|
|
||||||
XRecordValue::Byte(_) => matches!(code, 280..=289),
|
|
||||||
XRecordValue::Bool(_) => matches!(code, 290..=299),
|
|
||||||
XRecordValue::Handle(_) => {
|
|
||||||
code < 0 || matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480..=481 | 1005)
|
|
||||||
}
|
|
||||||
XRecordValue::Chunk(value) => {
|
|
||||||
matches!(code, 310..=319 | 1004) && value.len() <= u8::MAX as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
|
|
||||||
use Curve2Spec::*;
|
|
||||||
let valid = match spec {
|
|
||||||
Line { start, end, .. } => finite(start) && finite(end) && start != end,
|
|
||||||
Circle { centre, radius, .. } => finite(centre) && positive(*radius),
|
|
||||||
Point { position, .. } => finite(position),
|
|
||||||
Arc {
|
|
||||||
centre,
|
|
||||||
radius,
|
|
||||||
start_angle,
|
|
||||||
end_angle,
|
|
||||||
..
|
|
||||||
} => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]),
|
|
||||||
Polyline { points, closed, .. } => {
|
|
||||||
points.len() >= if *closed { 3 } else { 2 }
|
|
||||||
&& points.len() <= BULK_ITEM_CAP
|
|
||||||
&& points.iter().all(|p| finite(p) && p[2] == points[0][2])
|
|
||||||
}
|
|
||||||
Ellipse {
|
|
||||||
centre,
|
|
||||||
major_axis,
|
|
||||||
ratio,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
finite(centre)
|
|
||||||
&& direction(major_axis)
|
|
||||||
&& positive(*ratio)
|
|
||||||
&& *ratio <= 1.0
|
|
||||||
&& finite(&[*start, *end])
|
|
||||||
}
|
|
||||||
Spline {
|
|
||||||
degree,
|
|
||||||
control_points,
|
|
||||||
knots,
|
|
||||||
weights,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
*degree > 0
|
|
||||||
&& control_points.len() <= BULK_ITEM_CAP
|
|
||||||
&& knots.len() <= BULK_ITEM_CAP
|
|
||||||
&& weights.len() <= BULK_ITEM_CAP
|
|
||||||
&& cadkernel::space::NurbsCurve3::new_strict(
|
|
||||||
*degree as usize,
|
|
||||||
control_points.clone(),
|
|
||||||
knots.clone(),
|
|
||||||
if weights.is_empty() {
|
|
||||||
vec![1.0; control_points.len()]
|
|
||||||
} else {
|
|
||||||
weights.clone()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
Ray {
|
|
||||||
origin,
|
|
||||||
direction: d,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
| XLine {
|
|
||||||
origin,
|
|
||||||
direction: d,
|
|
||||||
..
|
|
||||||
} => finite(origin) && direction(d),
|
|
||||||
};
|
|
||||||
check(valid, "CreateCurve")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn operation(op: &Operation) -> ApiResult<()> {
|
|
||||||
use Operation::*;
|
|
||||||
let valid = match op {
|
|
||||||
CreateCurve(spec) => return curve(spec),
|
|
||||||
Extrude { direction: d, .. } => direction(d),
|
|
||||||
Revolve {
|
|
||||||
axis: (pivot, d),
|
|
||||||
angle,
|
|
||||||
..
|
|
||||||
} => finite(pivot) && direction(d) && angle.is_finite() && *angle != 0.0,
|
|
||||||
AddVertex { point, .. } => finite(point),
|
|
||||||
CreateInsert(s) => {
|
|
||||||
finite(&s.insert_point)
|
|
||||||
&& s.scale.is_finite()
|
|
||||||
&& s.scale != 0.0
|
|
||||||
&& s.rotation.is_finite()
|
|
||||||
}
|
|
||||||
CreateViewport(s) => {
|
|
||||||
finite(&s.center)
|
|
||||||
&& finite(&s.view_target)
|
|
||||||
&& positive(s.width)
|
|
||||||
&& positive(s.height)
|
|
||||||
&& positive(s.view_height)
|
|
||||||
}
|
|
||||||
CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(),
|
|
||||||
CreateMText(s) => finite(&s.insertion_point) && positive(s.height),
|
|
||||||
SetXData {
|
|
||||||
id,
|
|
||||||
application_name,
|
|
||||||
record,
|
|
||||||
} => {
|
|
||||||
return check_payload(
|
|
||||||
*id != ObjectId::NULL && valid_xdata_record(application_name, record.as_ref()),
|
|
||||||
"SetXData",
|
|
||||||
"invalid XDATA record",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SetXDataMany(records) => {
|
|
||||||
return check_payload(
|
|
||||||
records.iter().all(|(id, app, record)| {
|
|
||||||
*id != ObjectId::NULL && valid_xdata_record(app, record.as_ref())
|
|
||||||
}),
|
|
||||||
"SetXDataMany",
|
|
||||||
"invalid XDATA record",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CreateXRecord(spec) => {
|
|
||||||
return check_payload(valid_xrecord(spec), "CreateXRecord", "invalid XRecord payload")
|
|
||||||
}
|
|
||||||
SetXRecord { id, spec } => {
|
|
||||||
return check_payload(
|
|
||||||
*id != ObjectId::NULL && valid_xrecord(spec),
|
|
||||||
"SetXRecord",
|
|
||||||
"invalid XRecord payload",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CreateHatch(s) => {
|
|
||||||
(3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p))
|
|
||||||
}
|
|
||||||
CreateDimensionLinear(s) => {
|
|
||||||
finite(&s.first_point) && finite(&s.second_point) && finite(&s.definition_point)
|
|
||||||
}
|
|
||||||
CreateDimensionRadius(s) | CreateDimensionDiameter(s) => {
|
|
||||||
finite(&s.center) && finite(&s.point)
|
|
||||||
}
|
|
||||||
CreateDimensionAngular(s) | CreateDimensionAngular2Ln(s) => {
|
|
||||||
finite(&s.vertex)
|
|
||||||
&& finite(&s.first_point)
|
|
||||||
&& finite(&s.second_point)
|
|
||||||
&& finite(&s.arc_location)
|
|
||||||
}
|
|
||||||
CreateAttributeDefinition(s) => {
|
|
||||||
finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite()
|
|
||||||
}
|
|
||||||
SetViewportView {
|
|
||||||
view_target,
|
|
||||||
view_height,
|
|
||||||
..
|
|
||||||
} => finite(view_target) && positive(*view_height),
|
|
||||||
CreateRasterImage(s) => {
|
|
||||||
finite(&s.insertion_point)
|
|
||||||
&& direction(&s.u_vector)
|
|
||||||
&& direction(&s.v_vector)
|
|
||||||
&& s.size.iter().all(|v| positive(*v))
|
|
||||||
}
|
|
||||||
CreateTable(s) => {
|
|
||||||
finite(&s.insertion_point)
|
|
||||||
&& !s.data.is_empty()
|
|
||||||
&& !s.data[0].is_empty()
|
|
||||||
&& s.data
|
|
||||||
.len()
|
|
||||||
.checked_mul(s.data[0].len())
|
|
||||||
.is_some_and(|n| n <= BULK_ITEM_CAP)
|
|
||||||
&& s.data.iter().all(|r| r.len() == s.data[0].len())
|
|
||||||
}
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
check(valid, op.op_name())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn solid(spec: &SolidPrimitive) -> ApiResult<()> {
|
|
||||||
use SolidPrimitive::*;
|
|
||||||
let valid = match spec {
|
|
||||||
Cuboid { origin, size } | Wedge { origin, size } => {
|
|
||||||
finite(origin) && size.iter().all(|v| positive(*v))
|
|
||||||
}
|
|
||||||
Sphere { centre, radius } => finite(centre) && positive(*radius),
|
|
||||||
Cylinder {
|
|
||||||
base,
|
|
||||||
radius,
|
|
||||||
height,
|
|
||||||
}
|
|
||||||
| Cone {
|
|
||||||
base,
|
|
||||||
radius,
|
|
||||||
height,
|
|
||||||
} => finite(base) && positive(*radius) && positive(*height),
|
|
||||||
Torus {
|
|
||||||
centre,
|
|
||||||
major_radius,
|
|
||||||
minor_radius,
|
|
||||||
} => finite(centre) && positive(*major_radius) && positive(*minor_radius),
|
|
||||||
};
|
|
||||||
check(valid, "CreateSolid")
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
//! Spec↔wire consistency tests (enforces the codegen contract the headers
|
|
||||||
//! promise): every `op`/`query` name in `spec/entities.toml` maps to an existing
|
|
||||||
//! `Operation`/`Query` variant, and the wire enums are append-only relative to a
|
|
||||||
//! recorded baseline (bincode discriminant stability).
|
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Spec {
|
|
||||||
#[serde(default)]
|
|
||||||
family: Vec<Family>,
|
|
||||||
}
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Family {
|
|
||||||
#[serde(default)]
|
|
||||||
handle: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
constructor: Vec<MethodRef>,
|
|
||||||
#[serde(default)]
|
|
||||||
method: Vec<MethodRef>,
|
|
||||||
}
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct MethodRef {
|
|
||||||
#[serde(default)]
|
|
||||||
op: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
query: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spec() -> Spec {
|
|
||||||
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/spec/entities.toml"))
|
|
||||||
.expect("read spec/entities.toml");
|
|
||||||
toml::from_str(&src).expect("parse spec/entities.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The variant names of the `Operation` enum, in declaration order.
|
|
||||||
fn operation_variants() -> Vec<String> {
|
|
||||||
use ocs_doc_api::ops::{
|
|
||||||
BoolOp, Curve2Spec, InsertSpec, LayerInfo, Operation, PlacementSpec, SolidPrimitive,
|
|
||||||
ViewportSpec,
|
|
||||||
};
|
|
||||||
use ocs_doc_api::ObjectId;
|
|
||||||
let _marker: Vec<Operation> = vec![
|
|
||||||
Operation::CreateSolid(SolidPrimitive::Sphere {
|
|
||||||
centre: [0.0; 3],
|
|
||||||
radius: 1.0,
|
|
||||||
}),
|
|
||||||
Operation::CreateCurve(Curve2Spec::Point {
|
|
||||||
position: [0.0; 3],
|
|
||||||
layer: None,
|
|
||||||
}),
|
|
||||||
Operation::Extrude {
|
|
||||||
profile: ObjectId::from_u64(0),
|
|
||||||
direction: [0.0; 3],
|
|
||||||
},
|
|
||||||
Operation::Revolve {
|
|
||||||
profile: ObjectId::from_u64(0),
|
|
||||||
axis: ([0.0; 3], [0.0; 3]),
|
|
||||||
angle: 0.0,
|
|
||||||
},
|
|
||||||
Operation::Transform {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
placement: PlacementSpec::IDENTITY,
|
|
||||||
},
|
|
||||||
Operation::SolidBoolean {
|
|
||||||
op: BoolOp::Union,
|
|
||||||
a: ObjectId::from_u64(0),
|
|
||||||
b: ObjectId::from_u64(0),
|
|
||||||
erase_sources: true,
|
|
||||||
},
|
|
||||||
Operation::AddVertex {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
at: 0,
|
|
||||||
point: [0.0; 3],
|
|
||||||
},
|
|
||||||
Operation::Delete {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Operation::CreateMany(vec![]),
|
|
||||||
Operation::TransformMany {
|
|
||||||
ids: vec![],
|
|
||||||
placement: PlacementSpec::IDENTITY,
|
|
||||||
},
|
|
||||||
Operation::DeleteMany(vec![]),
|
|
||||||
Operation::CreateInsert(InsertSpec {
|
|
||||||
block_name: String::new(),
|
|
||||||
insert_point: [0.0; 3],
|
|
||||||
scale: 1.0,
|
|
||||||
rotation: 0.0,
|
|
||||||
}),
|
|
||||||
Operation::CreateViewport(ViewportSpec {
|
|
||||||
center: [0.0; 3],
|
|
||||||
width: 0.0,
|
|
||||||
height: 0.0,
|
|
||||||
view_target: [0.0; 3],
|
|
||||||
view_height: 0.0,
|
|
||||||
}),
|
|
||||||
Operation::CreateText(ocs_doc_api::ops::TextSpec {
|
|
||||||
value: String::new(),
|
|
||||||
insertion_point: [0.0; 3],
|
|
||||||
height: 0.0,
|
|
||||||
rotation: 0.0,
|
|
||||||
}),
|
|
||||||
Operation::CreateMText(ocs_doc_api::ops::MTextSpec {
|
|
||||||
value: String::new(),
|
|
||||||
insertion_point: [0.0; 3],
|
|
||||||
height: 0.0,
|
|
||||||
}),
|
|
||||||
Operation::SetTextContent {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
value: String::new(),
|
|
||||||
},
|
|
||||||
Operation::CreateHatch(ocs_doc_api::ops::HatchSpec {
|
|
||||||
boundary: vec![],
|
|
||||||
solid: true,
|
|
||||||
}),
|
|
||||||
Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec {
|
|
||||||
first_point: [0.0; 3],
|
|
||||||
second_point: [0.0; 3],
|
|
||||||
definition_point: [0.0; 3],
|
|
||||||
}),
|
|
||||||
Operation::SetAttribute {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
tag: String::new(),
|
|
||||||
value: String::new(),
|
|
||||||
},
|
|
||||||
Operation::SetViewportView {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
view_target: [0.0; 3],
|
|
||||||
view_height: 0.0,
|
|
||||||
},
|
|
||||||
Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec {
|
|
||||||
file_path: String::new(),
|
|
||||||
insertion_point: [0.0; 3],
|
|
||||||
u_vector: [0.0; 3],
|
|
||||||
v_vector: [0.0; 3],
|
|
||||||
size: [0.0; 2],
|
|
||||||
}),
|
|
||||||
Operation::Loft { profiles: vec![] },
|
|
||||||
Operation::CreateDimensionRadius(ocs_doc_api::ops::DimensionRadialSpec {
|
|
||||||
center: [0.0; 3],
|
|
||||||
point: [0.0; 3],
|
|
||||||
}),
|
|
||||||
Operation::CreateDimensionDiameter(ocs_doc_api::ops::DimensionRadialSpec {
|
|
||||||
center: [0.0; 3],
|
|
||||||
point: [0.0; 3],
|
|
||||||
}),
|
|
||||||
Operation::CreateDimensionAngular(ocs_doc_api::ops::DimensionAngularSpec {
|
|
||||||
vertex: [0.0; 3],
|
|
||||||
first_point: [0.0; 3],
|
|
||||||
second_point: [0.0; 3],
|
|
||||||
arc_location: [0.0; 3],
|
|
||||||
}),
|
|
||||||
Operation::CreateAttributeDefinition(ocs_doc_api::ops::AttributeDefinitionSpec {
|
|
||||||
tag: String::new(),
|
|
||||||
prompt: String::new(),
|
|
||||||
default_value: String::new(),
|
|
||||||
insertion_point: [0.0; 3],
|
|
||||||
height: 0.0,
|
|
||||||
rotation: 0.0,
|
|
||||||
}),
|
|
||||||
Operation::CreateTable(ocs_doc_api::ops::TableSpec {
|
|
||||||
insertion_point: [0.0; 3],
|
|
||||||
data: vec![],
|
|
||||||
}),
|
|
||||||
Operation::CreateDimensionAngular2Ln(ocs_doc_api::ops::DimensionAngularSpec {
|
|
||||||
vertex: [0.0; 3],
|
|
||||||
first_point: [0.0; 3],
|
|
||||||
second_point: [0.0; 3],
|
|
||||||
arc_location: [0.0; 3],
|
|
||||||
}),
|
|
||||||
Operation::SetXData {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
application_name: String::new(),
|
|
||||||
record: None,
|
|
||||||
},
|
|
||||||
Operation::SetXDataMany(vec![]),
|
|
||||||
Operation::CreateXRecord(ocs_doc_api::ops::XRecordSpec {
|
|
||||||
name: String::new(),
|
|
||||||
cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable,
|
|
||||||
entries: vec![],
|
|
||||||
}),
|
|
||||||
Operation::SetXRecord {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
spec: ocs_doc_api::ops::XRecordSpec {
|
|
||||||
name: String::new(),
|
|
||||||
cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable,
|
|
||||||
entries: vec![],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Operation::CreateLayer(LayerInfo::new("LAYER")),
|
|
||||||
Operation::UpdateLayer {
|
|
||||||
name: String::new(),
|
|
||||||
info: LayerInfo::new("LAYER"),
|
|
||||||
},
|
|
||||||
Operation::DeleteLayer {
|
|
||||||
name: String::new(),
|
|
||||||
},
|
|
||||||
Operation::SetEntityLayer {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
layer: String::new(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
_marker
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, op)| {
|
|
||||||
let wire = bincode::serialize(op).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize,
|
|
||||||
index
|
|
||||||
);
|
|
||||||
let dbg = format!("{:?}", op);
|
|
||||||
dbg.split(['(', ' ', '{'])
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The variant names of the `Query` enum, in declaration order.
|
|
||||||
fn query_variants() -> Vec<String> {
|
|
||||||
use ocs_doc_api::query::Query;
|
|
||||||
use ocs_doc_api::ObjectId;
|
|
||||||
let marker: Vec<Query> = vec![
|
|
||||||
Query::GetEntity {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetBounds {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetCentroid {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetVolume {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetIntersects {
|
|
||||||
a: ObjectId::from_u64(0),
|
|
||||||
b: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetGeometryRevision,
|
|
||||||
Query::GetTextContent {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetHatchBoundary {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetDimensionMeasurement {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetAttributes {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetBlockEntities {
|
|
||||||
block_name: String::new(),
|
|
||||||
},
|
|
||||||
Query::GetViewportView {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::GetXData {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
application_name: String::new(),
|
|
||||||
},
|
|
||||||
Query::GetXRecord {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
Query::ListLayers,
|
|
||||||
Query::GetEntityLayer {
|
|
||||||
id: ObjectId::from_u64(0),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
marker
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, q)| {
|
|
||||||
let wire = bincode::serialize(q).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize,
|
|
||||||
index
|
|
||||||
);
|
|
||||||
let dbg = format!("{:?}", q);
|
|
||||||
dbg.split(['(', ' ', '{'])
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_spec_op_and_query_maps_to_an_enum_variant() {
|
|
||||||
let spec = spec();
|
|
||||||
let op_names: BTreeSet<String> = operation_variants().into_iter().collect();
|
|
||||||
let query_names: BTreeSet<String> = query_variants().into_iter().collect();
|
|
||||||
|
|
||||||
let mut missing = Vec::new();
|
|
||||||
for fam in &spec.family {
|
|
||||||
for m in fam.constructor.iter().chain(fam.method.iter()) {
|
|
||||||
if let Some(op) = &m.op {
|
|
||||||
if !op_names.contains(op) {
|
|
||||||
missing.push(format!(
|
|
||||||
"Operation::{op} (family {})",
|
|
||||||
fam.handle.as_deref().unwrap_or("?")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(q) = &m.query {
|
|
||||||
if !query_names.contains(q) {
|
|
||||||
missing.push(format!(
|
|
||||||
"Query::{q} (family {})",
|
|
||||||
fam.handle.as_deref().unwrap_or("?")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
missing.is_empty(),
|
|
||||||
"spec references ops/queries with no enum variant: {missing:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recorded baseline of the `Operation` variant order (append-only contract).
|
|
||||||
/// New variants must be APPENDED after these; none may be removed or reordered.
|
|
||||||
const OPERATION_BASELINE: &[&str] = &[
|
|
||||||
"CreateSolid",
|
|
||||||
"CreateCurve",
|
|
||||||
"Extrude",
|
|
||||||
"Revolve",
|
|
||||||
"Transform",
|
|
||||||
"SolidBoolean",
|
|
||||||
"AddVertex",
|
|
||||||
"Delete",
|
|
||||||
"CreateMany",
|
|
||||||
"TransformMany",
|
|
||||||
"DeleteMany",
|
|
||||||
"CreateInsert",
|
|
||||||
"CreateViewport",
|
|
||||||
"CreateText",
|
|
||||||
"CreateMText",
|
|
||||||
"SetTextContent",
|
|
||||||
"CreateHatch",
|
|
||||||
"CreateDimensionLinear",
|
|
||||||
"SetAttribute",
|
|
||||||
"SetViewportView",
|
|
||||||
"CreateRasterImage",
|
|
||||||
"Loft",
|
|
||||||
"CreateDimensionRadius",
|
|
||||||
"CreateDimensionDiameter",
|
|
||||||
"CreateDimensionAngular",
|
|
||||||
"CreateAttributeDefinition",
|
|
||||||
"CreateTable",
|
|
||||||
"CreateDimensionAngular2Ln",
|
|
||||||
"SetXData",
|
|
||||||
"SetXDataMany",
|
|
||||||
"CreateXRecord",
|
|
||||||
"SetXRecord",
|
|
||||||
"CreateLayer",
|
|
||||||
"UpdateLayer",
|
|
||||||
"DeleteLayer",
|
|
||||||
"SetEntityLayer",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Recorded baseline of the `Query` variant order.
|
|
||||||
const QUERY_BASELINE: &[&str] = &[
|
|
||||||
"GetEntity",
|
|
||||||
"GetBounds",
|
|
||||||
"GetCentroid",
|
|
||||||
"GetVolume",
|
|
||||||
"GetIntersects",
|
|
||||||
"GetGeometryRevision",
|
|
||||||
"GetTextContent",
|
|
||||||
"GetHatchBoundary",
|
|
||||||
"GetDimensionMeasurement",
|
|
||||||
"GetAttributes",
|
|
||||||
"GetBlockEntities",
|
|
||||||
"GetViewportView",
|
|
||||||
"GetXData",
|
|
||||||
"GetXRecord",
|
|
||||||
"ListLayers",
|
|
||||||
"GetEntityLayer",
|
|
||||||
];
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn operation_enum_is_append_only_vs_baseline() {
|
|
||||||
let current = operation_variants();
|
|
||||||
assert!(
|
|
||||||
current.len() >= OPERATION_BASELINE.len(),
|
|
||||||
"Operation enum shrank: {current:?} < baseline {OPERATION_BASELINE:?}"
|
|
||||||
);
|
|
||||||
for (i, name) in OPERATION_BASELINE.iter().enumerate() {
|
|
||||||
assert_eq!(
|
|
||||||
current[i], *name,
|
|
||||||
"Operation variant {i} changed (append-only violation): {current:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn query_enum_is_append_only_vs_baseline() {
|
|
||||||
let current = query_variants();
|
|
||||||
assert!(
|
|
||||||
current.len() >= QUERY_BASELINE.len(),
|
|
||||||
"Query enum shrank: {current:?} < baseline {QUERY_BASELINE:?}"
|
|
||||||
);
|
|
||||||
for (i, name) in QUERY_BASELINE.iter().enumerate() {
|
|
||||||
assert_eq!(
|
|
||||||
current[i], *name,
|
|
||||||
"Query variant {i} changed (append-only violation): {current:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) {
|
|||||||
"acadrust_version": acadrust.version.to_string(),
|
"acadrust_version": acadrust.version.to_string(),
|
||||||
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
|
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
|
||||||
"rustc_version": rustc_version,
|
"rustc_version": rustc_version,
|
||||||
"api_version": 6,
|
"api_version": 5,
|
||||||
"api_version_min_supported": 2,
|
"api_version_min_supported": 2,
|
||||||
"build_timestamp": build_timestamp,
|
"build_timestamp": build_timestamp,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -552,18 +552,6 @@ pub trait HostApi {
|
|||||||
let _ = tab_id;
|
let _ = tab_id;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DocApi v2 (`ocs_doc_api`): dispatch one bincode-serialized `DocApiEnvelope`
|
|
||||||
/// (one write op OR a read-only query batch) and return the bincode-serialized
|
|
||||||
/// `Result<Receipt, ApiError>`. Opaque bytes:
|
|
||||||
/// this crate does not depend on `ocs_doc_api`, it only routes the envelope.
|
|
||||||
///
|
|
||||||
/// Added for the DocApi v2 adapter. The default returns `Err` ("not supported");
|
|
||||||
/// the in-process host (`HostSession`) overrides it to call the DocApi executor.
|
|
||||||
fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result<Vec<u8>, String> {
|
|
||||||
let _ = (tab_id, bytes);
|
|
||||||
Err("DocApi v2 not supported by this host".to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
||||||
|
|||||||
@@ -138,12 +138,6 @@ pub enum PluginRequest {
|
|||||||
GetTabId,
|
GetTabId,
|
||||||
/// V5: ask the host for the filesystem path of the document in `tab_id`.
|
/// V5: ask the host for the filesystem path of the document in `tab_id`.
|
||||||
DocumentPath { tab_id: u64 },
|
DocumentPath { tab_id: u64 },
|
||||||
/// DocApi v2 (`ocs_doc_api`): carry one bincode-serialized `DocApiEnvelope`
|
|
||||||
/// (one write op OR a read-only query batch). Appended at the end to keep
|
|
||||||
/// existing discriminants stable. The host routes this to the DocApi executor.
|
|
||||||
/// `serde_bytes` keeps the payload a single length-prefixed bulk copy (the
|
|
||||||
/// outer frame would otherwise walk it byte-by-byte).
|
|
||||||
DocApiRequest { tab_id: u64, #[serde(with = "serde_bytes")] bytes: Vec<u8> },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Responses the host sends back for `PluginRequest`.
|
/// Responses the host sends back for `PluginRequest`.
|
||||||
@@ -170,10 +164,6 @@ pub enum PluginResponse {
|
|||||||
TabId(u64),
|
TabId(u64),
|
||||||
/// V5: filesystem path of the document in the requested tab, if any.
|
/// V5: filesystem path of the document in the requested tab, if any.
|
||||||
DocumentPath(Option<std::ffi::OsString>),
|
DocumentPath(Option<std::ffi::OsString>),
|
||||||
/// DocApi v2 (`ocs_doc_api`): the bincode-serialized `Result<Receipt, ApiError>` answering a
|
|
||||||
/// `PluginRequest::DocApiRequest`. Appended at the end (discriminant stability).
|
|
||||||
/// `serde_bytes` keeps the payload a single length-prefixed bulk copy.
|
|
||||||
DocApiResponse { #[serde(with = "serde_bytes")] bytes: Vec<u8> },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages sent from the host to the plugin runner.
|
/// Messages sent from the host to the plugin runner.
|
||||||
|
|||||||
@@ -77,9 +77,5 @@ pub fn handle_plugin_request(
|
|||||||
DocumentPath { tab_id } => PluginResponse::DocumentPath(
|
DocumentPath { tab_id } => PluginResponse::DocumentPath(
|
||||||
host.document_path(tab_id).map(|path| path.into_os_string()),
|
host.document_path(tab_id).map(|path| path.into_os_string()),
|
||||||
),
|
),
|
||||||
DocApiRequest { tab_id, bytes } => match host.doc_api_dispatch(tab_id, &bytes) {
|
|
||||||
Ok(bytes) => PluginResponse::DocApiResponse { bytes },
|
|
||||||
Err(msg) => PluginResponse::Error(msg),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,15 +18,9 @@
|
|||||||
/// plugins and appends `document_reader` / `document_view` at the end of the
|
/// plugins and appends `document_reader` / `document_view` at the end of the
|
||||||
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
|
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
|
||||||
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
|
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
|
||||||
/// adds the `BuiltinPlugin::on_load` lifecycle callback.
|
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
|
||||||
/// Host ABI major version. Bumped to 6 when the DocApi v2 surface
|
/// `HostApi::document_path`.
|
||||||
/// (`PluginRequest::DocApiRequest` / `PluginResponse::DocApiResponse` +
|
pub const API_VERSION: u32 = 5;
|
||||||
/// `HostApi::doc_api_dispatch`) was added: plugins that call DocApi require an
|
|
||||||
/// API-6 host and are rejected at handshake by older (API ≤ 5) hosts, instead of
|
|
||||||
/// failing at runtime with a bincode-decode transport error. The DocApi wire
|
|
||||||
/// changes are append-only and `doc_api_dispatch` is a defaulted method, so
|
|
||||||
/// older V2–V5 plugins still load unchanged on an API-6 host.
|
|
||||||
pub const API_VERSION: u32 = 6;
|
|
||||||
|
|
||||||
/// Oldest plugin API major the current host still loads. This keeps previously
|
/// Oldest plugin API major the current host still loads. This keeps previously
|
||||||
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
|
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
|
||||||
|
|||||||
@@ -323,6 +323,8 @@ impl OpenCADStudio {
|
|||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
self.tabs[i].scene.clear();
|
self.tabs[i].scene.clear();
|
||||||
self.tabs[i].scene.document = doc;
|
self.tabs[i].scene.document = doc;
|
||||||
|
self.tabs[i].scene.load_sketch_constraints_from_document();
|
||||||
|
self.tabs[i].scene.load_named_parameters_from_document();
|
||||||
self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from);
|
self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from);
|
||||||
crate::app::style_ops::ensure_standard_styles(
|
crate::app::style_ops::ensure_standard_styles(
|
||||||
&mut self.tabs[i].scene.document,
|
&mut self.tabs[i].scene.document,
|
||||||
@@ -1610,4 +1612,3 @@ mod tests {
|
|||||||
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,42 +654,13 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NCOPY — copy the nested objects of the selected block reference(s)
|
|
||||||
// into model space, keeping the block (a non-destructive extraction;
|
|
||||||
// every nested object is copied — the block is not exploded).
|
|
||||||
"NCOPY" | "NCOPYALL" => {
|
"NCOPY" | "NCOPYALL" => {
|
||||||
use crate::modules::draw::modify::explode::explode_entity;
|
let command = crate::modules::draw::modify::ncopy::NcopyCommand::new(
|
||||||
let inserts: Vec<acadrust::Handle> = self.tabs[i]
|
&self.tabs[i].scene.document, self.ncopy_bind,
|
||||||
.scene
|
);
|
||||||
.selected_entities()
|
self.command_line.push_info(&command.prompt());
|
||||||
.iter()
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||||
.filter(|(_, e)| matches!(e, acadrust::EntityType::Insert(_)))
|
|
||||||
.map(|(h, _)| *h)
|
|
||||||
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
|
|
||||||
.collect();
|
|
||||||
if inserts.is_empty() {
|
|
||||||
self.command_line
|
|
||||||
.push_error(crate::t!("NCOPY: select a block reference first.").as_ref());
|
|
||||||
return Some(Task::none());
|
|
||||||
}
|
|
||||||
self.push_undo_snapshot(i, "NCOPY");
|
|
||||||
let mut n = 0usize;
|
|
||||||
for h in &inserts {
|
|
||||||
let nested = match self.tabs[i].scene.document.get_entity(*h).cloned() {
|
|
||||||
Some(ins) => explode_entity(&ins, &self.tabs[i].scene.document),
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
for e in nested {
|
|
||||||
self.tabs[i].scene.add_entity(e);
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.tabs[i].dirty = true;
|
|
||||||
self.command_line.push_output(crate::tf!(
|
|
||||||
"NCOPY: copied {n} nested object(s) into model space (blocks kept)."
|
|
||||||
).as_ref());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
Some(self.finish_dispatch(cmd))
|
Some(self.finish_dispatch(cmd))
|
||||||
@@ -706,6 +677,19 @@ mod tests {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ncopy_starts_nested_object_selection() {
|
||||||
|
let mut app = fresh_app();
|
||||||
|
let _ = app.run_command_line("NCOPY");
|
||||||
|
assert_eq!(
|
||||||
|
app.tabs[app.active_tab]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.map(|active| active.name()),
|
||||||
|
Some("NCOPY")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Run one command line and return only the command-line text it appended.
|
/// Run one command line and return only the command-line text it appended.
|
||||||
fn run_capture(app: &mut OpenCADStudio, cmd: &str) -> String {
|
fn run_capture(app: &mut OpenCADStudio, cmd: &str) -> String {
|
||||||
let start = app.command_line.history.len();
|
let start = app.command_line.history.len();
|
||||||
|
|||||||
@@ -596,9 +596,23 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
"REVERSE" => {
|
"REVERSE" => {
|
||||||
use crate::modules::draw::modify::reverse::ReverseCommand;
|
use crate::modules::draw::modify::reverse::ReverseCommand;
|
||||||
let new_cmd = ReverseCommand::new();
|
if self.tabs[i].scene.selected.is_empty() {
|
||||||
self.command_line.push_info(&new_cmd.prompt());
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
let selection = SelectObjectsCommand::new("REVERSE");
|
||||||
|
self.command_line.push_info(&selection.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(selection));
|
||||||
|
} else {
|
||||||
|
let replacements = self.tabs[i].scene.selected_entities().into_iter()
|
||||||
|
.filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle))
|
||||||
|
.filter_map(|(handle, entity)| ReverseCommand::reversed(entity)
|
||||||
|
.map(|reversed| (handle, vec![reversed])))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !replacements.is_empty() {
|
||||||
|
return Some(self.apply_cmd_result(crate::command::CmdResult::ReplaceMany(
|
||||||
|
replacements, Vec::new(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"MEASUREGEOM" => {
|
"MEASUREGEOM" => {
|
||||||
@@ -881,7 +895,7 @@ impl OpenCADStudio {
|
|||||||
SplineCommand::control_vertices()
|
SplineCommand::control_vertices()
|
||||||
} else {
|
} else {
|
||||||
SplineCommand::new()
|
SplineCommand::new()
|
||||||
};
|
}.with_document(&self.tabs[i].scene.document);
|
||||||
self.command_line.push_info(&new_cmd.prompt());
|
self.command_line.push_info(&new_cmd.prompt());
|
||||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||||
}
|
}
|
||||||
@@ -963,6 +977,193 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Persistent constraints ────────────────────────────────────
|
||||||
|
"HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => {
|
||||||
|
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||||
|
if handles.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let sel = SelectObjectsCommand::new(cmd);
|
||||||
|
self.command_line.push_info(&sel.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||||
|
} else if handles.len() != 1 {
|
||||||
|
self.command_line
|
||||||
|
.push_output("Select exactly one entity, then run this constraint again.");
|
||||||
|
} else {
|
||||||
|
use crate::command::CmdResult;
|
||||||
|
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||||
|
let (kind, label) = match cmd {
|
||||||
|
"HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"),
|
||||||
|
"VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"),
|
||||||
|
_ => (ConstraintKind::Fixed, "Fixed constraint"),
|
||||||
|
};
|
||||||
|
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||||
|
kind,
|
||||||
|
refs: vec![SketchRef::whole(handles[0])],
|
||||||
|
driving_param: None,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"CCONSTRAINT" => {
|
||||||
|
use crate::modules::draw::constrain::CoincidentConstraintCommand;
|
||||||
|
let new_cmd = CoincidentConstraintCommand::new();
|
||||||
|
self.command_line.push_info(&new_cmd.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
"EDCONSTRAINT" => {
|
||||||
|
use crate::modules::draw::constrain::EqualDistanceConstraintCommand;
|
||||||
|
let new_cmd = EqualDistanceConstraintCommand::new();
|
||||||
|
self.command_line.push_info(&new_cmd.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
"PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => {
|
||||||
|
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||||
|
if handles.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let sel = SelectObjectsCommand::new(cmd);
|
||||||
|
self.command_line.push_info(&sel.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||||
|
} else if handles.len() != 2 {
|
||||||
|
self.command_line.push_output(
|
||||||
|
"Select exactly two entities (first = reference, second = the one that moves), then run this constraint again.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
use crate::command::CmdResult;
|
||||||
|
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||||
|
let (kind, label) = match cmd {
|
||||||
|
"PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"),
|
||||||
|
"QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"),
|
||||||
|
"TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"),
|
||||||
|
"LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"),
|
||||||
|
"NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"),
|
||||||
|
_ => (ConstraintKind::Equal, "Equal constraint"),
|
||||||
|
};
|
||||||
|
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||||
|
kind,
|
||||||
|
refs: vec![SketchRef::whole(handles[0]), SketchRef::whole(handles[1])],
|
||||||
|
driving_param: None,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"NCONSTRAINT" => {
|
||||||
|
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||||
|
if handles.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let sel = SelectObjectsCommand::new(cmd);
|
||||||
|
self.command_line.push_info(&sel.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||||
|
} else if handles.len() != 2 {
|
||||||
|
self.command_line.push_output("Select exactly two circles/arcs, then run this constraint again.");
|
||||||
|
} else {
|
||||||
|
use crate::command::CmdResult;
|
||||||
|
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||||
|
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||||
|
kind: ConstraintKind::Concentric,
|
||||||
|
refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1])],
|
||||||
|
driving_param: None,
|
||||||
|
label: "Concentric constraint",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"SYCONSTRAINT" => {
|
||||||
|
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||||
|
if handles.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let sel = SelectObjectsCommand::new(cmd);
|
||||||
|
self.command_line.push_info(&sel.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||||
|
} else if handles.len() != 3 {
|
||||||
|
self.command_line.push_output(
|
||||||
|
"Select exactly three entities (two circles/arcs, then the mirror line), then run this constraint again.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
use crate::command::CmdResult;
|
||||||
|
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||||
|
return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint {
|
||||||
|
kind: ConstraintKind::Symmetric,
|
||||||
|
refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1]), SketchRef::whole(handles[2])],
|
||||||
|
driving_param: None,
|
||||||
|
label: "Symmetric constraint",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"CPCONSTRAINT" | "MPCONSTRAINT" | "OCCONSTRAINT" => {
|
||||||
|
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||||
|
if handles.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let sel = SelectObjectsCommand::new(cmd);
|
||||||
|
self.command_line.push_info(&sel.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||||
|
} else if handles.len() != 1 {
|
||||||
|
self.command_line.push_output(
|
||||||
|
"Select exactly one entity (the circle or line the point should attach to), then run this constraint again.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
use crate::modules::draw::constrain::PointOnEntityConstraintCommand;
|
||||||
|
use crate::scene::sketch_constraints::ConstraintKind;
|
||||||
|
let (name, kind, label) = match cmd {
|
||||||
|
"CPCONSTRAINT" => ("CPCONSTRAINT", ConstraintKind::CenterPoint, "Center point constraint"),
|
||||||
|
"MPCONSTRAINT" => ("MPCONSTRAINT", ConstraintKind::Midpoint, "Midpoint constraint"),
|
||||||
|
_ => ("OCCONSTRAINT", ConstraintKind::PointOnCurve, "Point on curve constraint"),
|
||||||
|
};
|
||||||
|
let new_cmd = PointOnEntityConstraintCommand::new(name, kind, handles[0], label);
|
||||||
|
self.command_line.push_info(&new_cmd.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"DCONSTRAINT" => {
|
||||||
|
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||||
|
if handles.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let sel = SelectObjectsCommand::new(cmd);
|
||||||
|
self.command_line.push_info(&sel.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||||
|
} else if handles.len() != 1 {
|
||||||
|
self.command_line
|
||||||
|
.push_output("Select exactly one line or circle, then run this constraint again.");
|
||||||
|
} else {
|
||||||
|
use crate::modules::draw::constrain::DistanceConstraintCommand;
|
||||||
|
match DistanceConstraintCommand::new(&self.tabs[i].scene, handles[0]) {
|
||||||
|
Some(new_cmd) => {
|
||||||
|
self.command_line.push_info(&new_cmd.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||||
|
}
|
||||||
|
None => self.command_line.push_output("Select a line or a circle for a distance constraint."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"ACONSTRAINT" => {
|
||||||
|
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||||
|
if handles.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let sel = SelectObjectsCommand::new(cmd);
|
||||||
|
self.command_line.push_info(&sel.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(sel));
|
||||||
|
} else if handles.len() != 2 {
|
||||||
|
self.command_line.push_output(
|
||||||
|
"Select exactly two lines (first = reference, second = the one that rotates), then run this constraint again.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
use crate::modules::draw::constrain::AngleConstraintCommand;
|
||||||
|
match AngleConstraintCommand::new(&self.tabs[i].scene, handles[0], handles[1]) {
|
||||||
|
Some(new_cmd) => {
|
||||||
|
self.command_line.push_info(&new_cmd.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||||
|
}
|
||||||
|
None => self.command_line.push_output("Select two lines for an angle constraint."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Model commands (3D primitives) ─────────────────────────────
|
// ── Model commands (3D primitives) ─────────────────────────────
|
||||||
"CYLINDER" => {
|
"CYLINDER" => {
|
||||||
use crate::modules::model::cylinder_cmd::CylinderCommand;
|
use crate::modules::model::cylinder_cmd::CylinderCommand;
|
||||||
@@ -1114,8 +1315,7 @@ impl OpenCADStudio {
|
|||||||
return Some(self.fit_spline());
|
return Some(self.fit_spline());
|
||||||
}
|
}
|
||||||
|
|
||||||
// REGION — convert selected closed boundaries (closed polylines /
|
// REGION — convert exact closed planar profiles into regions.
|
||||||
// circles) into Region entities (one wire loop each).
|
|
||||||
"REGION" | "REG" => {
|
"REGION" | "REG" => {
|
||||||
if self.tabs[i].scene.selected_entities().is_empty() {
|
if self.tabs[i].scene.selected_entities().is_empty() {
|
||||||
use crate::modules::draw::select::SelectObjectsCommand;
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
@@ -1129,12 +1329,7 @@ impl OpenCADStudio {
|
|||||||
let mut regions = Vec::new();
|
let mut regions = Vec::new();
|
||||||
let mut sources = Vec::new();
|
let mut sources = Vec::new();
|
||||||
for (handle, e) in self.tabs[i].scene.selected_entities().iter() {
|
for (handle, e) in self.tabs[i].scene.selected_entities().iter() {
|
||||||
let supported = matches!(
|
if !matches!(e, acadrust::EntityType::Region(_)) {
|
||||||
e,
|
|
||||||
acadrust::EntityType::LwPolyline(pl)
|
|
||||||
if pl.is_closed && pl.vertices.len() >= 3
|
|
||||||
) || matches!(e, acadrust::EntityType::Circle(_));
|
|
||||||
if supported {
|
|
||||||
let Some((plane, loops, true)) =
|
let Some((plane, loops, true)) =
|
||||||
crate::scene::model::presspull_model::profile_geometry(e)
|
crate::scene::model::presspull_model::profile_geometry(e)
|
||||||
else {
|
else {
|
||||||
@@ -1154,9 +1349,55 @@ impl OpenCADStudio {
|
|||||||
sources.push(*handle);
|
sources.push(*handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Open inputs must share one geometric plane, independently of the UCS.
|
||||||
|
let open_curves: Vec<_> = self.tabs[i].scene.selected_entities().iter()
|
||||||
|
.filter(|(handle, _)| !sources.contains(handle))
|
||||||
|
.filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity))
|
||||||
|
.collect();
|
||||||
|
let open_plane = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6);
|
||||||
|
let open_plane_rejected = !open_curves.is_empty() && open_plane.is_none();
|
||||||
|
if let Some(plane) = open_plane {
|
||||||
|
let working_plane = crate::command::WorkingPlane::new(
|
||||||
|
glam::DVec3::from_array(plane.origin),
|
||||||
|
glam::DVec3::from_array(plane.x_axis),
|
||||||
|
glam::DVec3::from_array(plane.y_axis),
|
||||||
|
);
|
||||||
|
let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i].scene
|
||||||
|
.selected_entities().iter().map(|(handle, _)| *handle).collect();
|
||||||
|
let mut boundary_sources = self.tabs[i].scene
|
||||||
|
.boundary_sources_on_plane(working_plane, 1.0e-6);
|
||||||
|
boundary_sources.retain(|handle, _| {
|
||||||
|
selected_handles.contains(handle) && !sources.contains(handle)
|
||||||
|
});
|
||||||
|
for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) {
|
||||||
|
let paths = crate::scene::exact_hatch_paths(
|
||||||
|
std::slice::from_ref(&ring), &[true], &boundary_sources, 1.0e-6,
|
||||||
|
);
|
||||||
|
let Some(path) = paths.first() else { continue; };
|
||||||
|
let Some(curves) = path.edges.iter()
|
||||||
|
.map(crate::entities::hatch::edge_curve).collect::<Option<Vec<_>>>()
|
||||||
|
else { continue; };
|
||||||
|
let Some(body) = cadkernel::brep::planar_region(plane, &[curves])
|
||||||
|
else { continue; };
|
||||||
|
let mut region = Region::new();
|
||||||
|
region.point_of_reference = Vector3::new(
|
||||||
|
plane.origin[0], plane.origin[1], plane.origin[2],
|
||||||
|
);
|
||||||
|
region.common.layer = self.tabs[i].active_layer.clone();
|
||||||
|
regions.push((region, body));
|
||||||
|
sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources));
|
||||||
|
}
|
||||||
|
} else if open_plane_rejected {
|
||||||
|
self.command_line.push_error(
|
||||||
|
"REGION: open objects must form coplanar, noncollinear boundaries.",
|
||||||
|
);
|
||||||
|
}
|
||||||
if regions.is_empty() {
|
if regions.is_empty() {
|
||||||
self.command_line
|
if !open_plane_rejected {
|
||||||
.push_error(crate::t!("REGION: select closed polylines or circles.").as_ref());
|
self.command_line.push_error(
|
||||||
|
"REGION: select closed planar profiles or connected coplanar edges.",
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.push_undo_snapshot(i, "REGION");
|
self.push_undo_snapshot(i, "REGION");
|
||||||
let count = regions.len();
|
let count = regions.len();
|
||||||
@@ -1171,6 +1412,8 @@ impl OpenCADStudio {
|
|||||||
created.push(handle);
|
created.push(handle);
|
||||||
}
|
}
|
||||||
if self.delete_objects != 0 {
|
if self.delete_objects != 0 {
|
||||||
|
sources.sort_by_key(|handle| handle.value());
|
||||||
|
sources.dedup();
|
||||||
self.tabs[i].scene.erase_entities(&sources);
|
self.tabs[i].scene.erase_entities(&sources);
|
||||||
self.refresh_properties();
|
self.refresh_properties();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ impl OpenCADStudio {
|
|||||||
self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected));
|
self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected));
|
||||||
return Some(task);
|
return Some(task);
|
||||||
}
|
}
|
||||||
let cmd = JoinCommand::new();
|
let mut cmd = JoinCommand::new();
|
||||||
|
if let Some(handle) = selected.first() {
|
||||||
|
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { cmd = cmd.with_source(*handle, entity); }
|
||||||
|
}
|
||||||
self.command_line.push_info(&cmd.prompt());
|
self.command_line.push_info(&cmd.prompt());
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||||
}
|
}
|
||||||
@@ -294,6 +297,7 @@ impl OpenCADStudio {
|
|||||||
header.surface_u_density,
|
header.surface_u_density,
|
||||||
header.surface_v_density,
|
header.surface_v_density,
|
||||||
)
|
)
|
||||||
|
.with_entities(self.tabs[i].scene.document.entities().cloned())
|
||||||
.with_preselection(&preselected);
|
.with_preselection(&preselected);
|
||||||
self.command_line.push_info(&cmd_obj.prompt());
|
self.command_line.push_info(&cmd_obj.prompt());
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||||
@@ -329,7 +333,17 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
"SPLINEDIT" => {
|
"SPLINEDIT" => {
|
||||||
use crate::modules::draw::modify::splinedit::SplineditCommand;
|
use crate::modules::draw::modify::splinedit::SplineditCommand;
|
||||||
let cmd_obj = SplineditCommand::new();
|
let mut cmd_obj = SplineditCommand::new().with_delete_source(self.delete_objects != 0);
|
||||||
|
let selected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
||||||
|
if let [handle] = selected.as_slice() {
|
||||||
|
if let Some(entity @ acadrust::EntityType::Spline(_)) =
|
||||||
|
self.tabs[i].scene.document.get_entity(*handle).cloned()
|
||||||
|
{
|
||||||
|
if self.reject_locked_edit(i, *handle) { return Some(Task::none()); }
|
||||||
|
cmd_obj.inject_picked_entity(entity);
|
||||||
|
cmd_obj.on_entity_pick(*handle, glam::DVec3::ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.command_line.push_info(&cmd_obj.prompt());
|
self.command_line.push_info(&cmd_obj.prompt());
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
|||||||
"CLEANSCREEN",
|
"CLEANSCREEN",
|
||||||
"CUI",
|
"CUI",
|
||||||
"DSETTINGS",
|
"DSETTINGS",
|
||||||
|
"PARAMETERS",
|
||||||
"GRID",
|
"GRID",
|
||||||
"ISODRAFT",
|
"ISODRAFT",
|
||||||
"ISOPLANE",
|
"ISOPLANE",
|
||||||
@@ -598,6 +599,10 @@ inventory::submit!(crate::command::CommandRegistration {
|
|||||||
"DIMSTYLE",
|
"DIMSTYLE",
|
||||||
"DONATE",
|
"DONATE",
|
||||||
"DRAWORDER",
|
"DRAWORDER",
|
||||||
|
"DRAWORDER_FRONT",
|
||||||
|
"DRAWORDER_BACK",
|
||||||
|
"DRAWORDER_ABOVE",
|
||||||
|
"DRAWORDER_UNDER",
|
||||||
"DWGPROP",
|
"DWGPROP",
|
||||||
"DWGPROPS",
|
"DWGPROPS",
|
||||||
"EATTEXT",
|
"EATTEXT",
|
||||||
|
|||||||
@@ -645,98 +645,221 @@ impl OpenCADStudio {
|
|||||||
// ── SETBYLAYER — clear color/linetype/lineweight overrides ────
|
// ── SETBYLAYER — clear color/linetype/lineweight overrides ────
|
||||||
// Resets the selected entities' direct property overrides back to
|
// Resets the selected entities' direct property overrides back to
|
||||||
// ByLayer so they follow their layer again.
|
// ByLayer so they follow their layer again.
|
||||||
|
"SETBYLAYERMODE" => {
|
||||||
|
let command = crate::modules::draw::modify::setbylayer::ModeCommand;
|
||||||
|
self.command_line.push_info(&command.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||||
|
}
|
||||||
|
value if value.starts_with("SETBYLAYERMODE ") => {
|
||||||
|
if let Ok(mode) = value.trim_start_matches("SETBYLAYERMODE ").trim().parse::<u8>() {
|
||||||
|
crate::modules::draw::modify::setbylayer::set_mode(mode);
|
||||||
|
} else { self.command_line.push_error("SETBYLAYERMODE requires an integer from 0 to 255."); }
|
||||||
|
}
|
||||||
"SETBYLAYER" => {
|
"SETBYLAYER" => {
|
||||||
let handles: Vec<_> = self.tabs[i]
|
let command = crate::modules::draw::modify::setbylayer::SetByLayerCommand::new(
|
||||||
|
self.tabs[i].scene.selected.iter().copied().collect(),
|
||||||
|
);
|
||||||
|
self.command_line.push_info(&command.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||||
|
}
|
||||||
|
cmd if cmd.starts_with("SETBYLAYER_APPLY ") => {
|
||||||
|
let flags: Vec<_> = cmd.split_whitespace().skip(1).collect();
|
||||||
|
if flags.len() != 2 || flags.iter().any(|flag| !matches!(*flag, "0" | "1")) {
|
||||||
|
return Some(Task::none());
|
||||||
|
}
|
||||||
|
let mask = crate::modules::draw::modify::setbylayer::mode();
|
||||||
|
let change_byblock = flags[0] == "1";
|
||||||
|
let include_blocks = flags[1] == "1";
|
||||||
|
let mut handles: Vec<_> = self.tabs[i]
|
||||||
.scene
|
.scene
|
||||||
.selected_entities()
|
.selected_entities()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(h, _)| h)
|
.map(|(h, _)| h)
|
||||||
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
|
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
|
||||||
.collect();
|
.collect();
|
||||||
|
if include_blocks {
|
||||||
|
let mut visited: std::collections::HashSet<_> = handles.iter().copied().collect();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < handles.len() {
|
||||||
|
let children = match self.tabs[i].scene.document.get_entity(handles[index]) {
|
||||||
|
Some(acadrust::EntityType::Insert(insert)) => self.tabs[i].scene.document
|
||||||
|
.block_records.get(&insert.block_name)
|
||||||
|
.map(|block| block.entity_handles.clone()).unwrap_or_default(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
for child in children {
|
||||||
|
if visited.insert(child) && !self.tabs[i].scene.is_layer_locked(child) {
|
||||||
|
handles.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
if handles.is_empty() {
|
if handles.is_empty() {
|
||||||
self.command_line
|
self.command_line
|
||||||
.push_error(crate::t!("SETBYLAYER: select entities first.").as_ref());
|
.push_error(crate::t!("SETBYLAYER: select entities first.").as_ref());
|
||||||
} else {
|
} else {
|
||||||
|
let has_changes = handles.iter().any(|handle| {
|
||||||
|
self.tabs[i].scene.document.get_entity(*handle).is_some_and(|entity| {
|
||||||
|
let mut common = entity.common().clone();
|
||||||
|
crate::modules::draw::modify::setbylayer::apply_mask(
|
||||||
|
&mut common, mask, change_byblock,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !has_changes {
|
||||||
|
self.command_line.push_output(
|
||||||
|
crate::t!("SETBYLAYER: no properties required changes.").as_ref(),
|
||||||
|
);
|
||||||
|
return Some(Task::none());
|
||||||
|
}
|
||||||
self.push_undo_snapshot(i, "SETBYLAYER");
|
self.push_undo_snapshot(i, "SETBYLAYER");
|
||||||
let mut changed = 0usize;
|
let mut changed = Vec::new();
|
||||||
for handle in &handles {
|
for handle in &handles {
|
||||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) {
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) {
|
||||||
let common = entity.common_mut();
|
if crate::modules::draw::modify::setbylayer::apply_mask(
|
||||||
common.color = acadrust::types::Color::ByLayer;
|
entity.common_mut(), mask, change_byblock,
|
||||||
common.color_name = None;
|
) {
|
||||||
common.color_book_handle = None;
|
changed.push(*handle);
|
||||||
common.linetype = "ByLayer".to_string();
|
}
|
||||||
common.line_weight = acadrust::types::LineWeight::ByLayer;
|
|
||||||
changed += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.tabs[i].dirty = true;
|
self.tabs[i].dirty = true;
|
||||||
let changes: Vec<_> = handles
|
let changes: Vec<_> = changed
|
||||||
.into_iter()
|
.iter()
|
||||||
|
.copied()
|
||||||
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
|
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
|
||||||
.collect();
|
.collect();
|
||||||
self.tabs[i].scene.bump_entities(&changes);
|
self.tabs[i].scene.bump_entities(&changes);
|
||||||
|
self.refresh_properties();
|
||||||
self.command_line.push_output(crate::tf!(
|
self.command_line.push_output(crate::tf!(
|
||||||
"SETBYLAYER: reset {changed} entity/entities to ByLayer."
|
"SETBYLAYER: reset {} entity/entities to ByLayer.",
|
||||||
|
changed.len()
|
||||||
).as_ref());
|
).as_ref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OVERKILL — delete duplicate (identical) objects ──────────
|
// OVERKILL gathers objects, exposes cleanup settings, then applies one undo group.
|
||||||
// Removes objects that are identical in geometry AND properties to
|
"OVERKILL" | "-OVERKILL" => {
|
||||||
// another object (compared with the handle ignored). Operates on
|
let handles=self.tabs[i].scene.selected_entities().iter().map(|(h,_)|*h).collect();
|
||||||
// the current selection, or the whole drawing when nothing is
|
let command=crate::modules::draw::modify::overkill::OverkillCommand::new(handles);
|
||||||
// selected. Conservative: only exact duplicates are removed.
|
self.command_line.push_info(&command.prompt());
|
||||||
"OVERKILL" => {
|
self.tabs[i].active_cmd=Some(Box::new(command));
|
||||||
use acadrust::Handle;
|
}
|
||||||
let selected: std::collections::HashSet<u64> = self.tabs[i]
|
value if value.starts_with("OVERKILL_APPLY ") => {
|
||||||
.scene
|
use crate::modules::draw::modify::overkill::{normalized,optimize};
|
||||||
.selected_entities()
|
let values:Vec<_>=value.split_whitespace().skip(1).collect();
|
||||||
.into_iter()
|
if values.len()!=6 { return Some(self.finish_dispatch(cmd)); }
|
||||||
.map(|(h, _)| h.value())
|
let tolerance=values[0].parse::<f64>().unwrap_or(1e-6);
|
||||||
.collect();
|
let ignore=values[1].parse::<u16>().unwrap_or(0);
|
||||||
// Capture (handle, type-name, handle-normalized clone) for each
|
let optimize_plines=values[2]=="1";
|
||||||
// candidate while the document is borrowed immutably.
|
let overlap=values[3]=="1";
|
||||||
let candidates: Vec<(Handle, String, acadrust::EntityType)> = self.tabs[i]
|
let end_to_end=values[4]=="1";
|
||||||
.scene
|
let preserve_associative=values[5]=="1";
|
||||||
.document
|
let mut candidates:Vec<_>=self.tabs[i].scene.selected_entities().into_iter()
|
||||||
.entities()
|
.filter(|(h,e)|!self.tabs[i].scene.is_layer_locked(*h)
|
||||||
.filter(|e| {
|
&& (!preserve_associative||e.common().reactors.is_empty()))
|
||||||
(selected.is_empty() || selected.contains(&e.common().handle.value()))
|
.map(|(h,e)|(h,e.clone())).collect();
|
||||||
&& !self.tabs[i].scene.is_layer_locked(e.common().handle)
|
candidates.sort_by_key(|(h,_)|h.value());
|
||||||
})
|
let mut changed=std::collections::HashSet::new();
|
||||||
.map(|e| {
|
if optimize_plines { for (h,e) in &mut candidates {
|
||||||
let key = crate::entities::names::dxf_name(e).to_string();
|
let before=e.clone(); optimize(e,tolerance); if *e!=before {changed.insert(*h);}
|
||||||
let mut norm = e.clone();
|
} }
|
||||||
norm.common_mut().handle = Handle::NULL;
|
let mut removed=std::collections::HashSet::new();
|
||||||
(e.common().handle, key, norm)
|
// Iterate to a fixed point: a bridge can join two previously disjoint intervals.
|
||||||
})
|
loop {
|
||||||
.collect();
|
let mut progress=false;
|
||||||
// Bucket by (type, layer) so only like objects are compared.
|
for a in 0..candidates.len() {
|
||||||
let mut kept: Vec<(String, acadrust::EntityType)> = Vec::new();
|
if removed.contains(&candidates[a].0) {continue;}
|
||||||
let mut dups: Vec<Handle> = Vec::new();
|
for b in a+1..candidates.len() {
|
||||||
for (h, key, norm) in &candidates {
|
if removed.contains(&candidates[b].0) {continue;}
|
||||||
let bucket = format!("{key}\u{0}{}", norm.common().layer);
|
let left=normalized(&candidates[a].1,ignore);
|
||||||
if kept.iter().any(|(b, e)| b == &bucket && e == norm) {
|
let right=normalized(&candidates[b].1,ignore);
|
||||||
dups.push(*h);
|
if left==right {
|
||||||
} else {
|
removed.insert(candidates[b].0);progress=true;continue;
|
||||||
kept.push((bucket, norm.clone()));
|
}
|
||||||
|
if overlap {
|
||||||
|
let contained = |circle: &acadrust::entities::Circle, arc: &acadrust::entities::Arc| {
|
||||||
|
circle.common == arc.common && circle.thickness == arc.thickness
|
||||||
|
&& cadkernel::space::arc_union::circle_contains_arc(
|
||||||
|
[circle.center.x,circle.center.y,circle.center.z],
|
||||||
|
[circle.normal.x,circle.normal.y,circle.normal.z],circle.radius,
|
||||||
|
cadkernel::space::arc_union::CircularArc { center:[arc.center.x,arc.center.y,arc.center.z],
|
||||||
|
normal:[arc.normal.x,arc.normal.y,arc.normal.z],radius:arc.radius,start:arc.start_angle,end:arc.end_angle })
|
||||||
|
};
|
||||||
|
match (&left,&right) {
|
||||||
|
(acadrust::EntityType::Circle(circle),acadrust::EntityType::Arc(arc)) if contained(circle,arc) => {
|
||||||
|
removed.insert(candidates[b].0);progress=true;continue;
|
||||||
|
}
|
||||||
|
(acadrust::EntityType::Arc(arc),acadrust::EntityType::Circle(circle)) if contained(circle,arc) => {
|
||||||
|
removed.insert(candidates[a].0);progress=true;break;
|
||||||
|
}
|
||||||
|
_ => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (acadrust::EntityType::Arc(l),acadrust::EntityType::Arc(r))=(&left,&right) {
|
||||||
|
use cadkernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union};
|
||||||
|
if l.common!=r.common || l.thickness!=r.thickness {continue;}
|
||||||
|
let arc=|v:&acadrust::entities::Arc|CircularArc{center:[v.center.x,v.center.y,v.center.z],normal:[v.normal.x,v.normal.y,v.normal.z],radius:v.radius,start:v.start_angle,end:v.end_angle};
|
||||||
|
let Some(union)=circular_arc_union(arc(l),arc(r),tolerance) else {continue;};
|
||||||
|
let allowed=match union.kind {ArcUnionKind::Duplicate=>true,ArcUnionKind::Overlap=>overlap,ArcUnionKind::EndToEnd=>end_to_end};
|
||||||
|
if !allowed {continue;}
|
||||||
|
if let acadrust::EntityType::Arc(source)=&candidates[a].1 {
|
||||||
|
let replacement=if union.full_circle {
|
||||||
|
let mut circle=acadrust::entities::Circle::new();
|
||||||
|
circle.common=source.common.clone();circle.center=source.center.clone();circle.normal=source.normal.clone();circle.radius=source.radius;circle.thickness=source.thickness;
|
||||||
|
acadrust::EntityType::Circle(circle)
|
||||||
|
} else {
|
||||||
|
let mut arc=source.clone();arc.start_angle=union.start;arc.end_angle=union.end;acadrust::EntityType::Arc(arc)
|
||||||
|
};
|
||||||
|
candidates[a].1=replacement;
|
||||||
|
}
|
||||||
|
changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (acadrust::EntityType::Line(l),acadrust::EntityType::Line(r))=(&left,&right)
|
||||||
|
else {continue;};
|
||||||
|
if l.common!=r.common||l.thickness!=r.thickness||l.normal!=r.normal {continue;}
|
||||||
|
let point=|p:acadrust::types::Vector3|[p.x,p.y,p.z];
|
||||||
|
let Some(union)=cadkernel::space::line_union(
|
||||||
|
[point(l.start),point(l.end)],[point(r.start),point(r.end)],tolerance)
|
||||||
|
else {continue;};
|
||||||
|
let allowed=match union.kind {
|
||||||
|
cadkernel::space::LineUnionKind::Duplicate=>true,
|
||||||
|
cadkernel::space::LineUnionKind::Overlap=>overlap,
|
||||||
|
cadkernel::space::LineUnionKind::EndToEnd=>end_to_end,
|
||||||
|
};
|
||||||
|
if !allowed {continue;}
|
||||||
|
if let acadrust::EntityType::Line(line)=&mut candidates[a].1 {
|
||||||
|
line.start=acadrust::types::Vector3::new(union.start[0],union.start[1],union.start[2]);
|
||||||
|
line.end=acadrust::types::Vector3::new(union.end[0],union.end[1],union.end[2]);
|
||||||
|
}
|
||||||
|
changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if !progress {break;}
|
||||||
}
|
}
|
||||||
if dups.is_empty() {
|
if !changed.is_empty()||!removed.is_empty() {
|
||||||
self.command_line
|
self.push_undo_snapshot(i,"OVERKILL");
|
||||||
.push_output(crate::t!("OVERKILL: no duplicate objects found.").as_ref());
|
let updates:Vec<_>=candidates.into_iter().filter(|(h,_)|changed.contains(h)&&!removed.contains(h)).collect();
|
||||||
|
let updated_count = updates.len();
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
for (handle, entity) in updates {
|
||||||
|
if let Some(target) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||||
|
*target = entity;
|
||||||
|
changes.push((handle, crate::scene::ChangeKind::Modified));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.tabs[i].scene.bump_entities(&changes);
|
||||||
|
let mut handles:Vec<_>=removed.iter().copied().collect();
|
||||||
|
handles.sort_by_key(|handle| handle.value());
|
||||||
|
self.tabs[i].scene.erase_entities(&handles);
|
||||||
|
self.tabs[i].dirty=true;self.refresh_properties();
|
||||||
|
self.command_line.push_output(&format!("OVERKILL: removed {} objects; updated {} objects.",removed.len(),updated_count));
|
||||||
} else {
|
} else {
|
||||||
let n = dups.len();
|
self.command_line.push_output("OVERKILL: no objects required changes.");
|
||||||
self.push_undo_snapshot(i, "OVERKILL");
|
|
||||||
self.tabs[i].scene.erase_entities(&dups);
|
|
||||||
self.tabs[i].dirty = true;
|
|
||||||
self.refresh_properties();
|
|
||||||
self.command_line
|
|
||||||
.push_output(crate::tf!("OVERKILL: deleted {n} duplicate object(s).").as_ref());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PICKADD / PICKDRAG — selection UX (#226, app settings) ───
|
// ── PICKADD / PICKDRAG — selection UX (#226, app settings) ───
|
||||||
// Bare form reports; `<name> 0|1` sets and persists. Defaults keep
|
// Bare form reports; `<name> 0|1` sets and persists. Defaults keep
|
||||||
// today's behaviour (PICKADD 1, PICKDRAG 0).
|
// today's behaviour (PICKADD 1, PICKDRAG 0).
|
||||||
@@ -2983,6 +3106,21 @@ mod tests {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_commands_gather_instead_of_using_the_whole_drawing() {
|
||||||
|
for command in ["SETBYLAYER", "OVERKILL"] {
|
||||||
|
let mut app = fresh_app();
|
||||||
|
let _ = app.run_command_line(command);
|
||||||
|
assert!(
|
||||||
|
app.tabs[app.active_tab]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|active| active.is_selection_gathering()),
|
||||||
|
"{command} should gather an explicit selection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cad_selection_and_model_space_sysvars() {
|
fn test_cad_selection_and_model_space_sysvars() {
|
||||||
let mut app = fresh_app();
|
let mut app = fresh_app();
|
||||||
@@ -3195,4 +3333,3 @@ mod scale_validation_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,62 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// Sort tables belong to the block's extension dictionary. Reserve real handles
|
||||||
|
// and reconnect older unattached tables without losing their existing entries.
|
||||||
|
fn ensure_draw_order_table(
|
||||||
|
doc: &mut acadrust::CadDocument,
|
||||||
|
block: acadrust::Handle,
|
||||||
|
) -> acadrust::Handle {
|
||||||
|
use acadrust::objects::{Dictionary, ObjectType, SortEntitiesTable};
|
||||||
|
|
||||||
|
// Older commands inserted objects using the unreserved next-handle value.
|
||||||
|
// Repair the allocator floor before creating anything beside those objects.
|
||||||
|
if let Some(maximum) = doc.objects.keys().map(|handle| handle.value()).max() {
|
||||||
|
doc.header.handle_seed = doc.header.handle_seed.max(maximum.saturating_add(1));
|
||||||
|
}
|
||||||
|
let dictionary = doc.extension_dictionary_handle(block).filter(|handle| {
|
||||||
|
matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))
|
||||||
|
});
|
||||||
|
let named_table = dictionary.and_then(|handle| match doc.objects.get(&handle) {
|
||||||
|
Some(ObjectType::Dictionary(value)) => value.get(SortEntitiesTable::DICTIONARY_KEY),
|
||||||
|
_ => None,
|
||||||
|
}).filter(|handle| matches!(doc.objects.get(handle),
|
||||||
|
Some(ObjectType::SortEntitiesTable(table)) if table.block_owner_handle == block));
|
||||||
|
let existing = named_table.or_else(|| doc.objects.iter().find_map(|(handle, object)| {
|
||||||
|
match object {
|
||||||
|
ObjectType::SortEntitiesTable(table) if table.block_owner_handle == block => Some(*handle),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let dictionary = dictionary.unwrap_or_else(|| {
|
||||||
|
let handle = doc.allocate_handle();
|
||||||
|
let mut value = Dictionary::new();
|
||||||
|
value.handle = handle;
|
||||||
|
value.owner = block;
|
||||||
|
value.hard_owner = true;
|
||||||
|
doc.objects.insert(handle, ObjectType::Dictionary(value));
|
||||||
|
handle
|
||||||
|
});
|
||||||
|
let handle = existing.unwrap_or_else(|| {
|
||||||
|
let handle = doc.allocate_handle();
|
||||||
|
let mut table = SortEntitiesTable::for_block(block);
|
||||||
|
table.handle = handle;
|
||||||
|
doc.objects.insert(handle, ObjectType::SortEntitiesTable(table));
|
||||||
|
handle
|
||||||
|
});
|
||||||
|
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&handle) {
|
||||||
|
table.owner_handle = dictionary;
|
||||||
|
}
|
||||||
|
if let Some(ObjectType::Dictionary(value)) = doc.objects.get_mut(&dictionary) {
|
||||||
|
if let Some((_, entry)) = value.entries.iter_mut()
|
||||||
|
.find(|(name, _)| name.eq_ignore_ascii_case(SortEntitiesTable::DICTIONARY_KEY)) {
|
||||||
|
*entry = handle;
|
||||||
|
} else {
|
||||||
|
value.add_entry(SortEntitiesTable::DICTIONARY_KEY, handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
impl OpenCADStudio {
|
impl OpenCADStudio {
|
||||||
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||||
match cmd {
|
match cmd {
|
||||||
@@ -253,6 +310,10 @@ impl OpenCADStudio {
|
|||||||
return Some(Task::done(Message::OptionsOpen));
|
return Some(Task::done(Message::OptionsOpen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"PARAMETERS" => {
|
||||||
|
return Some(Task::done(Message::NamedParametersOpen));
|
||||||
|
}
|
||||||
|
|
||||||
// CUIEXPORT <path> — write the keyboard-shortcut customizations
|
// CUIEXPORT <path> — write the keyboard-shortcut customizations
|
||||||
// (the drawing-independent CUI data) to a plain "KEY COMMAND" file.
|
// (the drawing-independent CUI data) to a plain "KEY COMMAND" file.
|
||||||
"CUIEXPORT" => {
|
"CUIEXPORT" => {
|
||||||
@@ -817,17 +878,15 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
// HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order.
|
// HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order.
|
||||||
"HATCHTOBACK" => {
|
"HATCHTOBACK" => {
|
||||||
use acadrust::objects::{ObjectType, SortEntitiesTable};
|
use acadrust::objects::ObjectType;
|
||||||
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
||||||
let doc_ref = &self.tabs[i].scene.document;
|
let doc_ref = &self.tabs[i].scene.document;
|
||||||
|
|
||||||
// 1. Single scan over objects to find existing SortEntitiesTable handle & overrides.
|
// 1. Read existing SortEntitiesTable overrides.
|
||||||
let mut existing_table_handle = None;
|
|
||||||
let mut overrides: Option<rustc_hash::FxHashMap<u64, u64>> = None;
|
let mut overrides: Option<rustc_hash::FxHashMap<u64, u64>> = None;
|
||||||
for (h, obj) in &doc_ref.objects {
|
for obj in doc_ref.objects.values() {
|
||||||
if let ObjectType::SortEntitiesTable(t) = obj {
|
if let ObjectType::SortEntitiesTable(t) = obj {
|
||||||
if t.block_owner_handle == block_handle {
|
if t.block_owner_handle == block_handle {
|
||||||
existing_table_handle = Some(*h);
|
|
||||||
if !t.is_empty() {
|
if !t.is_empty() {
|
||||||
overrides = Some(
|
overrides = Some(
|
||||||
t.entries()
|
t.entries()
|
||||||
@@ -894,24 +953,9 @@ impl OpenCADStudio {
|
|||||||
&locked_layers,
|
&locked_layers,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone).
|
// Dictionary ownership and handle allocation are part of this undo step.
|
||||||
let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true);
|
let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), false);
|
||||||
|
let th = ensure_draw_order_table(&mut self.tabs[i].scene.document, block_handle);
|
||||||
// 5. Update or insert SortEntitiesTable directly.
|
|
||||||
let table_before = existing_table_handle
|
|
||||||
.and_then(|h| self.tabs[i].scene.document.objects.get(&h).cloned());
|
|
||||||
let th = existing_table_handle.unwrap_or_else(|| {
|
|
||||||
let nh = acadrust::Handle::new(self.tabs[i].scene.document.next_handle());
|
|
||||||
let mut table = SortEntitiesTable::for_block(block_handle);
|
|
||||||
table.handle = nh;
|
|
||||||
self.tabs[i]
|
|
||||||
.scene
|
|
||||||
.document
|
|
||||||
.objects
|
|
||||||
.insert(nh, ObjectType::SortEntitiesTable(table));
|
|
||||||
nh
|
|
||||||
});
|
|
||||||
self.tabs[i].scene.record_undo_object_before(th, table_before);
|
|
||||||
|
|
||||||
if let Some(ObjectType::SortEntitiesTable(table)) =
|
if let Some(ObjectType::SortEntitiesTable(table)) =
|
||||||
self.tabs[i].scene.document.objects.get_mut(&th)
|
self.tabs[i].scene.document.objects.get_mut(&th)
|
||||||
@@ -941,6 +985,37 @@ impl OpenCADStudio {
|
|||||||
return Some(Task::none());
|
return Some(Task::none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"DRAWORDER_FRONT" | "DRAWORDER_BACK" | "DRAWORDER_ABOVE" | "DRAWORDER_UNDER" => {
|
||||||
|
let selected: Vec<acadrust::Handle> = self.tabs[i]
|
||||||
|
.scene
|
||||||
|
.selected_entities()
|
||||||
|
.iter()
|
||||||
|
.map(|(h, _)| *h)
|
||||||
|
.collect();
|
||||||
|
if selected.is_empty() {
|
||||||
|
use crate::modules::draw::select::SelectObjectsCommand;
|
||||||
|
let selection = SelectObjectsCommand::plain("DRAWORDER", cmd);
|
||||||
|
self.command_line.push_info(&selection.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(selection));
|
||||||
|
} else if matches!(cmd, "DRAWORDER_ABOVE" | "DRAWORDER_UNDER") {
|
||||||
|
let command = DrawOrderCommand::for_reference_pick(
|
||||||
|
selected,
|
||||||
|
cmd == "DRAWORDER_ABOVE",
|
||||||
|
);
|
||||||
|
self.command_line.push_info(&command.prompt());
|
||||||
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||||
|
} else {
|
||||||
|
let primitive = if cmd == "DRAWORDER_FRONT" {
|
||||||
|
"DRAWORDER FRONT"
|
||||||
|
} else {
|
||||||
|
"DRAWORDER BACK"
|
||||||
|
};
|
||||||
|
return Some(self.apply_cmd_result(crate::command::CmdResult::Relaunch(
|
||||||
|
primitive.to_string(),
|
||||||
|
selected,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
"DRAWORDER" => {
|
"DRAWORDER" => {
|
||||||
let selected: Vec<acadrust::Handle> = self.tabs[i]
|
let selected: Vec<acadrust::Handle> = self.tabs[i]
|
||||||
.scene
|
.scene
|
||||||
@@ -953,7 +1028,7 @@ impl OpenCADStudio {
|
|||||||
self.tabs[i].active_cmd = Some(Box::new(c));
|
self.tabs[i].active_cmd = Some(Box::new(c));
|
||||||
}
|
}
|
||||||
cmd if cmd.starts_with("DRAWORDER ") => {
|
cmd if cmd.starts_with("DRAWORDER ") => {
|
||||||
use acadrust::objects::{ObjectType, SortEntitiesTable};
|
use acadrust::objects::ObjectType;
|
||||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||||
let option = parts.get(1).unwrap_or(&"").to_uppercase();
|
let option = parts.get(1).unwrap_or(&"").to_uppercase();
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
@@ -968,25 +1043,29 @@ impl OpenCADStudio {
|
|||||||
self.command_line
|
self.command_line
|
||||||
.push_error(crate::t!("DRAWORDER: select entities first.").as_ref());
|
.push_error(crate::t!("DRAWORDER: select entities first.").as_ref());
|
||||||
} else {
|
} else {
|
||||||
// Parse relative target handle for ABOVE/UNDER.
|
let relative_above = match option.as_str() {
|
||||||
let relative_target: Option<(bool, acadrust::Handle)> = match option.as_str() {
|
"A" | "ABOVE" => Some(true),
|
||||||
"A" | "ABOVE" => {
|
"U" | "UNDER" | "BELOW" => Some(false),
|
||||||
let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok());
|
|
||||||
h_val.map(|v| (true, acadrust::Handle::new(v)))
|
|
||||||
}
|
|
||||||
"U" | "UNDER" | "BELOW" => {
|
|
||||||
let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok());
|
|
||||||
h_val.map(|v| (false, acadrust::Handle::new(v)))
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let references: Vec<_> = parts.iter().skip(2).filter_map(|text| {
|
||||||
|
u64::from_str_radix(text.trim_start_matches("0x").trim_start_matches("0X"), 16)
|
||||||
|
.ok().map(acadrust::Handle::new)
|
||||||
|
}).filter(|handle| !selected.contains(handle)).collect();
|
||||||
|
let relative_assignments = relative_above.and_then(|above| {
|
||||||
|
assign_relative_group_keys(
|
||||||
|
&self.tabs[i].scene.document,
|
||||||
|
self.tabs[i].scene.current_layout_block_handle_pub(),
|
||||||
|
&selected, &references, above,
|
||||||
|
)
|
||||||
|
});
|
||||||
let to_front_opt = match option.as_str() {
|
let to_front_opt = match option.as_str() {
|
||||||
"F" | "FRONT" => Some(true),
|
"F" | "FRONT" => Some(true),
|
||||||
"B" | "BACK" => Some(false),
|
"B" | "BACK" => Some(false),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if relative_target.is_some() || to_front_opt.is_some() {
|
if relative_assignments.is_some() || to_front_opt.is_some() {
|
||||||
self.push_undo_snapshot(i, "DRAWORDER");
|
self.push_undo_snapshot(i, "DRAWORDER");
|
||||||
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
||||||
|
|
||||||
@@ -1063,63 +1142,14 @@ impl OpenCADStudio {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let doc = &mut self.tabs[i].scene.document;
|
let doc = &mut self.tabs[i].scene.document;
|
||||||
let table_handle = doc.objects.iter().find_map(|(h, obj)| {
|
let th = ensure_draw_order_table(doc, block_handle);
|
||||||
if let ObjectType::SortEntitiesTable(t) = obj {
|
|
||||||
if t.block_owner_handle == block_handle {
|
|
||||||
Some(*h)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let th = table_handle.unwrap_or_else(|| {
|
|
||||||
let nh = acadrust::Handle::new(doc.next_handle());
|
|
||||||
let mut table = SortEntitiesTable::for_block(block_handle);
|
|
||||||
table.handle = nh;
|
|
||||||
doc.objects.insert(nh, ObjectType::SortEntitiesTable(table));
|
|
||||||
nh
|
|
||||||
});
|
|
||||||
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th)
|
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th)
|
||||||
{
|
{
|
||||||
if let Some((above, target)) = relative_target {
|
if let Some(assignments) = &relative_assignments {
|
||||||
// move_above/move_below recompute target±1 per
|
for (handle, sort) in assignments {
|
||||||
// call, so looping them over N selected entities
|
table.add_entry(*handle, acadrust::Handle::new(*sort));
|
||||||
// ties the whole group onto one key. Read the
|
|
||||||
// reference key once and hand out per-index
|
|
||||||
// keys instead, keeping the moved entities a
|
|
||||||
// distinct, selection-ordered block adjacent to
|
|
||||||
// the reference.
|
|
||||||
let target_sort = match table.get_sort_handle(target) {
|
|
||||||
Some(h) => h.value(),
|
|
||||||
None => {
|
|
||||||
// A reference object that was never
|
|
||||||
// reordered isn't in the table yet;
|
|
||||||
// seed it with its own handle as the
|
|
||||||
// implicit sort key.
|
|
||||||
table.add_entry(target, target);
|
|
||||||
table
|
|
||||||
.get_sort_handle(target)
|
|
||||||
.map_or(target.value(), |h| h.value())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (k, h) in selected.iter().enumerate() {
|
|
||||||
let offset = 1 + k as u64;
|
|
||||||
let sort = if above {
|
|
||||||
target_sort.saturating_add(offset)
|
|
||||||
} else {
|
|
||||||
target_sort.saturating_sub(offset).max(1)
|
|
||||||
};
|
|
||||||
table.add_entry(*h, acadrust::Handle::new(sort));
|
|
||||||
}
|
}
|
||||||
let rel = if above { "above" } else { "below" };
|
self.command_line.push_info(crate::t!("DRAWORDER: selection reordered relative to reference objects.").as_ref());
|
||||||
self.command_line.push_info(crate::tf!(
|
|
||||||
"DRAWORDER: moved {} entities {} {:x}.",
|
|
||||||
selected.len(),
|
|
||||||
rel,
|
|
||||||
target.value()
|
|
||||||
).as_ref());
|
|
||||||
} else if let Some(to_front) = to_front_opt {
|
} else if let Some(to_front) = to_front_opt {
|
||||||
if to_front {
|
if to_front {
|
||||||
let (_, max_eff) = fb_baseline.unwrap_or((1, 0));
|
let (_, max_eff) = fb_baseline.unwrap_or((1, 0));
|
||||||
@@ -1250,11 +1280,12 @@ enum DrawOrderStep {
|
|||||||
/// - `Front` / `F`: moves selection to front.
|
/// - `Front` / `F`: moves selection to front.
|
||||||
/// - `Back` / `B` / Enter: moves selection to back.
|
/// - `Back` / `B` / Enter: moves selection to back.
|
||||||
/// - `Above` / `A` / `Under` / `U`: advances to reference object pick.
|
/// - `Above` / `A` / `Under` / `U`: advances to reference object pick.
|
||||||
/// 3. Reference object pick: user can click the reference entity in the viewport
|
/// 3. Gather reference objects, excluding the moved selection; Enter applies.
|
||||||
/// or type its hex handle on the command line.
|
/// Typed hexadecimal handles may also be accumulated before confirming.
|
||||||
pub(crate) struct DrawOrderCommand {
|
pub(crate) struct DrawOrderCommand {
|
||||||
selected: Vec<acadrust::Handle>,
|
selected: Vec<acadrust::Handle>,
|
||||||
step: DrawOrderStep,
|
step: DrawOrderStep,
|
||||||
|
references: Vec<acadrust::Handle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DrawOrderCommand {
|
impl DrawOrderCommand {
|
||||||
@@ -1264,13 +1295,14 @@ impl DrawOrderCommand {
|
|||||||
} else {
|
} else {
|
||||||
DrawOrderStep::ChooseVerb
|
DrawOrderStep::ChooseVerb
|
||||||
};
|
};
|
||||||
Self { selected, step }
|
Self { selected, step, references: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn for_reference_pick(selected: Vec<acadrust::Handle>, above: bool) -> Self {
|
pub(crate) fn for_reference_pick(selected: Vec<acadrust::Handle>, above: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
selected,
|
selected,
|
||||||
step: DrawOrderStep::PickReference { above },
|
step: DrawOrderStep::PickReference { above },
|
||||||
|
references: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1289,10 +1321,10 @@ impl CadCommand for DrawOrderCommand {
|
|||||||
crate::t!("DRAWORDER [Above / Under / Front / Back] <Back>:").into_owned()
|
crate::t!("DRAWORDER [Above / Under / Front / Back] <Back>:").into_owned()
|
||||||
}
|
}
|
||||||
DrawOrderStep::PickReference { above: true } => {
|
DrawOrderStep::PickReference { above: true } => {
|
||||||
crate::t!("DRAWORDER Select reference object (move selection above):").into_owned()
|
format!("DRAWORDER Select reference objects to move above ({} selected, Enter when done):", self.references.len())
|
||||||
}
|
}
|
||||||
DrawOrderStep::PickReference { above: false } => {
|
DrawOrderStep::PickReference { above: false } => {
|
||||||
crate::t!("DRAWORDER Select reference object (move selection under):").into_owned()
|
format!("DRAWORDER Select reference objects to move under ({} selected, Enter when done):", self.references.len())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1310,7 +1342,7 @@ impl CadCommand for DrawOrderCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn input_kind(&self) -> crate::command::InputKind {
|
fn input_kind(&self) -> crate::command::InputKind {
|
||||||
if matches!(self.step, DrawOrderStep::SelectObjects) {
|
if matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. }) {
|
||||||
crate::command::InputKind::Point
|
crate::command::InputKind::Point
|
||||||
} else {
|
} else {
|
||||||
crate::command::InputKind::SingleToken
|
crate::command::InputKind::SingleToken
|
||||||
@@ -1318,11 +1350,15 @@ impl CadCommand for DrawOrderCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_selection_gathering(&self) -> bool {
|
fn is_selection_gathering(&self) -> bool {
|
||||||
matches!(self.step, DrawOrderStep::SelectObjects)
|
matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_selection_complete(&mut self, handles: Vec<acadrust::Handle>) -> crate::command::CmdResult {
|
fn on_selection_complete(&mut self, handles: Vec<acadrust::Handle>) -> crate::command::CmdResult {
|
||||||
self.selected = handles;
|
if matches!(self.step, DrawOrderStep::PickReference { .. }) {
|
||||||
|
self.references = handles.into_iter().filter(|handle| !self.selected.contains(handle)).collect();
|
||||||
|
} else {
|
||||||
|
self.selected = handles;
|
||||||
|
}
|
||||||
crate::command::CmdResult::NeedPoint
|
crate::command::CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,7 +1377,12 @@ impl CadCommand for DrawOrderCommand {
|
|||||||
let handles = std::mem::take(&mut self.selected);
|
let handles = std::mem::take(&mut self.selected);
|
||||||
crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles)
|
crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles)
|
||||||
}
|
}
|
||||||
DrawOrderStep::PickReference { .. } => crate::command::CmdResult::Cancel,
|
DrawOrderStep::PickReference { above } => {
|
||||||
|
if self.references.is_empty() { return crate::command::CmdResult::Cancel; }
|
||||||
|
let option = if above { "ABOVE" } else { "UNDER" };
|
||||||
|
let references = self.references.iter().map(|handle| format!("{:x}", handle.value())).collect::<Vec<_>>().join(" ");
|
||||||
|
crate::command::CmdResult::Relaunch(format!("DRAWORDER {option} {references}"), std::mem::take(&mut self.selected))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1374,47 +1415,63 @@ impl CadCommand for DrawOrderCommand {
|
|||||||
_ => Some(crate::command::CmdResult::NeedPoint),
|
_ => Some(crate::command::CmdResult::NeedPoint),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DrawOrderStep::PickReference { above } => {
|
DrawOrderStep::PickReference { .. } => {
|
||||||
let hex_str = t.trim_start_matches("0x").trim_start_matches("0X");
|
for text in t.split_whitespace() {
|
||||||
if let Ok(val) = u64::from_str_radix(hex_str, 16) {
|
let hex = text.trim_start_matches("0x").trim_start_matches("0X");
|
||||||
let opt = if above { "A" } else { "U" };
|
if let Ok(value) = u64::from_str_radix(hex, 16) {
|
||||||
let cmd = format!("DRAWORDER {} {:x}", opt, val);
|
let handle = acadrust::Handle::new(value);
|
||||||
let handles = std::mem::take(&mut self.selected);
|
if !handle.is_null() && !self.selected.contains(&handle) && !self.references.contains(&handle) {
|
||||||
Some(crate::command::CmdResult::Relaunch(cmd, handles))
|
self.references.push(handle);
|
||||||
} else {
|
}
|
||||||
Some(crate::command::CmdResult::NeedPoint)
|
}
|
||||||
}
|
}
|
||||||
|
Some(crate::command::CmdResult::NeedPoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_entity_pick(&self) -> bool {
|
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: glam::DVec3) -> crate::command::CmdResult {
|
||||||
matches!(self.step, DrawOrderStep::PickReference { .. })
|
if matches!(self.step, DrawOrderStep::PickReference { .. }) && !handle.is_null()
|
||||||
}
|
&& !self.selected.contains(&handle) && !self.references.contains(&handle) {
|
||||||
|
self.references.push(handle);
|
||||||
fn on_entity_pick(
|
|
||||||
&mut self,
|
|
||||||
handle: acadrust::Handle,
|
|
||||||
_pt: glam::DVec3,
|
|
||||||
) -> crate::command::CmdResult {
|
|
||||||
if handle.is_null() {
|
|
||||||
return crate::command::CmdResult::NeedPoint;
|
|
||||||
}
|
|
||||||
if let DrawOrderStep::PickReference { above } = self.step {
|
|
||||||
let opt = if above { "A" } else { "U" };
|
|
||||||
let cmd = format!("DRAWORDER {} {:x}", opt, handle.value());
|
|
||||||
let handles = std::mem::take(&mut self.selected);
|
|
||||||
crate::command::CmdResult::Relaunch(cmd, handles)
|
|
||||||
} else {
|
|
||||||
crate::command::CmdResult::NeedPoint
|
|
||||||
}
|
}
|
||||||
|
crate::command::CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult {
|
fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult {
|
||||||
crate::command::CmdResult::NeedPoint
|
crate::command::CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert the selected block next to the extremal reference in effective draw
|
||||||
|
/// order. Renumber the ordered siblings to avoid ties or saturated sort keys.
|
||||||
|
fn assign_relative_group_keys(
|
||||||
|
document: &acadrust::CadDocument,
|
||||||
|
block: acadrust::Handle,
|
||||||
|
selected: &[acadrust::Handle],
|
||||||
|
references: &[acadrust::Handle],
|
||||||
|
above: bool,
|
||||||
|
) -> Option<Vec<(acadrust::Handle, u64)>> {
|
||||||
|
use acadrust::objects::ObjectType;
|
||||||
|
let overrides: std::collections::HashMap<_, _> = document.objects.values().find_map(|object| {
|
||||||
|
if let ObjectType::SortEntitiesTable(table) = object {
|
||||||
|
(table.block_owner_handle == block).then(|| table.entries().map(|entry| (entry.entity_handle, entry.sort_handle.value())).collect())
|
||||||
|
} else { None }
|
||||||
|
}).unwrap_or_default();
|
||||||
|
let mut ordered: Vec<_> = document.entities().filter(|entity| {
|
||||||
|
let owner = entity.common().owner_handle;
|
||||||
|
owner == block || owner.is_null()
|
||||||
|
}).map(|entity| entity.common().handle).collect();
|
||||||
|
ordered.sort_by_key(|handle| (overrides.get(handle).copied().unwrap_or(handle.value()), handle.value()));
|
||||||
|
let selected_set: std::collections::HashSet<_> = selected.iter().copied().collect();
|
||||||
|
let reference_set: std::collections::HashSet<_> = references.iter().copied().collect();
|
||||||
|
let moved: Vec<_> = ordered.iter().copied().filter(|handle| selected_set.contains(handle)).collect();
|
||||||
|
if moved.is_empty() { return None; }
|
||||||
|
ordered.retain(|handle| !selected_set.contains(handle));
|
||||||
|
let indices: Vec<_> = ordered.iter().enumerate().filter_map(|(index, handle)| reference_set.contains(handle).then_some(index)).collect();
|
||||||
|
let insertion = if above { indices.into_iter().max()? + 1 } else { indices.into_iter().min()? };
|
||||||
|
ordered.splice(insertion..insertion, moved);
|
||||||
|
Some(ordered.into_iter().enumerate().map(|(index, handle)| (handle, index as u64 + 1)).collect())
|
||||||
|
}
|
||||||
/// Sort-key assignments sending `group` to the back of the active space.
|
/// Sort-key assignments sending `group` to the back of the active space.
|
||||||
///
|
///
|
||||||
/// Normal case: the floor is the lowest effective sort key among non-moved
|
/// Normal case: the floor is the lowest effective sort key among non-moved
|
||||||
@@ -2062,11 +2119,112 @@ mod tests {
|
|||||||
assert!(hatch2_sort < ref_sort, "hatch 2 ({hatch2_sort}) must be under reference ({ref_sort})");
|
assert!(hatch2_sort < ref_sort, "hatch 2 ({hatch2_sort}) must be under reference ({ref_sort})");
|
||||||
assert_ne!(hatch1_sort, hatch2_sort, "multi-select UNDER must not tie selected entities");
|
assert_ne!(hatch1_sort, hatch2_sort, "multi-select UNDER must not tie selected entities");
|
||||||
assert!(
|
assert!(
|
||||||
hatch1_sort > hatch2_sort,
|
hatch1_sort < hatch2_sort,
|
||||||
"selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})"
|
"selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_group_uses_extremal_references_and_preserves_internal_order() {
|
||||||
|
let mut app = fresh_app();
|
||||||
|
let i = app.active_tab;
|
||||||
|
let moved_first = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
let reference_low = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
let middle = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
let reference_high = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
let moved_last = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
let block = app.tabs[i].scene.current_layout_block_handle_pub();
|
||||||
|
let selected = [moved_first, moved_last];
|
||||||
|
let references = [reference_low, reference_high];
|
||||||
|
|
||||||
|
let ordered = |above| {
|
||||||
|
let mut assignments = assign_relative_group_keys(
|
||||||
|
&app.tabs[i].scene.document,
|
||||||
|
block,
|
||||||
|
&selected,
|
||||||
|
&references,
|
||||||
|
above,
|
||||||
|
)
|
||||||
|
.expect("references belong to the active block");
|
||||||
|
assignments.sort_by_key(|(_, sort)| *sort);
|
||||||
|
assignments.into_iter().map(|(handle, _)| handle).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ordered(true),
|
||||||
|
vec![reference_low, middle, reference_high, moved_first, moved_last]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ordered(false),
|
||||||
|
vec![moved_first, moved_last, reference_low, middle, reference_high]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_group_rejects_unusable_references_without_creating_a_table() {
|
||||||
|
let mut app = fresh_app();
|
||||||
|
let i = app.active_tab;
|
||||||
|
let moved = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
app.tabs[i].scene.replace_selection(std::iter::once(moved).collect());
|
||||||
|
|
||||||
|
let _ = app.run_command_line("DRAWORDER ABOVE deadbeef");
|
||||||
|
|
||||||
|
assert!(!app.tabs[i].scene.document.objects.values().any(|object| {
|
||||||
|
matches!(object, ObjectType::SortEntitiesTable(_))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_order_table_reattaches_orphan_and_advances_object_handles() {
|
||||||
|
use acadrust::objects::SortEntitiesTable;
|
||||||
|
|
||||||
|
let mut app = fresh_app();
|
||||||
|
let i = app.active_tab;
|
||||||
|
let block = app.tabs[i].scene.current_layout_block_handle_pub();
|
||||||
|
let entity = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
let orphan_handle = acadrust::Handle::new(0x10_0000);
|
||||||
|
let mut orphan = SortEntitiesTable::for_block(block);
|
||||||
|
orphan.handle = orphan_handle;
|
||||||
|
orphan.add_entry(entity, acadrust::Handle::new(7));
|
||||||
|
app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.document
|
||||||
|
.objects
|
||||||
|
.insert(orphan_handle, ObjectType::SortEntitiesTable(orphan));
|
||||||
|
|
||||||
|
let resolved = ensure_draw_order_table(&mut app.tabs[i].scene.document, block);
|
||||||
|
assert_eq!(resolved, orphan_handle);
|
||||||
|
let dictionary_handle = app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.document
|
||||||
|
.extension_dictionary_handle(block)
|
||||||
|
.expect("block extension dictionary");
|
||||||
|
let ObjectType::Dictionary(dictionary) = app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.document
|
||||||
|
.objects
|
||||||
|
.get(&dictionary_handle)
|
||||||
|
.expect("dictionary object")
|
||||||
|
else {
|
||||||
|
panic!("extension object must be a dictionary");
|
||||||
|
};
|
||||||
|
assert_eq!(dictionary.get(SortEntitiesTable::DICTIONARY_KEY), Some(orphan_handle));
|
||||||
|
let ObjectType::SortEntitiesTable(table) = app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.document
|
||||||
|
.objects
|
||||||
|
.get(&orphan_handle)
|
||||||
|
.expect("sort table")
|
||||||
|
else {
|
||||||
|
panic!("orphan must remain a sort table");
|
||||||
|
};
|
||||||
|
assert_eq!(table.owner_handle, dictionary_handle);
|
||||||
|
assert!(table.entries().any(|entry| {
|
||||||
|
entry.entity_handle == entity && entry.sort_handle.value() == 7
|
||||||
|
}));
|
||||||
|
assert!(app.tabs[i].scene.document.allocate_handle().value() > orphan_handle.value());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draworder_front_moves_hatch_in_front_of_all() {
|
fn draworder_front_moves_hatch_in_front_of_all() {
|
||||||
let mut app = fresh_app();
|
let mut app = fresh_app();
|
||||||
@@ -2175,18 +2333,18 @@ mod tests {
|
|||||||
_ => panic!("Expected Relaunch for shortcut B"),
|
_ => panic!("Expected Relaunch for shortcut B"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Shortcut 'A' -> Advances to PickReference (above)
|
// 3. Shortcut 'A' -> Advances to reference gathering (above)
|
||||||
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
||||||
assert!(!cmd.needs_entity_pick());
|
assert!(!cmd.needs_entity_pick());
|
||||||
let res = cmd.on_text_input("A");
|
let res = cmd.on_text_input("A");
|
||||||
assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint)));
|
assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint)));
|
||||||
assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'A'");
|
assert!(cmd.is_selection_gathering());
|
||||||
|
|
||||||
// 4. Shortcut 'U' -> Advances to PickReference (under)
|
// 4. Shortcut 'U' -> Advances to reference gathering (under)
|
||||||
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
||||||
let res = cmd.on_text_input("U");
|
let res = cmd.on_text_input("U");
|
||||||
assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint)));
|
assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint)));
|
||||||
assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'U'");
|
assert!(cmd.is_selection_gathering());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2198,14 +2356,15 @@ mod tests {
|
|||||||
let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default()));
|
let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default()));
|
||||||
|
|
||||||
// Above with viewport pick
|
// Above with a gathered viewport selection
|
||||||
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
||||||
let _ = cmd.on_text_input("A");
|
let _ = cmd.on_text_input("A");
|
||||||
assert!(cmd.needs_entity_pick());
|
assert!(cmd.is_selection_gathering());
|
||||||
let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO);
|
let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]);
|
||||||
|
let pick_res = cmd.on_enter();
|
||||||
match pick_res {
|
match pick_res {
|
||||||
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
|
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
|
||||||
assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value()));
|
assert_eq!(relaunch_cmd, format!("DRAWORDER ABOVE {:x}", h_ref.value()));
|
||||||
assert_eq!(handles, vec![h_hatch]);
|
assert_eq!(handles, vec![h_hatch]);
|
||||||
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
||||||
let _ = app.run_command_line(&relaunch_cmd);
|
let _ = app.run_command_line(&relaunch_cmd);
|
||||||
@@ -2218,14 +2377,15 @@ mod tests {
|
|||||||
let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value());
|
let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value());
|
||||||
assert!(hatch_sort > ref_sort, "Hatch must be above reference after viewport pick");
|
assert!(hatch_sort > ref_sort, "Hatch must be above reference after viewport pick");
|
||||||
|
|
||||||
// Under with viewport pick
|
// Under with a gathered viewport selection
|
||||||
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
||||||
let _ = cmd.on_text_input("U");
|
let _ = cmd.on_text_input("U");
|
||||||
assert!(cmd.needs_entity_pick());
|
assert!(cmd.is_selection_gathering());
|
||||||
let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO);
|
let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]);
|
||||||
|
let pick_res = cmd.on_enter();
|
||||||
match pick_res {
|
match pick_res {
|
||||||
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
|
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
|
||||||
assert_eq!(relaunch_cmd, format!("DRAWORDER U {:x}", h_ref.value()));
|
assert_eq!(relaunch_cmd, format!("DRAWORDER UNDER {:x}", h_ref.value()));
|
||||||
assert_eq!(handles, vec![h_hatch]);
|
assert_eq!(handles, vec![h_hatch]);
|
||||||
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
||||||
let _ = app.run_command_line(&relaunch_cmd);
|
let _ = app.run_command_line(&relaunch_cmd);
|
||||||
@@ -2252,9 +2412,10 @@ mod tests {
|
|||||||
let _ = cmd.on_text_input("Above");
|
let _ = cmd.on_text_input("Above");
|
||||||
let hex_input = format!("0x{:x}", h_ref.value());
|
let hex_input = format!("0x{:x}", h_ref.value());
|
||||||
let typed_res = cmd.on_text_input(&hex_input);
|
let typed_res = cmd.on_text_input(&hex_input);
|
||||||
match typed_res {
|
assert!(matches!(typed_res, Some(crate::command::CmdResult::NeedPoint)));
|
||||||
Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => {
|
match cmd.on_enter() {
|
||||||
assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value()));
|
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
|
||||||
|
assert_eq!(relaunch_cmd, format!("DRAWORDER ABOVE {:x}", h_ref.value()));
|
||||||
assert_eq!(handles, vec![h_hatch]);
|
assert_eq!(handles, vec![h_hatch]);
|
||||||
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
||||||
let _ = app.run_command_line(&relaunch_cmd);
|
let _ = app.run_command_line(&relaunch_cmd);
|
||||||
@@ -2306,4 +2467,35 @@ mod tests {
|
|||||||
let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value());
|
let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value());
|
||||||
assert!(hatch_sort > line_sort);
|
assert!(hatch_sort > line_sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draworder_mode_aliases_route_directly() {
|
||||||
|
let mut app = fresh_app();
|
||||||
|
let i = app.active_tab;
|
||||||
|
let line = app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.add_entity_clone(EntityType::Line(Default::default()));
|
||||||
|
let hatch = app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.add_entity_clone(EntityType::Hatch(Default::default()));
|
||||||
|
|
||||||
|
app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.replace_selection(std::iter::once(hatch).collect());
|
||||||
|
let _ = app.run_command_line("DRAWORDER_FRONT");
|
||||||
|
let entries = effective_sort_map(&app);
|
||||||
|
assert!(
|
||||||
|
entries.get(&hatch.value()).copied().unwrap_or(hatch.value())
|
||||||
|
> entries.get(&line.value()).copied().unwrap_or(line.value())
|
||||||
|
);
|
||||||
|
|
||||||
|
app.tabs[i]
|
||||||
|
.scene
|
||||||
|
.replace_selection(std::iter::once(hatch).collect());
|
||||||
|
let _ = app.run_command_line("DRAWORDER_ABOVE");
|
||||||
|
assert!(app.tabs[i]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|command| command.name() == "DRAWORDER" && command.is_selection_gathering()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -240,6 +240,17 @@ impl DocumentTab {
|
|||||||
.and_then(|index| self.block_edits.get_mut(index))
|
.and_then(|index| self.block_edits.get_mut(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The [`crate::scene::sketch_constraints::SketchScope`] a new persistent
|
||||||
|
/// constraint should attach to right now — whatever's actually being
|
||||||
|
/// edited: the open block definition if a BEDIT session is active, model
|
||||||
|
/// space otherwise.
|
||||||
|
pub(super) fn current_sketch_scope(&self) -> crate::scene::sketch_constraints::SketchScope {
|
||||||
|
match self.active_block_edit_session() {
|
||||||
|
Some(session) => crate::scene::sketch_constraints::SketchScope::Block(session.br_handle),
|
||||||
|
None => crate::scene::sketch_constraints::SketchScope::ModelSpace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The active WCS↔UCS converter for this tab — identity when no UCS is set.
|
/// The active WCS↔UCS converter for this tab — identity when no UCS is set.
|
||||||
/// Every consumer that needs UCS-relative coordinates goes through this.
|
/// Every consumer that needs UCS-relative coordinates goes through this.
|
||||||
pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform {
|
pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform {
|
||||||
@@ -644,7 +655,11 @@ impl DocumentTab {
|
|||||||
/// full entity store.
|
/// full entity store.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(super) enum HistorySnapshot {
|
pub(super) enum HistorySnapshot {
|
||||||
Delta(DeltaSnapshot),
|
// Boxed: `DeltaSnapshot` grew past the other variants once
|
||||||
|
// `sketch_constraints` (ERASE-undo fix) was added, and every undo/redo
|
||||||
|
// stack slot costs as much as this enum's largest variant regardless of
|
||||||
|
// which one it actually holds.
|
||||||
|
Delta(Box<DeltaSnapshot>),
|
||||||
ObjectVisibility(ObjectVisibilitySnapshot),
|
ObjectVisibility(ObjectVisibilitySnapshot),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +692,18 @@ impl HistorySnapshot {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(0, |(before, after)| before.len().saturating_add(after.len())),
|
.map_or(0, |(before, after)| before.len().saturating_add(after.len())),
|
||||||
)
|
)
|
||||||
|
.saturating_add(
|
||||||
|
d.sketch_constraints
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.before.constraints.len().saturating_add(entry.after.constraints.len()))
|
||||||
|
.sum::<usize>()
|
||||||
|
.saturating_mul(96),
|
||||||
|
)
|
||||||
|
.saturating_add(
|
||||||
|
d.named_parameters
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |(before, after)| before.len().saturating_add(after.len()).saturating_mul(128)),
|
||||||
|
)
|
||||||
.saturating_add(d.label.len()),
|
.saturating_add(d.label.len()),
|
||||||
HistorySnapshot::ObjectVisibility(v) => v
|
HistorySnapshot::ObjectVisibility(v) => v
|
||||||
.before
|
.before
|
||||||
@@ -733,6 +760,17 @@ pub(super) struct DeltaSnapshot {
|
|||||||
/// Opposite non-entity document state. `apply_delta_state` swaps this with
|
/// Opposite non-entity document state. `apply_delta_state` swaps this with
|
||||||
/// the live structure, so the same allocation shuttles between undo/redo.
|
/// the live structure, so the same allocation shuttles between undo/redo.
|
||||||
pub(super) structure: Option<StructureSnapshot>,
|
pub(super) structure: Option<StructureSnapshot>,
|
||||||
|
/// Sketch-constraint scopes this same command changed alongside its
|
||||||
|
/// entities (e.g. ERASE removing a constraint set's touched constraints)
|
||||||
|
/// — `sketch_constraints` lives on `Scene`, not in `document`/
|
||||||
|
/// `document.objects`, so it needs its own channel here rather than
|
||||||
|
/// riding along with `entities`/`structure`. Almost always empty.
|
||||||
|
pub(super) sketch_constraints: Vec<SketchConstraintsEntryDelta>,
|
||||||
|
/// Drawing-wide parameter table changed by this transaction.
|
||||||
|
pub(super) named_parameters: Option<(
|
||||||
|
crate::scene::named_parameters::ParameterTable,
|
||||||
|
crate::scene::named_parameters::ParameterTable,
|
||||||
|
)>,
|
||||||
pub(super) label: String,
|
pub(super) label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,6 +831,15 @@ pub(super) struct ObjectEntryDelta {
|
|||||||
pub(super) after: Option<acadrust::objects::ObjectType>,
|
pub(super) after: Option<acadrust::objects::ObjectType>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One sketch-constraint scope's before/after image within an entity delta.
|
||||||
|
/// This keeps constraint cleanup atomic with edits such as entity erasure.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct SketchConstraintsEntryDelta {
|
||||||
|
pub(super) scope: crate::scene::sketch_constraints::SketchScope,
|
||||||
|
pub(super) before: crate::scene::sketch_constraints::SketchConstraintSet,
|
||||||
|
pub(super) after: crate::scene::sketch_constraints::SketchConstraintSet,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct HistoryState {
|
pub(super) struct HistoryState {
|
||||||
pub(super) undo_stack: Vec<HistorySnapshot>,
|
pub(super) undo_stack: Vec<HistorySnapshot>,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::{
|
use super::{
|
||||||
document::{
|
document::{
|
||||||
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
|
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
|
||||||
PendingHistorySnapshot, StructureSnapshot, TableEntryDelta,
|
PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta,
|
||||||
},
|
},
|
||||||
OpenCADStudio,
|
OpenCADStudio,
|
||||||
};
|
};
|
||||||
@@ -225,9 +225,11 @@ impl OpenCADStudio {
|
|||||||
dirty_after: true,
|
dirty_after: true,
|
||||||
active_layer: None,
|
active_layer: None,
|
||||||
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
|
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
|
||||||
|
sketch_constraints: Vec::new(),
|
||||||
|
named_parameters: None,
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) {
|
pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) {
|
||||||
@@ -334,9 +336,11 @@ impl OpenCADStudio {
|
|||||||
active_layer: (pending.active_layer != active_layer_after)
|
active_layer: (pending.active_layer != active_layer_after)
|
||||||
.then_some((pending.active_layer, active_layer_after)),
|
.then_some((pending.active_layer, active_layer_after)),
|
||||||
structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)),
|
structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)),
|
||||||
|
sketch_constraints: Vec::new(),
|
||||||
|
named_parameters: None,
|
||||||
label: pending.label,
|
label: pending.label,
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn finish_all_pending_history(&mut self) {
|
pub(super) fn finish_all_pending_history(&mut self) {
|
||||||
@@ -441,9 +445,11 @@ impl OpenCADStudio {
|
|||||||
dirty_after: self.tabs[i].dirty,
|
dirty_after: self.tabs[i].dirty,
|
||||||
active_layer: None,
|
active_layer: None,
|
||||||
structure: Some(StructureSnapshot::Layers(entries)),
|
structure: Some(StructureSnapshot::Layers(entries)),
|
||||||
|
sketch_constraints: Vec::new(),
|
||||||
|
named_parameters: None,
|
||||||
label: pending.label,
|
label: pending.label,
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn begin_text_style_undo(
|
pub(super) fn begin_text_style_undo(
|
||||||
@@ -496,9 +502,11 @@ impl OpenCADStudio {
|
|||||||
dirty_after: self.tabs[i].dirty,
|
dirty_after: self.tabs[i].dirty,
|
||||||
active_layer: None,
|
active_layer: None,
|
||||||
structure: Some(StructureSnapshot::TextStyles(entries)),
|
structure: Some(StructureSnapshot::TextStyles(entries)),
|
||||||
|
sketch_constraints: Vec::new(),
|
||||||
|
named_parameters: None,
|
||||||
label: pending.label,
|
label: pending.label,
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn begin_dim_style_undo(
|
pub(super) fn begin_dim_style_undo(
|
||||||
@@ -551,9 +559,11 @@ impl OpenCADStudio {
|
|||||||
dirty_after: self.tabs[i].dirty,
|
dirty_after: self.tabs[i].dirty,
|
||||||
active_layer: None,
|
active_layer: None,
|
||||||
structure: Some(StructureSnapshot::DimStyles(entries)),
|
structure: Some(StructureSnapshot::DimStyles(entries)),
|
||||||
|
sketch_constraints: Vec::new(),
|
||||||
|
named_parameters: None,
|
||||||
label: pending.label,
|
label: pending.label,
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn group_object_state(&self, i: usize) -> FxHashMap<Handle, acadrust::objects::ObjectType> {
|
fn group_object_state(&self, i: usize) -> FxHashMap<Handle, acadrust::objects::ObjectType> {
|
||||||
@@ -614,9 +624,11 @@ impl OpenCADStudio {
|
|||||||
dirty_after: self.tabs[i].dirty,
|
dirty_after: self.tabs[i].dirty,
|
||||||
active_layer: None,
|
active_layer: None,
|
||||||
structure: Some(StructureSnapshot::Objects(entries)),
|
structure: Some(StructureSnapshot::Objects(entries)),
|
||||||
|
sketch_constraints: Vec::new(),
|
||||||
|
named_parameters: None,
|
||||||
label: pending.label,
|
label: pending.label,
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn commit_style_undo(
|
pub(super) fn commit_style_undo(
|
||||||
@@ -647,9 +659,11 @@ impl OpenCADStudio {
|
|||||||
dim_names,
|
dim_names,
|
||||||
object_handles,
|
object_handles,
|
||||||
}),
|
}),
|
||||||
|
sketch_constraints: Vec::new(),
|
||||||
|
named_parameters: None,
|
||||||
label: "STYLE".to_string(),
|
label: "STYLE".to_string(),
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dimension copies still need a full snapshot because they clone a fresh
|
/// Dimension copies still need a full snapshot because they clone a fresh
|
||||||
@@ -727,7 +741,8 @@ impl OpenCADStudio {
|
|||||||
pending.label
|
pending.label
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let (entity_before, object_before) = rec.into_recorded_images();
|
let (entity_before, object_before, sketch_constraints_before, named_parameters_before) =
|
||||||
|
rec.into_recorded_images();
|
||||||
let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> = entity_before
|
let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> = entity_before
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(h, before)| {
|
.map(|(h, before)| {
|
||||||
@@ -746,6 +761,25 @@ impl OpenCADStudio {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let sketch_constraints: Vec<SketchConstraintsEntryDelta> = sketch_constraints_before
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(scope, before)| {
|
||||||
|
let after = self.tabs[i]
|
||||||
|
.scene
|
||||||
|
.sketch_constraint_set(scope)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(scope));
|
||||||
|
(before.constraints != after.constraints).then_some(SketchConstraintsEntryDelta {
|
||||||
|
scope,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let named_parameters = named_parameters_before.and_then(|before| {
|
||||||
|
let after = self.tabs[i].scene.named_parameters().clone();
|
||||||
|
(before != after).then_some((before, after))
|
||||||
|
});
|
||||||
let selected_after = self.tabs[i].scene.selected.iter().copied().collect();
|
let selected_after = self.tabs[i].scene.selected.iter().copied().collect();
|
||||||
let dirty_after = self.tabs[i].dirty;
|
let dirty_after = self.tabs[i].dirty;
|
||||||
let mut structure = pending.structure_before.map(StructureSnapshot::Full);
|
let mut structure = pending.structure_before.map(StructureSnapshot::Full);
|
||||||
@@ -781,9 +815,11 @@ impl OpenCADStudio {
|
|||||||
dirty_after,
|
dirty_after,
|
||||||
active_layer: None,
|
active_layer: None,
|
||||||
structure,
|
structure,
|
||||||
|
sketch_constraints,
|
||||||
|
named_parameters,
|
||||||
label: pending.label,
|
label: pending.label,
|
||||||
};
|
};
|
||||||
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
|
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply one side of a delta entry in place: `undo` restores each entity's
|
/// Apply one side of a delta entry in place: `undo` restores each entity's
|
||||||
@@ -909,6 +945,13 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
|
let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
|
||||||
let scene = &mut self.tabs[i].scene;
|
let scene = &mut self.tabs[i].scene;
|
||||||
|
for entry in &d.sketch_constraints {
|
||||||
|
let value = if undo { &entry.before } else { &entry.after };
|
||||||
|
*scene.sketch_constraint_set_mut(entry.scope) = value.clone();
|
||||||
|
}
|
||||||
|
if let Some((before, after)) = &d.named_parameters {
|
||||||
|
scene.named_parameters = if undo { before } else { after }.clone();
|
||||||
|
}
|
||||||
let (sel, dirty) = if undo {
|
let (sel, dirty) = if undo {
|
||||||
(&d.selected_before, d.dirty_before)
|
(&d.selected_before, d.dirty_before)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ pub(crate) mod config;
|
|||||||
pub use automation::{export_headless, serve};
|
pub use automation::{export_headless, serve};
|
||||||
mod command_driver;
|
mod command_driver;
|
||||||
pub(crate) mod commands;
|
pub(crate) mod commands;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
mod doc_api;
|
|
||||||
mod document;
|
mod document;
|
||||||
pub(crate) mod expr_eval;
|
pub(crate) mod expr_eval;
|
||||||
mod find_replace;
|
mod find_replace;
|
||||||
@@ -511,6 +509,7 @@ pub(super) struct OpenCADStudio {
|
|||||||
/// Selected-object count past which grips stop being generated
|
/// Selected-object count past which grips stop being generated
|
||||||
/// (GRIPOBJLIMIT, 0..=32767; 0 = no limit).
|
/// (GRIPOBJLIMIT, 0..=32767; 0 = no limit).
|
||||||
grip_object_limit: i32,
|
grip_object_limit: i32,
|
||||||
|
ncopy_bind: bool,
|
||||||
/// Drawing viewport cursor style (CURSORTYPE).
|
/// Drawing viewport cursor style (CURSORTYPE).
|
||||||
cursor_type: settings::CursorType,
|
cursor_type: settings::CursorType,
|
||||||
/// Explicit crosshair colour; `None` retains automatic contrast.
|
/// Explicit crosshair colour; `None` retains automatic contrast.
|
||||||
@@ -547,6 +546,14 @@ pub(super) struct OpenCADStudio {
|
|||||||
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file
|
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file
|
||||||
/// handler on each launch. Toggle with the FILEASSOC command.
|
/// handler on each launch. Toggle with the FILEASSOC command.
|
||||||
pub file_assoc_enabled: bool,
|
pub file_assoc_enabled: bool,
|
||||||
|
/// When true, saving creates native constraint objects alongside the
|
||||||
|
/// application's own persistence record. Existing native objects remain
|
||||||
|
/// synchronized regardless of this setting.
|
||||||
|
pub write_dwg_native_constraints: bool,
|
||||||
|
/// When true (default), a sketch constraint's viewport pill shows its
|
||||||
|
/// glyph plus a driven value or named-parameter name. When false, every
|
||||||
|
/// pill shows only the glyph.
|
||||||
|
pub show_constraint_values: bool,
|
||||||
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
|
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
|
||||||
/// 0 disables autosave.
|
/// 0 disables autosave.
|
||||||
pub savetime_min: i32,
|
pub savetime_min: i32,
|
||||||
@@ -1040,6 +1047,15 @@ pub(super) struct OpenCADStudio {
|
|||||||
/// edited. Seeded from `command_aliases` on open, committed back on close.
|
/// edited. Seeded from `command_aliases` on open, committed back on close.
|
||||||
alias_editor_rows: Vec<(String, String)>,
|
alias_editor_rows: Vec<(String, String)>,
|
||||||
|
|
||||||
|
// ── Named Parameters ──────────────────────────────────────────────────
|
||||||
|
/// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`,
|
||||||
|
/// this isn't a copy of a separate app-level store — the real state
|
||||||
|
/// lives per-document at `Scene::named_parameters`; this buffer is
|
||||||
|
/// seeded from the active tab's table on open and only written back to
|
||||||
|
/// it on Apply (`apply_named_parameter_editor_rows`,
|
||||||
|
/// `src/app/named_parameters.rs`).
|
||||||
|
named_parameter_editor_rows: Vec<crate::ui::window::named_parameters::ParamEditorRow>,
|
||||||
|
|
||||||
// ── Layout Manager Panel ──────────────────────────────────────────────
|
// ── Layout Manager Panel ──────────────────────────────────────────────
|
||||||
layout_manager_selected: String,
|
layout_manager_selected: String,
|
||||||
layout_manager_rename_buf: String,
|
layout_manager_rename_buf: String,
|
||||||
@@ -1717,6 +1733,7 @@ pub enum ModalKind {
|
|||||||
AttributeEditor,
|
AttributeEditor,
|
||||||
LayerDeleteWarning,
|
LayerDeleteWarning,
|
||||||
Aliases,
|
Aliases,
|
||||||
|
NamedParameters,
|
||||||
ScaleManager,
|
ScaleManager,
|
||||||
/// Add / remove the annotation scales a single selected object has a
|
/// Add / remove the annotation scales a single selected object has a
|
||||||
/// per-object representation for.
|
/// per-object representation for.
|
||||||
@@ -2071,6 +2088,11 @@ pub enum Message {
|
|||||||
/// Register or unregister as the .dwg/.dxf handler, from Options. Same
|
/// Register or unregister as the .dwg/.dxf handler, from Options. Same
|
||||||
/// setting the FILEASSOC command carries.
|
/// setting the FILEASSOC command carries.
|
||||||
FileAssocChanged(bool),
|
FileAssocChanged(bool),
|
||||||
|
/// Toggle writing native constraint objects on save.
|
||||||
|
WriteDwgNativeConstraintsChanged(bool),
|
||||||
|
/// Toggle showing driven values/named-parameter names on constraint
|
||||||
|
/// pills, from Options. See `show_constraint_values`'s doc comment.
|
||||||
|
ShowConstraintValuesChanged(bool),
|
||||||
/// Switch the interface language and redraw localized views.
|
/// Switch the interface language and redraw localized views.
|
||||||
LanguageChanged(crate::i18n::Language),
|
LanguageChanged(crate::i18n::Language),
|
||||||
/// Drop every entity from the active drawing.
|
/// Drop every entity from the active drawing.
|
||||||
@@ -2494,6 +2516,10 @@ pub enum Message {
|
|||||||
CloseLayoutList,
|
CloseLayoutList,
|
||||||
/// Cycle the coordinate readout mode ($COORDS): static → live → polar.
|
/// Cycle the coordinate readout mode ($COORDS): static → live → polar.
|
||||||
CycleCoordsMode,
|
CycleCoordsMode,
|
||||||
|
/// Removes one flagged redundant or conflicting constraint from the
|
||||||
|
/// current sketch scope.
|
||||||
|
/// No-op if the scope currently has no flagged conflict.
|
||||||
|
ResolveOneSketchConflict,
|
||||||
/// Toggle the status-bar customization menu open/closed.
|
/// Toggle the status-bar customization menu open/closed.
|
||||||
ToggleStatusBarMenu,
|
ToggleStatusBarMenu,
|
||||||
/// Close the status-bar customization menu.
|
/// Close the status-bar customization menu.
|
||||||
@@ -2789,6 +2815,46 @@ pub enum Message {
|
|||||||
AliasEditorRemove(usize),
|
AliasEditorRemove(usize),
|
||||||
/// Commit the edited rows to the alias table (Apply button); stays open.
|
/// Commit the edited rows to the alias table (Apply button); stays open.
|
||||||
AliasEditorApply,
|
AliasEditorApply,
|
||||||
|
// ── Named Parameters (PARAMETERS) ───────────────────────────────────
|
||||||
|
/// Open the named-parameter editor, seeding rows from the active tab's
|
||||||
|
/// `Scene::named_parameters`.
|
||||||
|
NamedParametersOpen,
|
||||||
|
/// Live edit of the name or formula in row `idx`.
|
||||||
|
NamedParametersInput {
|
||||||
|
idx: usize,
|
||||||
|
field: crate::ui::window::named_parameters::ParamField,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
/// Append a blank parameter row.
|
||||||
|
NamedParametersAdd,
|
||||||
|
/// Remove parameter row `idx`.
|
||||||
|
NamedParametersRemove(usize),
|
||||||
|
/// Commit the edited rows to `Scene::named_parameters` (Apply button)
|
||||||
|
/// and re-solve every constraint that reads a named parameter; stays
|
||||||
|
/// open.
|
||||||
|
NamedParametersApply,
|
||||||
|
// ── Parameters / Constraints sections embedded in Properties ──────────
|
||||||
|
/// Live text of one column of parameter row `index`, keyed by its
|
||||||
|
/// `ParameterTable::iter()` position — see `PropValue::ParamRow`.
|
||||||
|
PropParamInput {
|
||||||
|
index: usize,
|
||||||
|
field: crate::ui::window::named_parameters::ParamField,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
/// Commit row `index`'s buffered edit for `field` to `Scene::
|
||||||
|
/// named_parameters` (Enter / losing focus) and re-solve whatever it
|
||||||
|
/// drives.
|
||||||
|
PropParamCommit {
|
||||||
|
index: usize,
|
||||||
|
field: crate::ui::window::named_parameters::ParamField,
|
||||||
|
},
|
||||||
|
/// Remove parameter row `index` immediately.
|
||||||
|
PropParamDelete(usize),
|
||||||
|
/// Append a fresh, uniquely-named parameter to `Scene::named_parameters`.
|
||||||
|
PropParamAddNew,
|
||||||
|
/// A Constraints-section row was clicked: select every entity in the
|
||||||
|
/// list (replacing the current selection) in the viewport.
|
||||||
|
PropConstraintLinkClick(Vec<acadrust::Handle>),
|
||||||
// ── About window ────────────────────────────────────────────────────
|
// ── About window ────────────────────────────────────────────────────
|
||||||
AboutOpen,
|
AboutOpen,
|
||||||
/// Close whatever in-canvas modal dialog is open (Plan B).
|
/// Close whatever in-canvas modal dialog is open (Plan B).
|
||||||
@@ -3461,6 +3527,7 @@ impl OpenCADStudio {
|
|||||||
double_click_block_refedit: false,
|
double_click_block_refedit: false,
|
||||||
double_click_block_attedit: true,
|
double_click_block_attedit: true,
|
||||||
grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT,
|
grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT,
|
||||||
|
ncopy_bind: false,
|
||||||
cursor_type: settings::CursorType::Crosshair,
|
cursor_type: settings::CursorType::Crosshair,
|
||||||
crosshair_color: None,
|
crosshair_color: None,
|
||||||
crosshair_color_input: String::new(),
|
crosshair_color_input: String::new(),
|
||||||
@@ -3478,6 +3545,8 @@ impl OpenCADStudio {
|
|||||||
dimension_continue_mode: 1,
|
dimension_continue_mode: 1,
|
||||||
backup_on_save: true,
|
backup_on_save: true,
|
||||||
file_assoc_enabled: true,
|
file_assoc_enabled: true,
|
||||||
|
write_dwg_native_constraints: false,
|
||||||
|
show_constraint_values: true,
|
||||||
savetime_min: 10,
|
savetime_min: 10,
|
||||||
default_bg_color: None,
|
default_bg_color: None,
|
||||||
default_paper_bg_color: None,
|
default_paper_bg_color: None,
|
||||||
@@ -3669,6 +3738,7 @@ impl OpenCADStudio {
|
|||||||
// Command aliases (populated from ocad.pgp just after construction)
|
// Command aliases (populated from ocad.pgp just after construction)
|
||||||
command_aliases: rustc_hash::FxHashMap::default(),
|
command_aliases: rustc_hash::FxHashMap::default(),
|
||||||
alias_editor_rows: Vec::new(),
|
alias_editor_rows: Vec::new(),
|
||||||
|
named_parameter_editor_rows: Vec::new(),
|
||||||
// Layout Manager
|
// Layout Manager
|
||||||
layout_manager_selected: "Model".to_string(),
|
layout_manager_selected: "Model".to_string(),
|
||||||
layer_state_selected: None,
|
layer_state_selected: None,
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ pub(crate) struct HostSession<'a> {
|
|||||||
app: &'a mut OpenCADStudio,
|
app: &'a mut OpenCADStudio,
|
||||||
tab: usize,
|
tab: usize,
|
||||||
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
|
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
|
||||||
/// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`).
|
|
||||||
doc_api_pending: Option<super::history::PendingDelta>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> HostSession<'a> {
|
impl<'a> HostSession<'a> {
|
||||||
@@ -26,7 +24,6 @@ impl<'a> HostSession<'a> {
|
|||||||
app,
|
app,
|
||||||
tab,
|
tab,
|
||||||
doc_store: None,
|
doc_store: None,
|
||||||
doc_api_pending: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,39 +242,6 @@ impl<'a> HostSession<'a> {
|
|||||||
self.app.push_undo_snapshot(self.tab, label);
|
self.app.push_undo_snapshot(self.tab, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DocApi v2 accessors (used by `app::doc_api` backend) ───────────────
|
|
||||||
|
|
||||||
pub(crate) fn scene(&self) -> &crate::scene::Scene {
|
|
||||||
&self.app.tabs[self.tab].scene
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn scene_mut(&mut self) -> &mut crate::scene::Scene {
|
|
||||||
&mut self.app.tabs[self.tab].scene
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DocApi `push_undo`: capture entity and document-structure changes for one op.
|
|
||||||
pub(crate) fn begin_doc_api_undo(&mut self, label: &str) {
|
|
||||||
self.doc_api_pending = self.app.begin_undo(self.tab, label, 1, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn cancel_doc_api_undo(&mut self) {
|
|
||||||
if self.doc_api_pending.take().is_some() {
|
|
||||||
self.scene_mut().take_undo_recording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DocApi `finalize_op`: close the delta + republish the document view.
|
|
||||||
/// The underlying entity op (add/update/remove/register_solid_model) already
|
|
||||||
/// bumped `geometry_epoch`, so we do NOT bump again here — exactly one undo
|
|
||||||
/// step is recorded, and the epoch has advanced for this op.
|
|
||||||
pub(crate) fn commit_doc_api_undo(&mut self) {
|
|
||||||
self.set_dirty();
|
|
||||||
if let Some(pending) = self.doc_api_pending.take() {
|
|
||||||
self.app.commit_undo_delta(self.tab, pending);
|
|
||||||
}
|
|
||||||
self.publish_document_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_dirty(&mut self) {
|
pub fn set_dirty(&mut self) {
|
||||||
self.app.tabs[self.tab].dirty = true;
|
self.app.tabs[self.tab].dirty = true;
|
||||||
}
|
}
|
||||||
@@ -396,10 +360,6 @@ impl HostApi for HostSession<'_> {
|
|||||||
fn close_document_view_v4(&mut self, tab_id: u64) {
|
fn close_document_view_v4(&mut self, tab_id: u64) {
|
||||||
self.close_document_view_v4(tab_id)
|
self.close_document_view_v4(tab_id)
|
||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result<Vec<u8>, String> {
|
|
||||||
super::doc_api::execute_doc_api(self, tab_id, bytes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
|
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
|
||||||
|
|||||||
@@ -406,6 +406,37 @@ impl OpenCADStudio {
|
|||||||
read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()),
|
read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// Document-wide named-parameter table embedded here
|
||||||
|
// instead of a separate modal — see `PropValue::
|
||||||
|
// ParamRow`'s doc comment. Belongs on the no-
|
||||||
|
// selection (drawing-level) page, not a per-entity
|
||||||
|
// one: a parameter isn't owned by any one entity.
|
||||||
|
PropSection {
|
||||||
|
title: t!("Parameters").into_owned(),
|
||||||
|
props: {
|
||||||
|
let mut props: Vec<Property> = scene
|
||||||
|
.named_parameters()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, p)| Property {
|
||||||
|
label: String::new(),
|
||||||
|
field: "named_parameter",
|
||||||
|
value: PropValue::ParamRow {
|
||||||
|
index,
|
||||||
|
name: p.name.clone(),
|
||||||
|
formula: p.source.clone(),
|
||||||
|
resolved: scene.named_parameters().resolve(&p.name).map_err(|e| e.to_string()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
props.push(Property {
|
||||||
|
label: String::new(),
|
||||||
|
field: "named_parameter_add",
|
||||||
|
value: PropValue::ParamAddRow,
|
||||||
|
});
|
||||||
|
props
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
ui::PropertiesPanel {
|
ui::PropertiesPanel {
|
||||||
title: t!("No selection").into_owned(),
|
title: t!("No selection").into_owned(),
|
||||||
@@ -2126,6 +2157,53 @@ impl OpenCADStudio {
|
|||||||
if compact_solid {
|
if compact_solid {
|
||||||
retain_compact_solid_sections(&mut sections);
|
retain_compact_solid_sections(&mut sections);
|
||||||
}
|
}
|
||||||
|
// Persistent sketch-constraint list for this entity —
|
||||||
|
// the Properties-panel analogue of the viewport's
|
||||||
|
// constraint glyph pills: one row per constraint
|
||||||
|
// touching `handle`, clickable to select every entity
|
||||||
|
// it relates (PropConstraintLinkClick). Omitted
|
||||||
|
// entirely when the entity has none — an unconstrained
|
||||||
|
// entity (the common case) shouldn't gain empty
|
||||||
|
// bookkeeping.
|
||||||
|
{
|
||||||
|
let scope = self.tabs[i].current_sketch_scope();
|
||||||
|
let scene = &self.tabs[i].scene;
|
||||||
|
if let Some(set) = scene.sketch_constraint_set(scope) {
|
||||||
|
let props: Vec<crate::scene::model::object::Property> = set
|
||||||
|
.constraints_touching(handle)
|
||||||
|
.map(|c| {
|
||||||
|
let conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
|
||||||
|
let value_suffix = match &c.driving_param {
|
||||||
|
Some(crate::scene::named_parameters::DrivingValue::Literal(v)) => {
|
||||||
|
format!(" = {v:.2}")
|
||||||
|
}
|
||||||
|
Some(crate::scene::named_parameters::DrivingValue::Named(name)) => {
|
||||||
|
match scene.named_parameters().resolve(name) {
|
||||||
|
Ok(v) => format!(" = {name} ({v:.2})"),
|
||||||
|
Err(_) => format!(" = {name} (?)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let label = format!("{} {:?}{}", c.kind.glyph_symbol(), c.kind, value_suffix);
|
||||||
|
let mut handles: Vec<acadrust::Handle> = c.refs.iter().map(|r| r.entity).collect();
|
||||||
|
handles.sort_by_key(|h| h.value());
|
||||||
|
handles.dedup();
|
||||||
|
crate::scene::model::object::Property {
|
||||||
|
label,
|
||||||
|
field: "sketch_constraint",
|
||||||
|
value: crate::scene::model::object::PropValue::EntityLink { handles, conflicting },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !props.is_empty() {
|
||||||
|
sections.push(crate::scene::model::object::PropSection {
|
||||||
|
title: t!("Constraints").into_owned(),
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let title = match entity {
|
let title = match entity {
|
||||||
acadrust::EntityType::Insert(ins) => {
|
acadrust::EntityType::Insert(ins) => {
|
||||||
let is_xref = self.tabs[i]
|
let is_xref = self.tabs[i]
|
||||||
@@ -3112,6 +3190,15 @@ fn make_sections_read_only(
|
|||||||
}
|
}
|
||||||
PropValue::Stepper { display, .. } => display.clone(),
|
PropValue::Stepper { display, .. } => display.clone(),
|
||||||
PropValue::AttrText { value, .. } => value.clone(),
|
PropValue::AttrText { value, .. } => value.clone(),
|
||||||
|
// The Constraints/Parameters sections aren't per-entity editable
|
||||||
|
// fields to begin with (a constraint link is a navigation
|
||||||
|
// action, not a value; a parameter belongs to the whole
|
||||||
|
// document, not the locked entity) — locking the entity doesn't
|
||||||
|
// change either, so just carry through readable text instead of
|
||||||
|
// collapsing them to a value that was never editable anyway.
|
||||||
|
PropValue::EntityLink { handles, .. } => format!("{} entity link(s)", handles.len()),
|
||||||
|
PropValue::ParamRow { name, formula, .. } => format!("{name} = {formula}"),
|
||||||
|
PropValue::ParamAddRow => String::new(),
|
||||||
};
|
};
|
||||||
property.field = "locked_read_only";
|
property.field = "locked_read_only";
|
||||||
property.value = PropValue::ReadOnly(text);
|
property.value = PropValue::ReadOnly(text);
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ pub struct UserSettings {
|
|||||||
/// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at
|
/// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at
|
||||||
/// all. 0 means no limit. The drawing header carries no slot for it.
|
/// all. 0 means no limit. The drawing header carries no slot for it.
|
||||||
pub grip_object_limit: i32,
|
pub grip_object_limit: i32,
|
||||||
|
/// Nested-copy symbol handling: false inserts, true binds.
|
||||||
|
pub ncopy_bind: bool,
|
||||||
/// Last Options page; unknown saved names fall back without rejecting the config.
|
/// Last Options page; unknown saved names fall back without rejecting the config.
|
||||||
#[serde(default, deserialize_with = "deserialize_options_tab")]
|
#[serde(default, deserialize_with = "deserialize_options_tab")]
|
||||||
pub options_tab: crate::ui::window::options::OptionsTab,
|
pub options_tab: crate::ui::window::options::OptionsTab,
|
||||||
@@ -276,6 +278,16 @@ pub struct UserSettings {
|
|||||||
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
|
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
|
||||||
/// handler on every launch. Toggle with the FILEASSOC command.
|
/// handler on every launch. Toggle with the FILEASSOC command.
|
||||||
pub file_assoc_enabled: bool,
|
pub file_assoc_enabled: bool,
|
||||||
|
/// When true, saving also writes sketch constraints as native drawing
|
||||||
|
/// objects alongside the application's own persistence record.
|
||||||
|
#[serde(default)]
|
||||||
|
pub write_dwg_native_constraints: bool,
|
||||||
|
/// When true (default), a sketch constraint's viewport pill shows its
|
||||||
|
/// glyph plus a driven value or named-parameter name. When false, every
|
||||||
|
/// pill shows just the bare glyph, so the value/name text doesn't cover
|
||||||
|
/// canvas detail on a dense sketch.
|
||||||
|
#[serde(default = "default_show_constraint_values")]
|
||||||
|
pub show_constraint_values: bool,
|
||||||
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
|
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
|
||||||
/// 0 disables autosave.
|
/// 0 disables autosave.
|
||||||
pub savetime_min: i32,
|
pub savetime_min: i32,
|
||||||
@@ -342,6 +354,10 @@ fn default_dimension_continue_mode() -> i16 {
|
|||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_show_constraint_values() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error>
|
fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@@ -372,6 +388,7 @@ impl Default for UserSettings {
|
|||||||
double_click_block_refedit: false,
|
double_click_block_refedit: false,
|
||||||
double_click_block_attedit: true,
|
double_click_block_attedit: true,
|
||||||
grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT,
|
grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT,
|
||||||
|
ncopy_bind: false,
|
||||||
cursor_type: CursorType::Crosshair,
|
cursor_type: CursorType::Crosshair,
|
||||||
crosshair_color: None,
|
crosshair_color: None,
|
||||||
lineweight_display_scale: 100,
|
lineweight_display_scale: 100,
|
||||||
@@ -395,6 +412,8 @@ impl Default for UserSettings {
|
|||||||
textfill: true,
|
textfill: true,
|
||||||
backup_on_save: true,
|
backup_on_save: true,
|
||||||
file_assoc_enabled: true,
|
file_assoc_enabled: true,
|
||||||
|
write_dwg_native_constraints: false,
|
||||||
|
show_constraint_values: true,
|
||||||
savetime_min: 10,
|
savetime_min: 10,
|
||||||
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
||||||
pick_add: true,
|
pick_add: true,
|
||||||
|
|||||||
@@ -890,6 +890,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
|
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
|
||||||
|
if self.ribbon.escape_extension() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
// Esc drops an unconsumed one-shot snap override and closes
|
// Esc drops an unconsumed one-shot snap override and closes
|
||||||
// its menu (#337). Falls through — Esc keeps its usual effect.
|
// its menu (#337). Falls through — Esc keeps its usual effect.
|
||||||
self.snap_override_popup = None;
|
self.snap_override_popup = None;
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ impl OpenCADStudio {
|
|||||||
double_click_block_refedit: self.double_click_block_refedit,
|
double_click_block_refedit: self.double_click_block_refedit,
|
||||||
double_click_block_attedit: self.double_click_block_attedit,
|
double_click_block_attedit: self.double_click_block_attedit,
|
||||||
grip_object_limit: self.grip_object_limit,
|
grip_object_limit: self.grip_object_limit,
|
||||||
|
ncopy_bind: self.ncopy_bind,
|
||||||
cursor_type: self.cursor_type,
|
cursor_type: self.cursor_type,
|
||||||
crosshair_color: self.crosshair_color,
|
crosshair_color: self.crosshair_color,
|
||||||
lineweight_display_scale: self.lineweight_display_scale,
|
lineweight_display_scale: self.lineweight_display_scale,
|
||||||
@@ -432,6 +433,8 @@ impl OpenCADStudio {
|
|||||||
textfill: crate::scene::text::sdf_atlas::textfill(),
|
textfill: crate::scene::text::sdf_atlas::textfill(),
|
||||||
backup_on_save: self.backup_on_save,
|
backup_on_save: self.backup_on_save,
|
||||||
file_assoc_enabled: self.file_assoc_enabled,
|
file_assoc_enabled: self.file_assoc_enabled,
|
||||||
|
write_dwg_native_constraints: self.write_dwg_native_constraints,
|
||||||
|
show_constraint_values: self.show_constraint_values,
|
||||||
savetime_min: self.savetime_min,
|
savetime_min: self.savetime_min,
|
||||||
default_save_format: self.default_save_format.clone(),
|
default_save_format: self.default_save_format.clone(),
|
||||||
pick_add: self.pick_add,
|
pick_add: self.pick_add,
|
||||||
@@ -467,6 +470,7 @@ impl OpenCADStudio {
|
|||||||
self.double_click_block_refedit = s.double_click_block_refedit;
|
self.double_click_block_refedit = s.double_click_block_refedit;
|
||||||
self.double_click_block_attedit = s.double_click_block_attedit;
|
self.double_click_block_attedit = s.double_click_block_attedit;
|
||||||
self.grip_object_limit = s.grip_object_limit.clamp(0, 32767);
|
self.grip_object_limit = s.grip_object_limit.clamp(0, 32767);
|
||||||
|
self.ncopy_bind = s.ncopy_bind;
|
||||||
self.cursor_type = s.cursor_type;
|
self.cursor_type = s.cursor_type;
|
||||||
self.crosshair_color = s.crosshair_color;
|
self.crosshair_color = s.crosshair_color;
|
||||||
self.crosshair_color_input = s
|
self.crosshair_color_input = s
|
||||||
@@ -510,6 +514,8 @@ impl OpenCADStudio {
|
|||||||
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
|
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
|
||||||
self.backup_on_save = s.backup_on_save;
|
self.backup_on_save = s.backup_on_save;
|
||||||
self.file_assoc_enabled = s.file_assoc_enabled;
|
self.file_assoc_enabled = s.file_assoc_enabled;
|
||||||
|
self.write_dwg_native_constraints = s.write_dwg_native_constraints;
|
||||||
|
self.show_constraint_values = s.show_constraint_values;
|
||||||
self.savetime_min = s.savetime_min;
|
self.savetime_min = s.savetime_min;
|
||||||
self.default_save_format =
|
self.default_save_format =
|
||||||
crate::io::canonical_save_format(&s.default_save_format).to_string();
|
crate::io::canonical_save_format(&s.default_save_format).to_string();
|
||||||
@@ -1438,6 +1444,11 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||||||
self.tabs[i].scene.material_base_dir =
|
self.tabs[i].scene.material_base_dir =
|
||||||
path.parent().map(std::path::Path::to_path_buf);
|
path.parent().map(std::path::Path::to_path_buf);
|
||||||
self.tabs[i].scene.document = doc;
|
self.tabs[i].scene.document = doc;
|
||||||
|
// Load persisted constraints after installing the document.
|
||||||
|
self.tabs[i].scene.load_sketch_constraints_from_document();
|
||||||
|
// named_parameters_design.md stage 2: same load-time hook,
|
||||||
|
// for the document-wide parameter table.
|
||||||
|
self.tabs[i].scene.load_named_parameters_from_document();
|
||||||
self.tabs[i].active_layer = self.tabs[i]
|
self.tabs[i].active_layer = self.tabs[i]
|
||||||
.scene
|
.scene
|
||||||
.document
|
.document
|
||||||
@@ -1701,6 +1712,13 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||||||
self.tabs[i].scene.document.header.user_real1 =
|
self.tabs[i].scene.document.header.user_real1 =
|
||||||
self.tabs[i].scene.annotation_scale as f64;
|
self.tabs[i].scene.annotation_scale as f64;
|
||||||
self.sync_solid_models_for_save(i);
|
self.sync_solid_models_for_save(i);
|
||||||
|
// Materialize constraints immediately before serialization.
|
||||||
|
self.tabs[i].scene.materialize_sketch_constraints_for_save();
|
||||||
|
// named_parameters_design.md stage 2: same save-time hook, for the
|
||||||
|
// document-wide parameter table.
|
||||||
|
self.tabs[i].scene.materialize_named_parameters_for_save();
|
||||||
|
// Keep the optional native constraint graph synchronized on save.
|
||||||
|
self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -2651,6 +2669,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||||||
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
||||||
self.stamp_header_sysvars(i);
|
self.stamp_header_sysvars(i);
|
||||||
self.sync_solid_models_for_save(i);
|
self.sync_solid_models_for_save(i);
|
||||||
|
self.tabs[i].scene.materialize_sketch_constraints_for_save();
|
||||||
|
self.tabs[i].scene.materialize_named_parameters_for_save();
|
||||||
|
self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints);
|
||||||
let tab_id = self.tabs[i].id;
|
let tab_id = self.tabs[i].id;
|
||||||
let bounds = crate::ui::wrap_bar::dropdown_bounds(
|
let bounds = crate::ui::wrap_bar::dropdown_bounds(
|
||||||
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
|
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
|
||||||
|
|||||||
@@ -3356,6 +3356,10 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::ResolveOneSketchConflict => {
|
||||||
|
self.resolve_one_sketch_conflict();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::TogglePolar => {
|
Message::TogglePolar => {
|
||||||
self.polar_mode ^= true;
|
self.polar_mode ^= true;
|
||||||
if self.polar_mode {
|
if self.polar_mode {
|
||||||
@@ -5926,6 +5930,65 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Named Parameters (PARAMETERS) ─────────────────────────────────
|
||||||
|
Message::NamedParametersOpen => {
|
||||||
|
let i = self.active_tab;
|
||||||
|
self.named_parameter_editor_rows = self.tabs[i]
|
||||||
|
.scene
|
||||||
|
.named_parameters()
|
||||||
|
.iter()
|
||||||
|
.map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone() })
|
||||||
|
.collect();
|
||||||
|
self.active_modal = Some(super::ModalKind::NamedParameters);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::NamedParametersInput { idx, field, value } => {
|
||||||
|
use crate::ui::window::named_parameters::ParamField;
|
||||||
|
if let Some(row) = self.named_parameter_editor_rows.get_mut(idx) {
|
||||||
|
match field {
|
||||||
|
ParamField::Name => row.name = value,
|
||||||
|
ParamField::Formula => row.formula = value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::NamedParametersAdd => {
|
||||||
|
self.named_parameter_editor_rows.push(crate::ui::window::named_parameters::ParamEditorRow::default());
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::NamedParametersRemove(idx) => {
|
||||||
|
if idx < self.named_parameter_editor_rows.len() {
|
||||||
|
self.named_parameter_editor_rows.remove(idx);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::NamedParametersApply => {
|
||||||
|
self.apply_named_parameter_editor_rows();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parameters / Constraints sections embedded in the
|
||||||
|
// Properties panel ──────────────────────────────────────────────
|
||||||
|
Message::PropParamInput { index, field, value } => {
|
||||||
|
self.tabs[self.active_tab]
|
||||||
|
.properties
|
||||||
|
.edit_buf
|
||||||
|
.insert(crate::ui::properties::FieldKey::Param(index, field), value);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::PropParamCommit { index, field } => self.on_prop_param_commit(index, field),
|
||||||
|
Message::PropParamDelete(index) => self.on_prop_param_delete(index),
|
||||||
|
Message::PropParamAddNew => self.on_prop_param_add_new(),
|
||||||
|
Message::PropConstraintLinkClick(handles) => {
|
||||||
|
let i = self.active_tab;
|
||||||
|
self.tabs[i].scene.deselect_all();
|
||||||
|
for h in handles {
|
||||||
|
self.tabs[i].scene.select_entity(h, false);
|
||||||
|
}
|
||||||
|
self.refresh_properties();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Options / About windows ───────────────────────────────────
|
// ── Options / About windows ───────────────────────────────────
|
||||||
Message::OptionsOpen => {
|
Message::OptionsOpen => {
|
||||||
self.active_modal = Some(super::ModalKind::Options);
|
self.active_modal = Some(super::ModalKind::Options);
|
||||||
@@ -6448,6 +6511,16 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::WriteDwgNativeConstraintsChanged(enabled) => {
|
||||||
|
self.write_dwg_native_constraints = enabled;
|
||||||
|
self.persist_settings_if_changed();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ShowConstraintValuesChanged(enabled) => {
|
||||||
|
self.show_constraint_values = enabled;
|
||||||
|
self.persist_settings_if_changed();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::LanguageChanged(language) => {
|
Message::LanguageChanged(language) => {
|
||||||
if self.language == language {
|
if self.language == language {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
|
|||||||
@@ -1246,7 +1246,7 @@ impl OpenCADStudio {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut seen_handles = rustc_hash::FxHashSet::default();
|
let mut seen_handles = rustc_hash::FxHashSet::default();
|
||||||
let edited_handles: Vec<_> = grip
|
let mut edited_handles: Vec<_> = grip
|
||||||
.targets
|
.targets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|target| target.handle)
|
.map(|target| target.handle)
|
||||||
@@ -1706,6 +1706,29 @@ impl OpenCADStudio {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Re-solve constrained neighbors on each drag frame and include
|
||||||
|
// their original state in the gesture's undo record.
|
||||||
|
let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles);
|
||||||
|
for (handle, _) in &solved_by_constraints {
|
||||||
|
let handle = *handle;
|
||||||
|
if !self.grip_preview_handles.contains(&handle) {
|
||||||
|
if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() {
|
||||||
|
self.grip_originals.push((handle, original));
|
||||||
|
}
|
||||||
|
self.grip_preview_handles.push(handle);
|
||||||
|
if !self.tabs[i].scene.meshes.contains_key(&handle) {
|
||||||
|
self.tabs[i].scene.preview_hidden.insert(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !edited_handles.contains(&handle) {
|
||||||
|
edited_handles.push(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (handle, new_entity) in solved_by_constraints {
|
||||||
|
if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||||
|
*slot = new_entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
let mesh_changes: Vec<_> = edited_handles
|
let mesh_changes: Vec<_> = edited_handles
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -3887,6 +3910,7 @@ impl OpenCADStudio {
|
|||||||
} else if self.tabs[i].active_cmd.as_ref()
|
} else if self.tabs[i].active_cmd.as_ref()
|
||||||
.is_some_and(|command| command.entity_pick_accepts_points())
|
.is_some_and(|command| command.entity_pick_accepts_points())
|
||||||
{
|
{
|
||||||
|
self.refresh_command_point_pick_context(i);
|
||||||
self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs))
|
self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs))
|
||||||
} else if self.tabs[i]
|
} else if self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
@@ -3963,6 +3987,7 @@ impl OpenCADStudio {
|
|||||||
// (LINE tangent to two circles, which needs both). When
|
// (LINE tangent to two circles, which needs both). When
|
||||||
// it does, sync last_point to the command's resolved
|
// it does, sync last_point to the command's resolved
|
||||||
// anchor since it replaced the picked coordinate.
|
// anchor since it replaced the picked coordinate.
|
||||||
|
self.refresh_command_point_pick_context(i);
|
||||||
let handled = self.tabs[i]
|
let handled = self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_mut()
|
.as_mut()
|
||||||
|
|||||||
@@ -805,6 +805,50 @@ bg={bg_ms:.1}ms n={view_count}"
|
|||||||
let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| {
|
let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| {
|
||||||
!cmd.needs_entity_pick() && !cmd.is_selection_gathering()
|
!cmd.needs_entity_pick() && !cmd.is_selection_gathering()
|
||||||
});
|
});
|
||||||
|
// Constraint glyphs are displayed in model space only.
|
||||||
|
let constraint_glyphs: Vec<(iced::Point, String, bool)> = if is_paper {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let (vw, vh) = sel_ref.vp_size;
|
||||||
|
let scope = tab.current_sketch_scope();
|
||||||
|
match tab.scene.sketch_constraint_set(scope) {
|
||||||
|
Some(set) if !set.constraints.is_empty() => {
|
||||||
|
let edit_frame = tab.scene.viewport_edit_frame((vw, vh));
|
||||||
|
let bounds = match &edit_frame {
|
||||||
|
Some((_, full)) => *full,
|
||||||
|
None => tab.scene.active_model_tile_bounds(vw, vh),
|
||||||
|
};
|
||||||
|
let (view_rot, eye) = if let Some((cam, _)) = &edit_frame {
|
||||||
|
(cam.view_proj_rte(bounds), cam.eye())
|
||||||
|
} else {
|
||||||
|
let cam = tab.scene.camera.borrow();
|
||||||
|
(cam.view_proj_rte(bounds), cam.eye())
|
||||||
|
};
|
||||||
|
set.constraints
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.enabled)
|
||||||
|
.filter_map(|c| {
|
||||||
|
let anchor = crate::scene::sketch_constraints::glyph_anchor(&tab.scene.document, c)?;
|
||||||
|
let screen = crate::scene::pick::grip::project_rte(
|
||||||
|
glam::DVec3::new(anchor.x, anchor.y, anchor.z),
|
||||||
|
view_rot,
|
||||||
|
eye,
|
||||||
|
bounds,
|
||||||
|
)?;
|
||||||
|
let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y);
|
||||||
|
let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
|
||||||
|
let label = if self.show_constraint_values {
|
||||||
|
crate::scene::sketch_constraints::glyph_label(c)
|
||||||
|
} else {
|
||||||
|
c.kind.glyph_symbol().to_string()
|
||||||
|
};
|
||||||
|
point.x.is_finite().then(|| (point, label, is_conflicting))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
crate::ui::overlay::selection_overlay(
|
crate::ui::overlay::selection_overlay(
|
||||||
std::sync::Arc::clone(&tab.scene.selection),
|
std::sync::Arc::clone(&tab.scene.selection),
|
||||||
snap_info,
|
snap_info,
|
||||||
@@ -847,6 +891,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
|||||||
grip_hot: self.model_space.grip_hot,
|
grip_hot: self.model_space.grip_hot,
|
||||||
grip_hover: self.model_space.grip_hover,
|
grip_hover: self.model_space.grip_hover,
|
||||||
},
|
},
|
||||||
|
constraint_glyphs,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2091,6 +2136,8 @@ bg={bg_ms:.1}ms n={view_count}"
|
|||||||
self.selection_cycling,
|
self.selection_cycling,
|
||||||
&self.statusbar_config,
|
&self.statusbar_config,
|
||||||
status_menu_data,
|
status_menu_data,
|
||||||
|
tab.scene.sketch_constraint_set(tab.current_sketch_scope()).and_then(|s| s.dof),
|
||||||
|
tab.scene.sketch_constraint_set(tab.current_sketch_scope()).map(|s| s.conflicts.len()).unwrap_or(0),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ impl OpenCADStudio {
|
|||||||
Some(K::About) => crate::tr!("modal", "about"),
|
Some(K::About) => crate::tr!("modal", "about"),
|
||||||
Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"),
|
Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"),
|
||||||
Some(K::Aliases) => crate::tr!("modal", "command-aliases"),
|
Some(K::Aliases) => crate::tr!("modal", "command-aliases"),
|
||||||
|
Some(K::NamedParameters) => crate::t!("Named Parameters").into_owned(),
|
||||||
Some(K::Options) => crate::tr!("action", "options"),
|
Some(K::Options) => crate::tr!("action", "options"),
|
||||||
Some(K::FindReplace) => crate::tr!("modal", "find-replace"),
|
Some(K::FindReplace) => crate::tr!("modal", "find-replace"),
|
||||||
Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"),
|
Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"),
|
||||||
@@ -173,6 +174,21 @@ impl OpenCADStudio {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
super::super::ModalKind::NamedParameters => {
|
||||||
|
let scene = &self.tabs[self.active_tab].scene;
|
||||||
|
sized_flow(
|
||||||
|
ex,
|
||||||
|
820,
|
||||||
|
520,
|
||||||
|
|flow| {
|
||||||
|
crate::ui::window::named_parameters::view_window(
|
||||||
|
&self.named_parameter_editor_rows,
|
||||||
|
scene,
|
||||||
|
flow,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
super::super::ModalKind::Options => sized_flow(
|
super::super::ModalKind::Options => sized_flow(
|
||||||
ex,
|
ex,
|
||||||
880,
|
880,
|
||||||
@@ -181,6 +197,8 @@ impl OpenCADStudio {
|
|||||||
crate::ui::window::options::view_window(
|
crate::ui::window::options::view_window(
|
||||||
&self.default_save_format,
|
&self.default_save_format,
|
||||||
self.file_assoc_enabled,
|
self.file_assoc_enabled,
|
||||||
|
self.write_dwg_native_constraints,
|
||||||
|
self.show_constraint_values,
|
||||||
&self.ui_theme,
|
&self.ui_theme,
|
||||||
&self.theme_color_inputs,
|
&self.theme_color_inputs,
|
||||||
self.language,
|
self.language,
|
||||||
|
|||||||
@@ -1408,6 +1408,60 @@ pub enum CmdResult {
|
|||||||
ReplaceMany(Vec<(Handle, Vec<EntityType>)>, Vec<EntityType>),
|
ReplaceMany(Vec<(Handle, Vec<EntityType>)>, Vec<EntityType>),
|
||||||
/// Replace several entities as one undo step while keeping the command active.
|
/// Replace several entities as one undo step while keeping the command active.
|
||||||
ReplaceManyContinue(Vec<(Handle, Vec<EntityType>)>),
|
ReplaceManyContinue(Vec<(Handle, Vec<EntityType>)>),
|
||||||
|
/// Add a persistent parametric constraint to the current scope's
|
||||||
|
/// `SketchConstraintSet` and trigger its
|
||||||
|
/// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`,
|
||||||
|
/// nothing here is geometry to add or replace directly — the host adds
|
||||||
|
/// the constraint record, then re-solves through `Scene::bump_entities`'s
|
||||||
|
/// existing chain (`refresh_sketch_constraints`) the same way any later
|
||||||
|
/// edit to these entities will.
|
||||||
|
AddSketchConstraint {
|
||||||
|
kind: crate::scene::sketch_constraints::ConstraintKind,
|
||||||
|
refs: Vec<crate::scene::sketch_constraints::SketchRef>,
|
||||||
|
/// The typed target for a dimensional kind (Distance/Angle/Radius) —
|
||||||
|
/// a literal number, or a named-parameter reference recognized by
|
||||||
|
/// `DistanceConstraintCommand`/`AngleConstraintCommand::
|
||||||
|
/// on_text_input` when the typed token matches a known parameter
|
||||||
|
/// name instead of parsing as a number); `None` for a purely
|
||||||
|
/// geometric kind.
|
||||||
|
driving_param: Option<crate::scene::named_parameters::DrivingValue>,
|
||||||
|
/// Undo-history label, e.g. `"Horizontal constraint"`.
|
||||||
|
label: &'static str,
|
||||||
|
},
|
||||||
|
/// Adds a Coincident constraint between the sub-entity points nearest
|
||||||
|
/// the two picked positions. The host resolves the raw points because
|
||||||
|
/// `CadCommand` has no document access.
|
||||||
|
AddCoincidentConstraint {
|
||||||
|
point_a: DVec3,
|
||||||
|
point_b: DVec3,
|
||||||
|
label: &'static str,
|
||||||
|
},
|
||||||
|
/// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint
|
||||||
|
/// (`crate::modules::draw::constrain::point_on_entity`) between one
|
||||||
|
/// picked point and `target`, a whole entity selected before the tool
|
||||||
|
/// ran. Like `AddCoincidentConstraint`, the host resolves `point` via
|
||||||
|
/// `sketch_constraints::nearest_sketch_point` (a `CadCommand` has no
|
||||||
|
/// document access) and, for `CenterPoint` specifically, addresses
|
||||||
|
/// `target` via its center marker rather than as a whole entity — see
|
||||||
|
/// `ConstraintKind::CenterPoint`'s own doc comment for why it's the
|
||||||
|
/// same solve as `Coincident`/`Concentric` under a different DWG-native
|
||||||
|
/// class name.
|
||||||
|
AddPointOnEntityConstraint {
|
||||||
|
point: DVec3,
|
||||||
|
target: Handle,
|
||||||
|
kind: crate::scene::sketch_constraints::ConstraintKind,
|
||||||
|
label: &'static str,
|
||||||
|
},
|
||||||
|
/// Add a persistent `EqualDistance` constraint
|
||||||
|
/// (`crate::modules::draw::constrain::equal_distance`): the distance
|
||||||
|
/// between `points[0]`/`points[1]` equals the distance between
|
||||||
|
/// `points[2]`/`points[3]`. The host resolves each point via
|
||||||
|
/// `sketch_constraints::nearest_sketch_point`, same reasoning as
|
||||||
|
/// `AddCoincidentConstraint`.
|
||||||
|
AddEqualDistanceConstraint {
|
||||||
|
points: [DVec3; 4],
|
||||||
|
label: &'static str,
|
||||||
|
},
|
||||||
/// Attach one smart centre mark to a newly selected circular source.
|
/// Attach one smart centre mark to a newly selected circular source.
|
||||||
ReassociateCenterMark {
|
ReassociateCenterMark {
|
||||||
target: Handle,
|
target: Handle,
|
||||||
@@ -1475,6 +1529,8 @@ pub enum CmdResult {
|
|||||||
BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 },
|
BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 },
|
||||||
/// Attempt to join the given entities into fewer merged entities.
|
/// Attempt to join the given entities into fewer merged entities.
|
||||||
JoinEntities(Vec<Handle>),
|
JoinEntities(Vec<Handle>),
|
||||||
|
/// Join candidates into an explicitly selected source.
|
||||||
|
JoinToSource { source: Handle, handles: Vec<Handle> },
|
||||||
/// Apply a polyline-edit operation to one entity; keep command active.
|
/// Apply a polyline-edit operation to one entity; keep command active.
|
||||||
PeditOp {
|
PeditOp {
|
||||||
handle: Handle,
|
handle: Handle,
|
||||||
@@ -1939,7 +1995,21 @@ impl InputKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current screen projection and configured aperture for point-feature picking.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PointPickContext {
|
||||||
|
pub view: glam::Mat4,
|
||||||
|
pub eye: DVec3,
|
||||||
|
pub bounds: iced::Rectangle,
|
||||||
|
pub aperture_px: f32,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait CadCommand: Send {
|
pub trait CadCommand: Send {
|
||||||
|
/// Preserve source appearance for commands that extract existing entities.
|
||||||
|
fn preserve_commit_style(&self) -> bool { false }
|
||||||
|
fn nested_copy_bind_setting(&self) -> Option<bool> { None }
|
||||||
|
/// Symbol localization is committed together with the extracted entities.
|
||||||
|
fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { None }
|
||||||
/// Keep the layer already carried by entities committed by this command
|
/// Keep the layer already carried by entities committed by this command
|
||||||
/// instead of replacing it with the current drawing layer.
|
/// instead of replacing it with the current drawing layer.
|
||||||
fn preserve_commit_layer(&self) -> bool {
|
fn preserve_commit_layer(&self) -> bool {
|
||||||
@@ -2006,6 +2076,12 @@ pub trait CadCommand: Send {
|
|||||||
/// Called when the user left-clicks in the viewport (point pick).
|
/// Called when the user left-clicks in the viewport (point pick).
|
||||||
fn on_point(&mut self, pt: DVec3) -> CmdResult;
|
fn on_point(&mut self, pt: DVec3) -> CmdResult;
|
||||||
|
|
||||||
|
/// Opt in only while a point input selects an existing point feature.
|
||||||
|
fn wants_point_pick_context(&self) -> bool { false }
|
||||||
|
|
||||||
|
/// Refreshed for each point input so zoom and viewport changes are reflected.
|
||||||
|
fn set_point_pick_context(&mut self, _context: Option<PointPickContext>) {}
|
||||||
|
|
||||||
/// Called when the user presses Enter (finalize / next option).
|
/// Called when the user presses Enter (finalize / next option).
|
||||||
fn on_enter(&mut self) -> CmdResult;
|
fn on_enter(&mut self) -> CmdResult;
|
||||||
|
|
||||||
@@ -2286,6 +2362,9 @@ pub trait CadCommand: Send {
|
|||||||
CmdResult::Cancel
|
CmdResult::Cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exclude locked-layer entities from injected selection geometry.
|
||||||
|
fn selection_entities_exclude_locked(&self) -> bool { false }
|
||||||
|
|
||||||
fn inject_selection_entities(&mut self, _entities: Vec<SelectionEntity>) {}
|
fn inject_selection_entities(&mut self, _entities: Vec<SelectionEntity>) {}
|
||||||
|
|
||||||
fn area_preview_regions(&self) -> Option<Vec<AreaPreviewRegion>> {
|
fn area_preview_regions(&self) -> Option<Vec<AreaPreviewRegion>> {
|
||||||
@@ -2470,6 +2549,40 @@ pub fn all_registered_command_names() -> Vec<&'static str> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod constraint_registry_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Ensures every constraint command remains discoverable by autocomplete
|
||||||
|
/// and command listings.
|
||||||
|
#[test]
|
||||||
|
fn every_constraint_command_is_in_the_autocomplete_registry() {
|
||||||
|
let names = all_registered_command_names();
|
||||||
|
for id in [
|
||||||
|
"CCONSTRAINT",
|
||||||
|
"EDCONSTRAINT",
|
||||||
|
"CPCONSTRAINT",
|
||||||
|
"MPCONSTRAINT",
|
||||||
|
"OCCONSTRAINT",
|
||||||
|
"HCONSTRAINT",
|
||||||
|
"VCONSTRAINT",
|
||||||
|
"PCONSTRAINT",
|
||||||
|
"QCONSTRAINT",
|
||||||
|
"ECONSTRAINT",
|
||||||
|
"TCONSTRAINT",
|
||||||
|
"NCONSTRAINT",
|
||||||
|
"NRCONSTRAINT",
|
||||||
|
"LCONSTRAINT",
|
||||||
|
"FXCONSTRAINT",
|
||||||
|
"SYCONSTRAINT",
|
||||||
|
"DCONSTRAINT",
|
||||||
|
"ACONSTRAINT",
|
||||||
|
] {
|
||||||
|
assert!(names.contains(&id), "{id} is missing from the command registry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2508,4 +2621,3 @@ mod tests {
|
|||||||
assert!(names.contains(&"M2P"));
|
assert!(names.contains(&"M2P"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +53,16 @@ fn spline_curve(spline: &Spline) -> Option<NurbsCurve3> {
|
|||||||
fn axis_frame(helix: &Helix) -> Option<(Vec3, Vec3, Vec3)> {
|
fn axis_frame(helix: &Helix) -> Option<(Vec3, Vec3, Vec3)> {
|
||||||
let base = point(helix.axis_base_point);
|
let base = point(helix.axis_base_point);
|
||||||
let axis = point(helix.axis_vector).normalize()?;
|
let axis = point(helix.axis_vector).normalize()?;
|
||||||
let radial = point(helix.start_point) - base;
|
let tangent = spline_curve(&helix.spline)
|
||||||
let start_direction = (radial - axis * radial.dot(axis)).normalize()?;
|
.map(|curve| Vec3::from(curve.tangent_at(0.0)))
|
||||||
Some((base, axis, start_direction))
|
.and_then(Vec3::normalize)
|
||||||
|
.or_else(|| perpendicular(axis))?;
|
||||||
|
let (axis, radial, _) = HelixCurve::frame_from_points(base.to_array(),point(helix.axis_vector).to_array(),point(helix.start_point).to_array(),tangent.to_array())?;
|
||||||
|
Some((base,Vec3::from(axis),Vec3::from(radial)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_radius(helix: &Helix) -> Option<f64> {
|
||||||
|
radius_from_axis(helix, point(helix.start_point))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn radius_from_axis(helix: &Helix, value: Vec3) -> Option<f64> {
|
fn radius_from_axis(helix: &Helix, value: Vec3) -> Option<f64> {
|
||||||
@@ -65,6 +72,7 @@ fn radius_from_axis(helix: &Helix, value: Vec3) -> Option<f64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn top_radius(helix: &Helix) -> f64 {
|
fn top_radius(helix: &Helix) -> f64 {
|
||||||
|
if helix.turns == 0.0 { return helix.radius; }
|
||||||
spline_curve(&helix.spline)
|
spline_curve(&helix.spline)
|
||||||
.map(|curve| Vec3::from(curve.point_at(1.0)))
|
.map(|curve| Vec3::from(curve.point_at(1.0)))
|
||||||
.and_then(|endpoint| radius_from_axis(helix, endpoint))
|
.and_then(|endpoint| radius_from_axis(helix, endpoint))
|
||||||
@@ -77,7 +85,7 @@ fn kernel_curve(helix: &Helix, top_radius: f64) -> Option<HelixCurve> {
|
|||||||
base_center: base.to_array(),
|
base_center: base.to_array(),
|
||||||
axis_direction: axis.to_array(),
|
axis_direction: axis.to_array(),
|
||||||
start_direction: start_direction.to_array(),
|
start_direction: start_direction.to_array(),
|
||||||
base_radius: helix.radius,
|
base_radius: base_radius(helix)?,
|
||||||
top_radius,
|
top_radius,
|
||||||
height: helix.turns * helix.turn_height,
|
height: helix.turns * helix.turn_height,
|
||||||
turns: helix.turns,
|
turns: helix.turns,
|
||||||
@@ -89,6 +97,36 @@ fn kernel_curve(helix: &Helix, top_radius: f64) -> Option<HelixCurve> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reverse generating parameters together with the exact stored spline.
|
||||||
|
pub(crate) fn reversed(helix: &Helix) -> Option<Helix> {
|
||||||
|
let original_curve = spline_curve(&helix.spline)?;
|
||||||
|
let parameters = kernel_curve(helix, top_radius(helix))?.reversed()?;
|
||||||
|
let mut result = helix.clone();
|
||||||
|
result.axis_base_point = vector(Vec3::from(parameters.base_center));
|
||||||
|
result.axis_vector = vector(Vec3::from(parameters.axis_direction));
|
||||||
|
result.start_point = vector(Vec3::from(original_curve.point_at(1.0)));
|
||||||
|
result.radius = parameters.top_radius;
|
||||||
|
// Reverse the evaluated curve itself instead of fitting it again.
|
||||||
|
let reversed_curve = original_curve.reversed()?;
|
||||||
|
result.spline.control_points = reversed_curve
|
||||||
|
.control_points()
|
||||||
|
.iter()
|
||||||
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
||||||
|
.collect();
|
||||||
|
result.spline.fit_points.reverse();
|
||||||
|
result.spline.weights = if helix.spline.weights.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
reversed_curve.weights().to_vec()
|
||||||
|
};
|
||||||
|
result.spline.knots = reversed_curve.knots().to_vec();
|
||||||
|
let begin = helix.spline.begin_tangent;
|
||||||
|
let end = helix.spline.end_tangent;
|
||||||
|
result.spline.begin_tangent = Vector3::new(-end.x, -end.y, -end.z);
|
||||||
|
result.spline.end_tangent = Vector3::new(-begin.x, -begin.y, -begin.z);
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
fn projected_direction(direction: Vec3, axis: Vec3) -> Option<Vec3> {
|
fn projected_direction(direction: Vec3, axis: Vec3) -> Option<Vec3> {
|
||||||
(direction - axis * direction.dot(axis)).normalize()
|
(direction - axis * direction.dot(axis)).normalize()
|
||||||
}
|
}
|
||||||
@@ -105,12 +143,18 @@ fn perpendicular(axis: Vec3) -> Option<Vec3> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild(helix: &mut Helix, top_radius: f64) -> bool {
|
fn rebuild(helix: &mut Helix, top_radius: f64) -> bool {
|
||||||
let Some(curve) = kernel_curve(helix, top_radius) else {
|
rebuild_with_radial(helix, top_radius, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_with_radial(helix: &mut Helix, top_radius: f64, radial: Option<Vec3>) -> bool {
|
||||||
|
let Some(mut curve) = kernel_curve(helix, top_radius) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
if let Some(radial) = radial { curve.start_direction = radial.to_array(); }
|
||||||
let Some(nurbs) = curve.nurbs() else {
|
let Some(nurbs) = curve.nurbs() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
helix.radius = top_radius;
|
||||||
helix.spline.degree = nurbs.degree() as i32;
|
helix.spline.degree = nurbs.degree() as i32;
|
||||||
helix.spline.knots = nurbs.knots().to_vec();
|
helix.spline.knots = nurbs.knots().to_vec();
|
||||||
helix.spline.control_points = nurbs
|
helix.spline.control_points = nurbs
|
||||||
@@ -127,7 +171,7 @@ fn rebuild(helix: &mut Helix, top_radius: f64) -> bool {
|
|||||||
helix.spline.flags.closed = false;
|
helix.spline.flags.closed = false;
|
||||||
helix.spline.flags.periodic = false;
|
helix.spline.flags.periodic = false;
|
||||||
helix.spline.flags.rational = nurbs.is_rational();
|
helix.spline.flags.rational = nurbs.is_rational();
|
||||||
helix.spline.flags.planar = false;
|
helix.spline.flags.planar = curve.height == 0.0;
|
||||||
helix.spline.flags.linear = false;
|
helix.spline.flags.linear = false;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -194,7 +238,7 @@ fn properties(helix: &Helix) -> Vec<PropSection> {
|
|||||||
edit(t!("Height").as_ref(), "height", height),
|
edit(t!("Height").as_ref(), "height", height),
|
||||||
edit(t!("Turns").as_ref(), "turns", helix.turns),
|
edit(t!("Turns").as_ref(), "turns", helix.turns),
|
||||||
edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height),
|
edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height),
|
||||||
edit(t!("Base radius").as_ref(), "base_radius", helix.radius),
|
edit(t!("Base radius").as_ref(), "base_radius", base_radius(helix).unwrap_or(0.0)),
|
||||||
edit(t!("Top radius").as_ref(), "top_radius", top_radius),
|
edit(t!("Top radius").as_ref(), "top_radius", top_radius),
|
||||||
choice(t!("Twist").as_ref(), "twist", &twist, &twist_options),
|
choice(t!("Twist").as_ref(), "twist", &twist, &twist_options),
|
||||||
ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)),
|
ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)),
|
||||||
@@ -249,10 +293,15 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) {
|
|||||||
helix.axis_base_point = vector(base);
|
helix.axis_base_point = vector(base);
|
||||||
helix.start_point = vector(point(helix.start_point) + delta);
|
helix.start_point = vector(point(helix.start_point) + delta);
|
||||||
}
|
}
|
||||||
"height" if number.abs() > EPSILON => {
|
"height" => {
|
||||||
|
if helix.turns == 0.0 && number != 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if number < 0.0 { helix.axis_vector = vector(-point(helix.axis_vector)); }
|
||||||
|
let number = number.abs();
|
||||||
if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON {
|
if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON {
|
||||||
let turns = number / helix.turn_height;
|
let turns = number / helix.turn_height;
|
||||||
if turns <= EPSILON {
|
if turns < 0.0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
helix.turns = turns;
|
helix.turns = turns;
|
||||||
@@ -260,16 +309,18 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) {
|
|||||||
helix.turn_height = number / helix.turns.max(EPSILON);
|
helix.turn_height = number / helix.turns.max(EPSILON);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"turns" if number > EPSILON => {
|
"turns" if number >= 0.0 => {
|
||||||
if helix.constraint == HelixConstraint::TurnHeight {
|
if number == 0.0 { helix.turns = 0.0; }
|
||||||
|
else if helix.constraint == HelixConstraint::TurnHeight {
|
||||||
helix.turns = number;
|
helix.turns = number;
|
||||||
} else {
|
} else {
|
||||||
helix.turns = number;
|
helix.turns = number;
|
||||||
helix.turn_height = old_height / number;
|
helix.turn_height = old_height / number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"turn_height" if number.abs() > EPSILON => {
|
"turn_height" if number >= 0.0 => {
|
||||||
if helix.constraint == HelixConstraint::Turns {
|
if number == 0.0 { helix.turn_height = 0.0; }
|
||||||
|
else if helix.constraint == HelixConstraint::Turns {
|
||||||
helix.turn_height = number;
|
helix.turn_height = number;
|
||||||
} else {
|
} else {
|
||||||
let turns = old_height / number;
|
let turns = old_height / number;
|
||||||
@@ -280,12 +331,13 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) {
|
|||||||
helix.turns = turns;
|
helix.turns = turns;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"base_radius" if number > EPSILON => {
|
"base_radius" if number >= 0.0 => {
|
||||||
let Some((base, _, start_direction)) = axis_frame(helix) else {
|
let Some((base, _, start_direction)) = axis_frame(helix) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
helix.radius = number;
|
|
||||||
helix.start_point = vector(base + start_direction * number);
|
helix.start_point = vector(base + start_direction * number);
|
||||||
|
if !rebuild_with_radial(helix, top, Some(start_direction)) { *helix = original; }
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
"top_radius" if number >= 0.0 => {
|
"top_radius" if number >= 0.0 => {
|
||||||
if !rebuild(helix, number) {
|
if !rebuild(helix, number) {
|
||||||
@@ -303,6 +355,7 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) {
|
|||||||
fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) {
|
fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) {
|
||||||
let original = helix.clone();
|
let original = helix.clone();
|
||||||
let top = top_radius(helix);
|
let top = top_radius(helix);
|
||||||
|
let Some(base_radius) = base_radius(helix) else { return; };
|
||||||
let Some((base, axis, start_direction)) = axis_frame(helix) else {
|
let Some((base, axis, start_direction)) = axis_frame(helix) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -334,7 +387,6 @@ fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) {
|
|||||||
if radius <= EPSILON {
|
if radius <= EPSILON {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
helix.radius = radius;
|
|
||||||
helix.start_point = vector(base + radial);
|
helix.start_point = vector(base + radial);
|
||||||
}
|
}
|
||||||
(2, GripApply::Absolute(position)) => {
|
(2, GripApply::Absolute(position)) => {
|
||||||
@@ -362,7 +414,7 @@ fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) {
|
|||||||
*helix = original;
|
*helix = original;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
helix.start_point = vector(base + start_direction * helix.radius);
|
helix.start_point = vector(base + start_direction * base_radius);
|
||||||
}
|
}
|
||||||
(3, GripApply::Absolute(position)) => {
|
(3, GripApply::Absolute(position)) => {
|
||||||
let position = kernel(position);
|
let position = kernel(position);
|
||||||
@@ -455,3 +507,98 @@ impl Transformable for Helix {
|
|||||||
apply_transform(self, transform);
|
apply_transform(self, transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn zero_base_point_helix() -> Helix {
|
||||||
|
let curve = HelixCurve {
|
||||||
|
base_center: [1.0, 2.0, 3.0],
|
||||||
|
axis_direction: [0.0, 0.0, 1.0],
|
||||||
|
start_direction: [1.0, 0.0, 0.0],
|
||||||
|
base_radius: 0.0,
|
||||||
|
top_radius: 2.0,
|
||||||
|
height: 0.0,
|
||||||
|
turns: 0.0,
|
||||||
|
direction: HelixDirection::CounterClockwise,
|
||||||
|
};
|
||||||
|
let nurbs = curve.nurbs().unwrap();
|
||||||
|
let mut helix = Helix::new();
|
||||||
|
helix.axis_base_point = Vector3::new(1.0, 2.0, 3.0);
|
||||||
|
helix.axis_vector = Vector3::UNIT_Z;
|
||||||
|
helix.start_point = helix.axis_base_point;
|
||||||
|
helix.radius = 2.0;
|
||||||
|
helix.turns = 0.0;
|
||||||
|
helix.turn_height = 0.0;
|
||||||
|
helix.spline.degree = nurbs.degree() as i32;
|
||||||
|
helix.spline.knots = nurbs.knots().to_vec();
|
||||||
|
helix.spline.control_points = nurbs
|
||||||
|
.control_points()
|
||||||
|
.iter()
|
||||||
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
||||||
|
.collect();
|
||||||
|
helix
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_base_point_locus_retains_an_editable_frame() {
|
||||||
|
let mut helix = zero_base_point_helix();
|
||||||
|
assert_eq!(base_radius(&helix), Some(0.0));
|
||||||
|
assert_eq!(top_radius(&helix), 2.0);
|
||||||
|
|
||||||
|
let original = helix.clone();
|
||||||
|
apply_geom_prop(&mut helix, "height", "5");
|
||||||
|
assert_eq!(helix, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reversing_twice_restores_a_conical_helix_and_radius_roles() {
|
||||||
|
let curve = HelixCurve {
|
||||||
|
base_center: [1.0, 2.0, 3.0],
|
||||||
|
axis_direction: [0.0, 1.0, 1.0],
|
||||||
|
start_direction: [1.0, 0.0, 0.0],
|
||||||
|
base_radius: 1.0,
|
||||||
|
top_radius: 2.5,
|
||||||
|
height: 4.0,
|
||||||
|
turns: 1.25,
|
||||||
|
direction: HelixDirection::Clockwise,
|
||||||
|
};
|
||||||
|
let nurbs = curve.nurbs().expect("valid helix");
|
||||||
|
let mut helix = Helix::new();
|
||||||
|
helix.axis_base_point = Vector3::new(1.0, 2.0, 3.0);
|
||||||
|
let axis = Vec3::from(curve.axis_direction).normalize().unwrap();
|
||||||
|
helix.axis_vector = vector(axis);
|
||||||
|
helix.start_point = Vector3::new(2.0, 2.0, 3.0);
|
||||||
|
helix.radius = curve.top_radius;
|
||||||
|
helix.turns = curve.turns;
|
||||||
|
helix.turn_height = curve.height / curve.turns;
|
||||||
|
helix.handedness = false;
|
||||||
|
helix.spline.degree = nurbs.degree() as i32;
|
||||||
|
helix.spline.control_points = nurbs
|
||||||
|
.control_points()
|
||||||
|
.iter()
|
||||||
|
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
||||||
|
.collect();
|
||||||
|
helix.spline.knots = nurbs.knots().to_vec();
|
||||||
|
helix.spline.weights = nurbs.weights().to_vec();
|
||||||
|
|
||||||
|
let once = reversed(&helix).expect("first reversal");
|
||||||
|
assert!((once.radius - curve.base_radius).abs() < 1.0e-12);
|
||||||
|
assert_eq!(once.turns, helix.turns);
|
||||||
|
assert_eq!(once.turn_height, helix.turn_height);
|
||||||
|
assert_eq!(once.handedness, helix.handedness);
|
||||||
|
let original_curve = spline_curve(&helix.spline).unwrap();
|
||||||
|
let once_curve = spline_curve(&once.spline).unwrap();
|
||||||
|
assert!(Vec3::from(once_curve.point_at(0.0))
|
||||||
|
.distance(Vec3::from(original_curve.point_at(1.0))) < 1.0e-9);
|
||||||
|
|
||||||
|
let twice = reversed(&once).expect("second reversal");
|
||||||
|
assert!((twice.radius - helix.radius).abs() < 1.0e-12);
|
||||||
|
let twice_curve = spline_curve(&twice.spline).unwrap();
|
||||||
|
for parameter in [0.0, 0.2, 0.5, 0.8, 1.0] {
|
||||||
|
assert!(Vec3::from(twice_curve.point_at(parameter))
|
||||||
|
.distance(Vec3::from(original_curve.point_at(parameter))) < 1.0e-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -347,6 +347,7 @@ fn merge_xref_into_block(
|
|||||||
let old = layer.name.clone();
|
let old = layer.name.clone();
|
||||||
let new = format!("{}|{}", prefix, old);
|
let new = format!("{}|{}", prefix, old);
|
||||||
let mut cloned = layer.clone();
|
let mut cloned = layer.clone();
|
||||||
|
xref_doc.normalize_imported_layer_defaults(&mut cloned);
|
||||||
cloned.name = new.clone();
|
cloned.name = new.clone();
|
||||||
cloned.set_handle(doc.allocate_handle());
|
cloned.set_handle(doc.allocate_handle());
|
||||||
doc.layers.add_or_replace(cloned);
|
doc.layers.add_or_replace(cloned);
|
||||||
|
|||||||