Merge current main with division marker support
No files matched your search
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"bincode",
|
||||
"bytemuck",
|
||||
"cadkernel",
|
||||
"cadkernel-constraints",
|
||||
"clap",
|
||||
"console_error_panic_hook",
|
||||
"cosmic-text 0.15.0",
|
||||
@@ -38,7 +39,6 @@ dependencies = [
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"ocs_doc_api",
|
||||
"ocs_plugin_api",
|
||||
"open",
|
||||
"printpdf",
|
||||
@@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.5.4"
|
||||
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=f23ffc873228e5f41b876485c741e4a8ac20a67d#f23ffc873228e5f41b876485c741e4a8ac20a67d"
|
||||
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=2f2cd25832db298524fb5eb36ced5a438a877e95#2f2cd25832db298524fb5eb36ced5a438a877e95"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
@@ -323,6 +323,15 @@ version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -885,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
[[package]]
|
||||
name = "cadkernel"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0507b720fef47bd7da0d4f777f97f1743a4f4038#0507b720fef47bd7da0d4f777f97f1743a4f4038"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5#0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cavalier_contours",
|
||||
@@ -893,6 +902,14 @@ dependencies = [
|
||||
"spade",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadkernel-constraints"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5#0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5"
|
||||
dependencies = [
|
||||
"nalgebra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.13.0"
|
||||
@@ -3178,6 +3195,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -3358,6 +3385,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3891,20 +3945,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ocs_plugin_api"
|
||||
version = "0.2.1"
|
||||
@@ -4607,6 +4647,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
@@ -5015,6 +5061,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -5228,6 +5283,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -6532,6 +6600,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -11,7 +11,6 @@ members = [
|
||||
"crates/ocs_web_worker",
|
||||
"crates/dwg-thumbnailer",
|
||||
"crates/dwg-thumbnailer-win",
|
||||
"crates/ocs_doc_api",
|
||||
]
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
@@ -31,8 +30,9 @@ glam = { version = "0.33", features = ["bytemuck"] }
|
||||
rfd = "0.17"
|
||||
clap = { version = "4", features = ["derive", "string"] }
|
||||
env_logger = "0.11"
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5", features = ["acis", "offset"] }
|
||||
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5" }
|
||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||
flate2 = "1"
|
||||
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]
|
||||
getrandom = "0.3"
|
||||
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
|
||||
ocs_doc_api = { path = "crates/ocs_doc_api", features = ["host", "ipc"] }
|
||||
bincode = "1.3"
|
||||
meshopt = "0.6.2"
|
||||
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/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
|
||||
# `HostApi` runtime surface. The default crate stays dependency-free so engine
|
||||
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", optional = true, features = ["serde"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", optional = true, features = ["serde"] }
|
||||
|
||||
# Runtime IPC and serialization (host feature only).
|
||||
interprocess = { version = "2", optional = true }
|
||||
@@ -38,7 +38,7 @@ serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
cargo-lock = "11"
|
||||
# acadrust is scanned at build time to generate the embedded type registry.
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
@@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) {
|
||||
"acadrust_version": acadrust.version.to_string(),
|
||||
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
|
||||
"rustc_version": rustc_version,
|
||||
"api_version": 6,
|
||||
"api_version": 5,
|
||||
"api_version_min_supported": 2,
|
||||
"build_timestamp": build_timestamp,
|
||||
});
|
||||
|
||||
@@ -552,18 +552,6 @@ pub trait HostApi {
|
||||
let _ = tab_id;
|
||||
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`].
|
||||
|
||||
@@ -138,12 +138,6 @@ pub enum PluginRequest {
|
||||
GetTabId,
|
||||
/// V5: ask the host for the filesystem path of the document in `tab_id`.
|
||||
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`.
|
||||
@@ -170,10 +164,6 @@ pub enum PluginResponse {
|
||||
TabId(u64),
|
||||
/// V5: filesystem path of the document in the requested tab, if any.
|
||||
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.
|
||||
|
||||
@@ -77,9 +77,5 @@ pub fn handle_plugin_request(
|
||||
DocumentPath { tab_id } => PluginResponse::DocumentPath(
|
||||
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
|
||||
/// 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
|
||||
/// adds the `BuiltinPlugin::on_load` lifecycle callback.
|
||||
/// Host ABI major version. Bumped to 6 when the DocApi v2 surface
|
||||
/// (`PluginRequest::DocApiRequest` / `PluginResponse::DocApiResponse` +
|
||||
/// `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;
|
||||
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
|
||||
/// `HostApi::document_path`.
|
||||
pub const API_VERSION: u32 = 5;
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -8,7 +8,7 @@ publish = false
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] }
|
||||
bincode = "1.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
@@ -10,4 +10,4 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] }
|
||||
@@ -323,6 +323,8 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.clear();
|
||||
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);
|
||||
crate::app::style_ops::ensure_standard_styles(
|
||||
&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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" => {
|
||||
use crate::modules::draw::modify::explode::explode_entity;
|
||||
let inserts: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.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());
|
||||
let command = crate::modules::draw::modify::ncopy::NcopyCommand::new(
|
||||
&self.tabs[i].scene.document, self.ncopy_bind,
|
||||
);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
@@ -706,6 +677,19 @@ mod tests {
|
||||
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.
|
||||
fn run_capture(app: &mut OpenCADStudio, cmd: &str) -> String {
|
||||
let start = app.command_line.history.len();
|
||||
|
||||
@@ -596,9 +596,23 @@ impl OpenCADStudio {
|
||||
|
||||
"REVERSE" => {
|
||||
use crate::modules::draw::modify::reverse::ReverseCommand;
|
||||
let new_cmd = ReverseCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
if self.tabs[i].scene.selected.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
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" => {
|
||||
@@ -775,7 +789,7 @@ impl OpenCADStudio {
|
||||
selected,
|
||||
inherited,
|
||||
plane,
|
||||
);
|
||||
).with_origin(self.tabs[i].scene.document.hatch_origin());
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
self.refresh_area_preview(i);
|
||||
@@ -808,7 +822,7 @@ impl OpenCADStudio {
|
||||
scale,
|
||||
angle,
|
||||
annotative,
|
||||
);
|
||||
).with_appearance(entity,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin());
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
@@ -816,7 +830,7 @@ impl OpenCADStudio {
|
||||
.push_error(crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref());
|
||||
}
|
||||
} else {
|
||||
let cmd = HatcheditCommand::new();
|
||||
let cmd = HatcheditCommand::new().with_appearance(None,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin());
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
@@ -881,7 +895,7 @@ impl OpenCADStudio {
|
||||
SplineCommand::control_vertices()
|
||||
} else {
|
||||
SplineCommand::new()
|
||||
};
|
||||
}.with_document(&self.tabs[i].scene.document);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
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) ─────────────────────────────
|
||||
"CYLINDER" => {
|
||||
use crate::modules::model::cylinder_cmd::CylinderCommand;
|
||||
@@ -1114,8 +1315,7 @@ impl OpenCADStudio {
|
||||
return Some(self.fit_spline());
|
||||
}
|
||||
|
||||
// REGION — convert selected closed boundaries (closed polylines /
|
||||
// circles) into Region entities (one wire loop each).
|
||||
// REGION — convert exact closed planar profiles into regions.
|
||||
"REGION" | "REG" => {
|
||||
if self.tabs[i].scene.selected_entities().is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
@@ -1129,12 +1329,7 @@ impl OpenCADStudio {
|
||||
let mut regions = Vec::new();
|
||||
let mut sources = Vec::new();
|
||||
for (handle, e) in self.tabs[i].scene.selected_entities().iter() {
|
||||
let supported = matches!(
|
||||
e,
|
||||
acadrust::EntityType::LwPolyline(pl)
|
||||
if pl.is_closed && pl.vertices.len() >= 3
|
||||
) || matches!(e, acadrust::EntityType::Circle(_));
|
||||
if supported {
|
||||
if !matches!(e, acadrust::EntityType::Region(_)) {
|
||||
let Some((plane, loops, true)) =
|
||||
crate::scene::model::presspull_model::profile_geometry(e)
|
||||
else {
|
||||
@@ -1154,9 +1349,55 @@ impl OpenCADStudio {
|
||||
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() {
|
||||
self.command_line
|
||||
.push_error(crate::t!("REGION: select closed polylines or circles.").as_ref());
|
||||
if !open_plane_rejected {
|
||||
self.command_line.push_error(
|
||||
"REGION: select closed planar profiles or connected coplanar edges.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "REGION");
|
||||
let count = regions.len();
|
||||
@@ -1171,6 +1412,8 @@ impl OpenCADStudio {
|
||||
created.push(handle);
|
||||
}
|
||||
if self.delete_objects != 0 {
|
||||
sources.sort_by_key(|handle| handle.value());
|
||||
sources.dedup();
|
||||
self.tabs[i].scene.erase_entities(&sources);
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
@@ -218,7 +218,10 @@ impl OpenCADStudio {
|
||||
self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected));
|
||||
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.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
@@ -294,6 +297,7 @@ impl OpenCADStudio {
|
||||
header.surface_u_density,
|
||||
header.surface_v_density,
|
||||
)
|
||||
.with_entities(self.tabs[i].scene.document.entities().cloned())
|
||||
.with_preselection(&preselected);
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
@@ -329,7 +333,17 @@ impl OpenCADStudio {
|
||||
|
||||
"SPLINEDIT" => {
|
||||
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.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
}
|
||||
@@ -1373,6 +1387,7 @@ impl OpenCADStudio {
|
||||
crate::entities::names::dxf_name(e).to_string(),
|
||||
c.layer.clone(),
|
||||
c.color,
|
||||
c.transparency,
|
||||
c.linetype.clone(),
|
||||
c.linetype_scale,
|
||||
c.line_weight,
|
||||
@@ -1384,7 +1399,7 @@ impl OpenCADStudio {
|
||||
},
|
||||
)
|
||||
});
|
||||
let Some((verb, kind, layer, color, linetype, lt_scale, lw, template_dimstyle)) = info
|
||||
let Some((verb, kind, layer, color, transparency, linetype, lt_scale, lw, template_dimstyle)) = info
|
||||
else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("ADDSELECTED: selected object not found.").as_ref());
|
||||
@@ -1408,6 +1423,7 @@ impl OpenCADStudio {
|
||||
layer_name: self.tabs[i].scene.document.header.current_layer_name.clone(),
|
||||
layer_handle: self.tabs[i].scene.document.header.current_layer_handle,
|
||||
color: self.tabs[i].scene.document.header.current_entity_color,
|
||||
transparency: self.tabs[i].scene.document.current_entity_transparency(),
|
||||
linetype_name: self.tabs[i].scene.document.header.current_linetype_name.clone(),
|
||||
linetype_handle: self.tabs[i].scene.document.header.current_linetype_handle,
|
||||
line_weight: self.tabs[i].scene.document.header.current_line_weight,
|
||||
@@ -1422,6 +1438,11 @@ impl OpenCADStudio {
|
||||
ribbon_lineweight: self.ribbon.active_lineweight,
|
||||
};
|
||||
self.add_selected_restore = Some(restore);
|
||||
if !self.tabs[i].scene.document.set_current_entity_transparency(transparency) {
|
||||
self.add_selected_restore = None;
|
||||
self.command_line.push_error("ADDSELECTED: template transparency cannot be adopted.");
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Adopt the template's general properties as the current defaults. The
|
||||
// entity-creation path stamps new objects from the tab's active layer
|
||||
@@ -1504,6 +1525,9 @@ impl OpenCADStudio {
|
||||
h.current_dimstyle_name = r.dimstyle_name;
|
||||
h.current_dimstyle_handle = r.dimstyle_handle;
|
||||
}
|
||||
if !self.tabs[i].scene.document.set_current_entity_transparency(r.transparency) {
|
||||
self.command_line.push_error("ADDSELECTED: current transparency could not be restored.");
|
||||
}
|
||||
self.tabs[i].active_layer = r.tab_active_layer;
|
||||
self.tabs[i].layers.current_layer = r.tab_layers_current;
|
||||
self.ribbon.active_layer = r.ribbon_layer;
|
||||
|
||||
@@ -322,6 +322,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"CLEANSCREEN",
|
||||
"CUI",
|
||||
"DSETTINGS",
|
||||
"PARAMETERS",
|
||||
"GRID",
|
||||
"ISODRAFT",
|
||||
"ISOPLANE",
|
||||
@@ -368,6 +369,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"COLOR",
|
||||
"COLOUR",
|
||||
"CECOLOR",
|
||||
"CETRANSPARENCY",
|
||||
"DDCOLOR",
|
||||
"BYLAYER",
|
||||
// Synchronise block attributes.
|
||||
@@ -598,6 +600,10 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"DIMSTYLE",
|
||||
"DONATE",
|
||||
"DRAWORDER",
|
||||
"DRAWORDER_FRONT",
|
||||
"DRAWORDER_BACK",
|
||||
"DRAWORDER_ABOVE",
|
||||
"DRAWORDER_UNDER",
|
||||
"DWGPROP",
|
||||
"DWGPROPS",
|
||||
"EATTEXT",
|
||||
|
||||
@@ -3,6 +3,10 @@ use super::*;
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
"CETRANSPARENCY" => return self.dispatch_styleprops("SETVAR CETRANSPARENCY", i),
|
||||
cmd if cmd.starts_with("CETRANSPARENCY ") => {
|
||||
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
|
||||
}
|
||||
"FRAMES0" => return self.dispatch_styleprops("SETVAR FRAME 0", i),
|
||||
"FRAMES1" => return self.dispatch_styleprops("SETVAR FRAME 1", i),
|
||||
"FRAMES2" => return self.dispatch_styleprops("SETVAR FRAME 2", i),
|
||||
@@ -645,98 +649,221 @@ impl OpenCADStudio {
|
||||
// ── SETBYLAYER — clear color/linetype/lineweight overrides ────
|
||||
// Resets the selected entities' direct property overrides back to
|
||||
// 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" => {
|
||||
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
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
|
||||
.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() {
|
||||
self.command_line
|
||||
.push_error(crate::t!("SETBYLAYER: select entities first.").as_ref());
|
||||
} 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");
|
||||
let mut changed = 0usize;
|
||||
let mut changed = Vec::new();
|
||||
for handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) {
|
||||
let common = entity.common_mut();
|
||||
common.color = acadrust::types::Color::ByLayer;
|
||||
common.color_name = None;
|
||||
common.color_book_handle = None;
|
||||
common.linetype = "ByLayer".to_string();
|
||||
common.line_weight = acadrust::types::LineWeight::ByLayer;
|
||||
changed += 1;
|
||||
if crate::modules::draw::modify::setbylayer::apply_mask(
|
||||
entity.common_mut(), mask, change_byblock,
|
||||
) {
|
||||
changed.push(*handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
let changes: Vec<_> = handles
|
||||
.into_iter()
|
||||
let changes: Vec<_> = changed
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
|
||||
.collect();
|
||||
self.tabs[i].scene.bump_entities(&changes);
|
||||
self.refresh_properties();
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"SETBYLAYER: reset {changed} entity/entities to ByLayer."
|
||||
"SETBYLAYER: reset {} entity/entities to ByLayer.",
|
||||
changed.len()
|
||||
).as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
// ── OVERKILL — delete duplicate (identical) objects ──────────
|
||||
// Removes objects that are identical in geometry AND properties to
|
||||
// another object (compared with the handle ignored). Operates on
|
||||
// the current selection, or the whole drawing when nothing is
|
||||
// selected. Conservative: only exact duplicates are removed.
|
||||
"OVERKILL" => {
|
||||
use acadrust::Handle;
|
||||
let selected: std::collections::HashSet<u64> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h.value())
|
||||
.collect();
|
||||
// Capture (handle, type-name, handle-normalized clone) for each
|
||||
// candidate while the document is borrowed immutably.
|
||||
let candidates: Vec<(Handle, String, acadrust::EntityType)> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| {
|
||||
(selected.is_empty() || selected.contains(&e.common().handle.value()))
|
||||
&& !self.tabs[i].scene.is_layer_locked(e.common().handle)
|
||||
})
|
||||
.map(|e| {
|
||||
let key = crate::entities::names::dxf_name(e).to_string();
|
||||
let mut norm = e.clone();
|
||||
norm.common_mut().handle = Handle::NULL;
|
||||
(e.common().handle, key, norm)
|
||||
})
|
||||
.collect();
|
||||
// Bucket by (type, layer) so only like objects are compared.
|
||||
let mut kept: Vec<(String, acadrust::EntityType)> = Vec::new();
|
||||
let mut dups: Vec<Handle> = Vec::new();
|
||||
for (h, key, norm) in &candidates {
|
||||
let bucket = format!("{key}\u{0}{}", norm.common().layer);
|
||||
if kept.iter().any(|(b, e)| b == &bucket && e == norm) {
|
||||
dups.push(*h);
|
||||
} else {
|
||||
kept.push((bucket, norm.clone()));
|
||||
// OVERKILL gathers objects, exposes cleanup settings, then applies one undo group.
|
||||
"OVERKILL" | "-OVERKILL" => {
|
||||
let handles=self.tabs[i].scene.selected_entities().iter().map(|(h,_)|*h).collect();
|
||||
let command=crate::modules::draw::modify::overkill::OverkillCommand::new(handles);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd=Some(Box::new(command));
|
||||
}
|
||||
value if value.starts_with("OVERKILL_APPLY ") => {
|
||||
use crate::modules::draw::modify::overkill::{normalized,optimize};
|
||||
let values:Vec<_>=value.split_whitespace().skip(1).collect();
|
||||
if values.len()!=6 { return Some(self.finish_dispatch(cmd)); }
|
||||
let tolerance=values[0].parse::<f64>().unwrap_or(1e-6);
|
||||
let ignore=values[1].parse::<u16>().unwrap_or(0);
|
||||
let optimize_plines=values[2]=="1";
|
||||
let overlap=values[3]=="1";
|
||||
let end_to_end=values[4]=="1";
|
||||
let preserve_associative=values[5]=="1";
|
||||
let mut candidates:Vec<_>=self.tabs[i].scene.selected_entities().into_iter()
|
||||
.filter(|(h,e)|!self.tabs[i].scene.is_layer_locked(*h)
|
||||
&& (!preserve_associative||e.common().reactors.is_empty()))
|
||||
.map(|(h,e)|(h,e.clone())).collect();
|
||||
candidates.sort_by_key(|(h,_)|h.value());
|
||||
let mut changed=std::collections::HashSet::new();
|
||||
if optimize_plines { for (h,e) in &mut candidates {
|
||||
let before=e.clone(); optimize(e,tolerance); if *e!=before {changed.insert(*h);}
|
||||
} }
|
||||
let mut removed=std::collections::HashSet::new();
|
||||
// Iterate to a fixed point: a bridge can join two previously disjoint intervals.
|
||||
loop {
|
||||
let mut progress=false;
|
||||
for a in 0..candidates.len() {
|
||||
if removed.contains(&candidates[a].0) {continue;}
|
||||
for b in a+1..candidates.len() {
|
||||
if removed.contains(&candidates[b].0) {continue;}
|
||||
let left=normalized(&candidates[a].1,ignore);
|
||||
let right=normalized(&candidates[b].1,ignore);
|
||||
if left==right {
|
||||
removed.insert(candidates[b].0);progress=true;continue;
|
||||
}
|
||||
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() {
|
||||
self.command_line
|
||||
.push_output(crate::t!("OVERKILL: no duplicate objects found.").as_ref());
|
||||
if !changed.is_empty()||!removed.is_empty() {
|
||||
self.push_undo_snapshot(i,"OVERKILL");
|
||||
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 {
|
||||
let n = dups.len();
|
||||
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());
|
||||
self.command_line.push_output("OVERKILL: no objects required changes.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── PICKADD / PICKDRAG — selection UX (#226, app settings) ───
|
||||
// Bare form reports; `<name> 0|1` sets and persists. Defaults keep
|
||||
// today's behaviour (PICKADD 1, PICKDRAG 0).
|
||||
@@ -941,9 +1068,34 @@ impl OpenCADStudio {
|
||||
let value = it.next().map(|s| s.trim().to_string());
|
||||
if name.is_empty() || name == "?" {
|
||||
self.command_line.push_info(
|
||||
crate::t!("SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
|
||||
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
|
||||
);
|
||||
} else {
|
||||
if name == "CETRANSPARENCY" {
|
||||
let current = self.tabs[i].scene.document.current_entity_transparency();
|
||||
if let Some(value) = &value {
|
||||
match crate::scene::creation_style::parse_current_transparency(value) {
|
||||
Some(transparency) => {
|
||||
if current != transparency {
|
||||
self.push_undo_snapshot(i, &name);
|
||||
if !self.tabs[i].scene.document.set_current_entity_transparency(transparency) {
|
||||
self.discard_last_undo_entry(i);
|
||||
self.command_line.push_error("CETRANSPARENCY: drawing variable dictionary is invalid.");
|
||||
return Some(self.finish_dispatch(cmd));
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
}
|
||||
self.command_line.push_output(&format!("CETRANSPARENCY = {}", crate::scene::creation_style::current_transparency_label(transparency)));
|
||||
}
|
||||
None => self.command_line.push_error("CETRANSPARENCY: expected ByLayer (-1), ByBlock (-2), or an integer from 0 to 90."),
|
||||
}
|
||||
} else {
|
||||
self.command_line.push_output(&format!("Enter new value for CETRANSPARENCY <{}>:", crate::scene::creation_style::current_transparency_label(current)));
|
||||
self.pending_setvar = Some(name.clone());
|
||||
}
|
||||
return Some(self.finish_dispatch(cmd));
|
||||
}
|
||||
if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") {
|
||||
let current = if name == "SHOWHIST" {
|
||||
self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2)
|
||||
@@ -2983,6 +3135,21 @@ mod tests {
|
||||
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]
|
||||
fn test_cad_selection_and_model_space_sysvars() {
|
||||
let mut app = fresh_app();
|
||||
@@ -3195,4 +3362,3 @@ mod scale_validation_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
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 {
|
||||
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
@@ -253,6 +310,10 @@ impl OpenCADStudio {
|
||||
return Some(Task::done(Message::OptionsOpen));
|
||||
}
|
||||
|
||||
"PARAMETERS" => {
|
||||
return Some(Task::done(Message::NamedParametersOpen));
|
||||
}
|
||||
|
||||
// CUIEXPORT <path> — write the keyboard-shortcut customizations
|
||||
// (the drawing-independent CUI data) to a plain "KEY COMMAND" file.
|
||||
"CUIEXPORT" => {
|
||||
@@ -817,17 +878,15 @@ impl OpenCADStudio {
|
||||
|
||||
// HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order.
|
||||
"HATCHTOBACK" => {
|
||||
use acadrust::objects::{ObjectType, SortEntitiesTable};
|
||||
use acadrust::objects::ObjectType;
|
||||
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
||||
let doc_ref = &self.tabs[i].scene.document;
|
||||
|
||||
// 1. Single scan over objects to find existing SortEntitiesTable handle & overrides.
|
||||
let mut existing_table_handle = None;
|
||||
// 1. Read existing SortEntitiesTable overrides.
|
||||
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 t.block_owner_handle == block_handle {
|
||||
existing_table_handle = Some(*h);
|
||||
if !t.is_empty() {
|
||||
overrides = Some(
|
||||
t.entries()
|
||||
@@ -894,24 +953,9 @@ impl OpenCADStudio {
|
||||
&locked_layers,
|
||||
);
|
||||
|
||||
// 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone).
|
||||
let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true);
|
||||
|
||||
// 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);
|
||||
// Dictionary ownership and handle allocation are part of this undo step.
|
||||
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);
|
||||
|
||||
if let Some(ObjectType::SortEntitiesTable(table)) =
|
||||
self.tabs[i].scene.document.objects.get_mut(&th)
|
||||
@@ -941,6 +985,37 @@ impl OpenCADStudio {
|
||||
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" => {
|
||||
let selected: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
@@ -953,7 +1028,7 @@ impl OpenCADStudio {
|
||||
self.tabs[i].active_cmd = Some(Box::new(c));
|
||||
}
|
||||
cmd if cmd.starts_with("DRAWORDER ") => {
|
||||
use acadrust::objects::{ObjectType, SortEntitiesTable};
|
||||
use acadrust::objects::ObjectType;
|
||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||
let option = parts.get(1).unwrap_or(&"").to_uppercase();
|
||||
let i = self.active_tab;
|
||||
@@ -968,25 +1043,29 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_error(crate::t!("DRAWORDER: select entities first.").as_ref());
|
||||
} else {
|
||||
// Parse relative target handle for ABOVE/UNDER.
|
||||
let relative_target: Option<(bool, acadrust::Handle)> = match option.as_str() {
|
||||
"A" | "ABOVE" => {
|
||||
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)))
|
||||
}
|
||||
let relative_above = match option.as_str() {
|
||||
"A" | "ABOVE" => Some(true),
|
||||
"U" | "UNDER" | "BELOW" => Some(false),
|
||||
_ => 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() {
|
||||
"F" | "FRONT" => Some(true),
|
||||
"B" | "BACK" => Some(false),
|
||||
_ => 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");
|
||||
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 table_handle = doc.objects.iter().find_map(|(h, obj)| {
|
||||
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
|
||||
});
|
||||
let th = ensure_draw_order_table(doc, block_handle);
|
||||
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th)
|
||||
{
|
||||
if let Some((above, target)) = relative_target {
|
||||
// move_above/move_below recompute target±1 per
|
||||
// call, so looping them over N selected entities
|
||||
// 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));
|
||||
if let Some(assignments) = &relative_assignments {
|
||||
for (handle, sort) in assignments {
|
||||
table.add_entry(*handle, acadrust::Handle::new(*sort));
|
||||
}
|
||||
let rel = if above { "above" } else { "below" };
|
||||
self.command_line.push_info(crate::tf!(
|
||||
"DRAWORDER: moved {} entities {} {:x}.",
|
||||
selected.len(),
|
||||
rel,
|
||||
target.value()
|
||||
).as_ref());
|
||||
self.command_line.push_info(crate::t!("DRAWORDER: selection reordered relative to reference objects.").as_ref());
|
||||
} else if let Some(to_front) = to_front_opt {
|
||||
if to_front {
|
||||
let (_, max_eff) = fb_baseline.unwrap_or((1, 0));
|
||||
@@ -1250,11 +1280,12 @@ enum DrawOrderStep {
|
||||
/// - `Front` / `F`: moves selection to front.
|
||||
/// - `Back` / `B` / Enter: moves selection to back.
|
||||
/// - `Above` / `A` / `Under` / `U`: advances to reference object pick.
|
||||
/// 3. Reference object pick: user can click the reference entity in the viewport
|
||||
/// or type its hex handle on the command line.
|
||||
/// 3. Gather reference objects, excluding the moved selection; Enter applies.
|
||||
/// Typed hexadecimal handles may also be accumulated before confirming.
|
||||
pub(crate) struct DrawOrderCommand {
|
||||
selected: Vec<acadrust::Handle>,
|
||||
step: DrawOrderStep,
|
||||
references: Vec<acadrust::Handle>,
|
||||
}
|
||||
|
||||
impl DrawOrderCommand {
|
||||
@@ -1264,13 +1295,14 @@ impl DrawOrderCommand {
|
||||
} else {
|
||||
DrawOrderStep::ChooseVerb
|
||||
};
|
||||
Self { selected, step }
|
||||
Self { selected, step, references: Vec::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn for_reference_pick(selected: Vec<acadrust::Handle>, above: bool) -> Self {
|
||||
Self {
|
||||
selected,
|
||||
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()
|
||||
}
|
||||
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 } => {
|
||||
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 {
|
||||
if matches!(self.step, DrawOrderStep::SelectObjects) {
|
||||
if matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. }) {
|
||||
crate::command::InputKind::Point
|
||||
} else {
|
||||
crate::command::InputKind::SingleToken
|
||||
@@ -1318,11 +1350,15 @@ impl CadCommand for DrawOrderCommand {
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1341,7 +1377,12 @@ impl CadCommand for DrawOrderCommand {
|
||||
let handles = std::mem::take(&mut self.selected);
|
||||
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),
|
||||
}
|
||||
}
|
||||
DrawOrderStep::PickReference { above } => {
|
||||
let hex_str = t.trim_start_matches("0x").trim_start_matches("0X");
|
||||
if let Ok(val) = u64::from_str_radix(hex_str, 16) {
|
||||
let opt = if above { "A" } else { "U" };
|
||||
let cmd = format!("DRAWORDER {} {:x}", opt, val);
|
||||
let handles = std::mem::take(&mut self.selected);
|
||||
Some(crate::command::CmdResult::Relaunch(cmd, handles))
|
||||
} else {
|
||||
Some(crate::command::CmdResult::NeedPoint)
|
||||
DrawOrderStep::PickReference { .. } => {
|
||||
for text in t.split_whitespace() {
|
||||
let hex = text.trim_start_matches("0x").trim_start_matches("0X");
|
||||
if let Ok(value) = u64::from_str_radix(hex, 16) {
|
||||
let handle = acadrust::Handle::new(value);
|
||||
if !handle.is_null() && !self.selected.contains(&handle) && !self.references.contains(&handle) {
|
||||
self.references.push(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::command::CmdResult::NeedPoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, DrawOrderStep::PickReference { .. })
|
||||
}
|
||||
|
||||
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
|
||||
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: glam::DVec3) -> crate::command::CmdResult {
|
||||
if matches!(self.step, DrawOrderStep::PickReference { .. }) && !handle.is_null()
|
||||
&& !self.selected.contains(&handle) && !self.references.contains(&handle) {
|
||||
self.references.push(handle);
|
||||
}
|
||||
crate::command::CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult {
|
||||
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.
|
||||
///
|
||||
/// 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_ne!(hatch1_sort, hatch2_sort, "multi-select UNDER must not tie selected entities");
|
||||
assert!(
|
||||
hatch1_sort > hatch2_sort,
|
||||
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]
|
||||
fn draworder_front_moves_hatch_in_front_of_all() {
|
||||
let mut app = fresh_app();
|
||||
@@ -2175,18 +2333,18 @@ mod tests {
|
||||
_ => 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]);
|
||||
assert!(!cmd.needs_entity_pick());
|
||||
let res = cmd.on_text_input("A");
|
||||
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 res = cmd.on_text_input("U");
|
||||
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]
|
||||
@@ -2198,14 +2356,15 @@ mod tests {
|
||||
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()));
|
||||
|
||||
// Above with viewport pick
|
||||
// Above with a gathered viewport selection
|
||||
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
||||
let _ = cmd.on_text_input("A");
|
||||
assert!(cmd.needs_entity_pick());
|
||||
let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO);
|
||||
assert!(cmd.is_selection_gathering());
|
||||
let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]);
|
||||
let pick_res = cmd.on_enter();
|
||||
match pick_res {
|
||||
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]);
|
||||
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
||||
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());
|
||||
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 _ = cmd.on_text_input("U");
|
||||
assert!(cmd.needs_entity_pick());
|
||||
let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO);
|
||||
assert!(cmd.is_selection_gathering());
|
||||
let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]);
|
||||
let pick_res = cmd.on_enter();
|
||||
match pick_res {
|
||||
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]);
|
||||
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
||||
let _ = app.run_command_line(&relaunch_cmd);
|
||||
@@ -2252,9 +2412,10 @@ mod tests {
|
||||
let _ = cmd.on_text_input("Above");
|
||||
let hex_input = format!("0x{:x}", h_ref.value());
|
||||
let typed_res = cmd.on_text_input(&hex_input);
|
||||
match typed_res {
|
||||
Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => {
|
||||
assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value()));
|
||||
assert!(matches!(typed_res, Some(crate::command::CmdResult::NeedPoint)));
|
||||
match cmd.on_enter() {
|
||||
crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => {
|
||||
assert_eq!(relaunch_cmd, format!("DRAWORDER ABOVE {:x}", h_ref.value()));
|
||||
assert_eq!(handles, vec![h_hatch]);
|
||||
app.tabs[i].scene.replace_selection(handles.into_iter().collect());
|
||||
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());
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Every consumer that needs UCS-relative coordinates goes through this.
|
||||
pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform {
|
||||
@@ -644,7 +655,11 @@ impl DocumentTab {
|
||||
/// full entity store.
|
||||
#[derive(Clone)]
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -677,6 +692,18 @@ impl HistorySnapshot {
|
||||
.as_ref()
|
||||
.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()),
|
||||
HistorySnapshot::ObjectVisibility(v) => v
|
||||
.before
|
||||
@@ -733,6 +760,17 @@ pub(super) struct DeltaSnapshot {
|
||||
/// Opposite non-entity document state. `apply_delta_state` swaps this with
|
||||
/// the live structure, so the same allocation shuttles between undo/redo.
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -793,6 +831,15 @@ pub(super) struct ObjectEntryDelta {
|
||||
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)]
|
||||
pub(super) struct HistoryState {
|
||||
pub(super) undo_stack: Vec<HistorySnapshot>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
document::{
|
||||
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
|
||||
PendingHistorySnapshot, StructureSnapshot, TableEntryDelta,
|
||||
PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta,
|
||||
},
|
||||
OpenCADStudio,
|
||||
};
|
||||
@@ -225,9 +225,11 @@ impl OpenCADStudio {
|
||||
dirty_after: true,
|
||||
active_layer: None,
|
||||
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
|
||||
sketch_constraints: Vec::new(),
|
||||
named_parameters: None,
|
||||
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) {
|
||||
@@ -334,9 +336,11 @@ impl OpenCADStudio {
|
||||
active_layer: (pending.active_layer != active_layer_after)
|
||||
.then_some((pending.active_layer, active_layer_after)),
|
||||
structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)),
|
||||
sketch_constraints: Vec::new(),
|
||||
named_parameters: None,
|
||||
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) {
|
||||
@@ -441,9 +445,11 @@ impl OpenCADStudio {
|
||||
dirty_after: self.tabs[i].dirty,
|
||||
active_layer: None,
|
||||
structure: Some(StructureSnapshot::Layers(entries)),
|
||||
sketch_constraints: Vec::new(),
|
||||
named_parameters: None,
|
||||
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(
|
||||
@@ -496,9 +502,11 @@ impl OpenCADStudio {
|
||||
dirty_after: self.tabs[i].dirty,
|
||||
active_layer: None,
|
||||
structure: Some(StructureSnapshot::TextStyles(entries)),
|
||||
sketch_constraints: Vec::new(),
|
||||
named_parameters: None,
|
||||
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(
|
||||
@@ -551,9 +559,11 @@ impl OpenCADStudio {
|
||||
dirty_after: self.tabs[i].dirty,
|
||||
active_layer: None,
|
||||
structure: Some(StructureSnapshot::DimStyles(entries)),
|
||||
sketch_constraints: Vec::new(),
|
||||
named_parameters: None,
|
||||
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> {
|
||||
@@ -614,9 +624,11 @@ impl OpenCADStudio {
|
||||
dirty_after: self.tabs[i].dirty,
|
||||
active_layer: None,
|
||||
structure: Some(StructureSnapshot::Objects(entries)),
|
||||
sketch_constraints: Vec::new(),
|
||||
named_parameters: None,
|
||||
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(
|
||||
@@ -647,9 +659,11 @@ impl OpenCADStudio {
|
||||
dim_names,
|
||||
object_handles,
|
||||
}),
|
||||
sketch_constraints: Vec::new(),
|
||||
named_parameters: None,
|
||||
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
|
||||
@@ -727,7 +741,8 @@ impl OpenCADStudio {
|
||||
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
|
||||
.into_iter()
|
||||
.map(|(h, before)| {
|
||||
@@ -746,6 +761,25 @@ impl OpenCADStudio {
|
||||
})
|
||||
})
|
||||
.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 dirty_after = self.tabs[i].dirty;
|
||||
let mut structure = pending.structure_before.map(StructureSnapshot::Full);
|
||||
@@ -781,9 +815,11 @@ impl OpenCADStudio {
|
||||
dirty_after,
|
||||
active_layer: None,
|
||||
structure,
|
||||
sketch_constraints,
|
||||
named_parameters,
|
||||
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
|
||||
@@ -909,6 +945,13 @@ impl OpenCADStudio {
|
||||
}
|
||||
let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
|
||||
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 {
|
||||
(&d.selected_before, d.dirty_before)
|
||||
} else {
|
||||
|
||||
@@ -9,8 +9,6 @@ pub(crate) mod config;
|
||||
pub use automation::{export_headless, serve};
|
||||
mod command_driver;
|
||||
pub(crate) mod commands;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod doc_api;
|
||||
mod document;
|
||||
pub(crate) mod expr_eval;
|
||||
mod find_replace;
|
||||
@@ -307,6 +305,7 @@ struct AddSelectedRestore {
|
||||
layer_name: String,
|
||||
layer_handle: acadrust::types::Handle,
|
||||
color: AcadColor,
|
||||
transparency: acadrust::types::Transparency,
|
||||
linetype_name: String,
|
||||
linetype_handle: acadrust::types::Handle,
|
||||
line_weight: i16,
|
||||
@@ -511,6 +510,7 @@ pub(super) struct OpenCADStudio {
|
||||
/// Selected-object count past which grips stop being generated
|
||||
/// (GRIPOBJLIMIT, 0..=32767; 0 = no limit).
|
||||
grip_object_limit: i32,
|
||||
ncopy_bind: bool,
|
||||
/// Drawing viewport cursor style (CURSORTYPE).
|
||||
cursor_type: settings::CursorType,
|
||||
/// Explicit crosshair colour; `None` retains automatic contrast.
|
||||
@@ -547,6 +547,14 @@ pub(super) struct OpenCADStudio {
|
||||
/// When true (default), the app registers itself as a .dwg/.dxf/.bak file
|
||||
/// handler on each launch. Toggle with the FILEASSOC command.
|
||||
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);
|
||||
/// 0 disables autosave.
|
||||
pub savetime_min: i32,
|
||||
@@ -1040,6 +1048,15 @@ pub(super) struct OpenCADStudio {
|
||||
/// edited. Seeded from `command_aliases` on open, committed back on close.
|
||||
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_selected: String,
|
||||
layout_manager_rename_buf: String,
|
||||
@@ -1717,6 +1734,7 @@ pub enum ModalKind {
|
||||
AttributeEditor,
|
||||
LayerDeleteWarning,
|
||||
Aliases,
|
||||
NamedParameters,
|
||||
ScaleManager,
|
||||
/// Add / remove the annotation scales a single selected object has a
|
||||
/// per-object representation for.
|
||||
@@ -2071,6 +2089,11 @@ pub enum Message {
|
||||
/// Register or unregister as the .dwg/.dxf handler, from Options. Same
|
||||
/// setting the FILEASSOC command carries.
|
||||
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.
|
||||
LanguageChanged(crate::i18n::Language),
|
||||
/// Drop every entity from the active drawing.
|
||||
@@ -2494,6 +2517,10 @@ pub enum Message {
|
||||
CloseLayoutList,
|
||||
/// Cycle the coordinate readout mode ($COORDS): static → live → polar.
|
||||
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.
|
||||
ToggleStatusBarMenu,
|
||||
/// Close the status-bar customization menu.
|
||||
@@ -2789,6 +2816,46 @@ pub enum Message {
|
||||
AliasEditorRemove(usize),
|
||||
/// Commit the edited rows to the alias table (Apply button); stays open.
|
||||
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 ────────────────────────────────────────────────────
|
||||
AboutOpen,
|
||||
/// Close whatever in-canvas modal dialog is open (Plan B).
|
||||
@@ -3461,6 +3528,7 @@ impl OpenCADStudio {
|
||||
double_click_block_refedit: false,
|
||||
double_click_block_attedit: true,
|
||||
grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT,
|
||||
ncopy_bind: false,
|
||||
cursor_type: settings::CursorType::Crosshair,
|
||||
crosshair_color: None,
|
||||
crosshair_color_input: String::new(),
|
||||
@@ -3478,6 +3546,8 @@ impl OpenCADStudio {
|
||||
dimension_continue_mode: 1,
|
||||
backup_on_save: true,
|
||||
file_assoc_enabled: true,
|
||||
write_dwg_native_constraints: false,
|
||||
show_constraint_values: true,
|
||||
savetime_min: 10,
|
||||
default_bg_color: None,
|
||||
default_paper_bg_color: None,
|
||||
@@ -3669,6 +3739,7 @@ impl OpenCADStudio {
|
||||
// Command aliases (populated from ocad.pgp just after construction)
|
||||
command_aliases: rustc_hash::FxHashMap::default(),
|
||||
alias_editor_rows: Vec::new(),
|
||||
named_parameter_editor_rows: Vec::new(),
|
||||
// Layout Manager
|
||||
layout_manager_selected: "Model".to_string(),
|
||||
layer_state_selected: None,
|
||||
|
||||
@@ -16,8 +16,6 @@ pub(crate) struct HostSession<'a> {
|
||||
app: &'a mut OpenCADStudio,
|
||||
tab: usize,
|
||||
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> {
|
||||
@@ -26,7 +24,6 @@ impl<'a> HostSession<'a> {
|
||||
app,
|
||||
tab,
|
||||
doc_store: None,
|
||||
doc_api_pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,39 +242,6 @@ impl<'a> HostSession<'a> {
|
||||
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) {
|
||||
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) {
|
||||
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)
|
||||
|
||||
@@ -306,7 +306,7 @@ impl OpenCADStudio {
|
||||
label: t!("Transparency").into_owned(),
|
||||
field: "transparency",
|
||||
value: PropValue::EditChoice {
|
||||
value: "ByLayer".to_string(),
|
||||
value: crate::scene::creation_style::current_transparency_label(self.tabs[i].scene.document.current_entity_transparency()),
|
||||
options: vec!["ByLayer".to_string(), "ByBlock".to_string()],
|
||||
},
|
||||
},
|
||||
@@ -406,6 +406,37 @@ impl OpenCADStudio {
|
||||
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 {
|
||||
title: t!("No selection").into_owned(),
|
||||
@@ -2126,6 +2157,53 @@ impl OpenCADStudio {
|
||||
if compact_solid {
|
||||
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 {
|
||||
acadrust::EntityType::Insert(ins) => {
|
||||
let is_xref = self.tabs[i]
|
||||
@@ -3112,6 +3190,15 @@ fn make_sections_read_only(
|
||||
}
|
||||
PropValue::Stepper { display, .. } => display.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.value = PropValue::ReadOnly(text);
|
||||
|
||||
@@ -203,6 +203,8 @@ pub struct UserSettings {
|
||||
/// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at
|
||||
/// all. 0 means no limit. The drawing header carries no slot for it.
|
||||
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.
|
||||
#[serde(default, deserialize_with = "deserialize_options_tab")]
|
||||
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
|
||||
/// handler on every launch. Toggle with the FILEASSOC command.
|
||||
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).
|
||||
/// 0 disables autosave.
|
||||
pub savetime_min: i32,
|
||||
@@ -342,6 +354,10 @@ fn default_dimension_continue_mode() -> i16 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_show_constraint_values() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
@@ -372,6 +388,7 @@ impl Default for UserSettings {
|
||||
double_click_block_refedit: false,
|
||||
double_click_block_attedit: true,
|
||||
grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT,
|
||||
ncopy_bind: false,
|
||||
cursor_type: CursorType::Crosshair,
|
||||
crosshair_color: None,
|
||||
lineweight_display_scale: 100,
|
||||
@@ -395,6 +412,8 @@ impl Default for UserSettings {
|
||||
textfill: true,
|
||||
backup_on_save: true,
|
||||
file_assoc_enabled: true,
|
||||
write_dwg_native_constraints: false,
|
||||
show_constraint_values: true,
|
||||
savetime_min: 10,
|
||||
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
||||
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> {
|
||||
if self.ribbon.escape_extension() {
|
||||
return Task::none();
|
||||
}
|
||||
// Esc drops an unconsumed one-shot snap override and closes
|
||||
// its menu (#337). Falls through — Esc keeps its usual effect.
|
||||
self.snap_override_popup = None;
|
||||
@@ -1997,9 +2000,6 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
if let Some(acadrust::EntityType::Hatch(dxf)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
let old_origin = dxf.pattern.lines.first().map(|line| {
|
||||
(line.base_point.x, line.base_point.y)
|
||||
});
|
||||
let mut pattern = hatch_patterns::build_dxf_pattern(entry);
|
||||
// Stored pattern lines are final world-space
|
||||
// geometry. Preserve the selected hatch's scale,
|
||||
@@ -2013,17 +2013,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
&mut pattern,
|
||||
dxf.pattern_angle,
|
||||
);
|
||||
if let (Some((old_x, old_y)), Some(new_origin)) =
|
||||
(old_origin, pattern.lines.first())
|
||||
{
|
||||
let dx = old_x - new_origin.base_point.x;
|
||||
let dy = old_y - new_origin.base_point.y;
|
||||
crate::entities::hatch::translate_pattern_geometry(
|
||||
&mut pattern,
|
||||
dx,
|
||||
dy,
|
||||
);
|
||||
}
|
||||
let origin = dxf.pattern_origin();
|
||||
crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y);
|
||||
dxf.pattern = pattern;
|
||||
dxf.is_solid = matches!(
|
||||
entry.gpu,
|
||||
@@ -2808,7 +2799,28 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
} else {
|
||||
match field {
|
||||
"transparency" => {
|
||||
self.tabs[i].dirty = true;
|
||||
let Some(transparency) = crate::scene::creation_style::parse_current_transparency(&value) else {
|
||||
self.command_line.push_error("Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90.");
|
||||
self.refresh_properties();
|
||||
return Task::none();
|
||||
};
|
||||
if self.tabs[i].scene.document.current_entity_transparency()
|
||||
!= transparency
|
||||
{
|
||||
self.push_undo_snapshot(i, "CETRANSPARENCY");
|
||||
if self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.set_current_entity_transparency(transparency)
|
||||
{
|
||||
self.tabs[i].dirty = true;
|
||||
} else {
|
||||
self.discard_last_undo_entry(i);
|
||||
self.command_line.push_error(
|
||||
"CETRANSPARENCY: drawing variable dictionary is invalid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
self.refresh_properties();
|
||||
}
|
||||
"material" => {
|
||||
|
||||
@@ -404,6 +404,7 @@ impl OpenCADStudio {
|
||||
double_click_block_refedit: self.double_click_block_refedit,
|
||||
double_click_block_attedit: self.double_click_block_attedit,
|
||||
grip_object_limit: self.grip_object_limit,
|
||||
ncopy_bind: self.ncopy_bind,
|
||||
cursor_type: self.cursor_type,
|
||||
crosshair_color: self.crosshair_color,
|
||||
lineweight_display_scale: self.lineweight_display_scale,
|
||||
@@ -432,6 +433,8 @@ impl OpenCADStudio {
|
||||
textfill: crate::scene::text::sdf_atlas::textfill(),
|
||||
backup_on_save: self.backup_on_save,
|
||||
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,
|
||||
default_save_format: self.default_save_format.clone(),
|
||||
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_attedit = s.double_click_block_attedit;
|
||||
self.grip_object_limit = s.grip_object_limit.clamp(0, 32767);
|
||||
self.ncopy_bind = s.ncopy_bind;
|
||||
self.cursor_type = s.cursor_type;
|
||||
self.crosshair_color = s.crosshair_color;
|
||||
self.crosshair_color_input = s
|
||||
@@ -510,6 +514,8 @@ impl OpenCADStudio {
|
||||
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
|
||||
self.backup_on_save = s.backup_on_save;
|
||||
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.default_save_format =
|
||||
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 =
|
||||
path.parent().map(std::path::Path::to_path_buf);
|
||||
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]
|
||||
.scene
|
||||
.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.annotation_scale as f64;
|
||||
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"))]
|
||||
@@ -2651,6 +2669,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
||||
self.stamp_header_sysvars(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 bounds = crate::ui::wrap_bar::dropdown_bounds(
|
||||
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
|
||||
|
||||
@@ -3356,6 +3356,10 @@ impl OpenCADStudio {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ResolveOneSketchConflict => {
|
||||
self.resolve_one_sketch_conflict();
|
||||
Task::none()
|
||||
}
|
||||
Message::TogglePolar => {
|
||||
self.polar_mode ^= true;
|
||||
if self.polar_mode {
|
||||
@@ -5926,6 +5930,65 @@ impl OpenCADStudio {
|
||||
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 ───────────────────────────────────
|
||||
Message::OptionsOpen => {
|
||||
self.active_modal = Some(super::ModalKind::Options);
|
||||
@@ -6448,6 +6511,16 @@ impl OpenCADStudio {
|
||||
}
|
||||
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) => {
|
||||
if self.language == language {
|
||||
return Task::none();
|
||||
|
||||
@@ -1246,7 +1246,7 @@ impl OpenCADStudio {
|
||||
};
|
||||
|
||||
let mut seen_handles = rustc_hash::FxHashSet::default();
|
||||
let edited_handles: Vec<_> = grip
|
||||
let mut edited_handles: Vec<_> = grip
|
||||
.targets
|
||||
.iter()
|
||||
.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
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -3847,13 +3870,22 @@ impl OpenCADStudio {
|
||||
};
|
||||
use crate::command::CadCommand;
|
||||
use crate::modules::draw::draw::hatchedit::HatcheditCommand;
|
||||
let cmd: Box<dyn CadCommand> = Box::new(HatcheditCommand::with_handle(
|
||||
handle,
|
||||
model.name.clone(),
|
||||
scale,
|
||||
angle,
|
||||
annotative,
|
||||
));
|
||||
let current_color =
|
||||
self.tabs[i].scene.document.header.current_entity_color;
|
||||
let current_transparency =
|
||||
self.tabs[i].scene.document.current_entity_transparency();
|
||||
let current_origin = self.tabs[i].scene.document.hatch_origin();
|
||||
let cmd: Box<dyn CadCommand> = Box::new(
|
||||
HatcheditCommand::with_handle(
|
||||
handle,
|
||||
model.name.clone(),
|
||||
scale,
|
||||
angle,
|
||||
annotative,
|
||||
)
|
||||
.with_appearance(entity, current_color, current_transparency)
|
||||
.with_origin(current_origin),
|
||||
);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(cmd);
|
||||
} else {
|
||||
@@ -3887,6 +3919,7 @@ impl OpenCADStudio {
|
||||
} else if self.tabs[i].active_cmd.as_ref()
|
||||
.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))
|
||||
} else if self.tabs[i]
|
||||
.active_cmd
|
||||
@@ -3963,6 +3996,7 @@ impl OpenCADStudio {
|
||||
// (LINE tangent to two circles, which needs both). When
|
||||
// it does, sync last_point to the command's resolved
|
||||
// anchor since it replaced the picked coordinate.
|
||||
self.refresh_command_point_pick_context(i);
|
||||
let handled = self.tabs[i]
|
||||
.active_cmd
|
||||
.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| {
|
||||
!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(
|
||||
std::sync::Arc::clone(&tab.scene.selection),
|
||||
snap_info,
|
||||
@@ -847,6 +891,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
grip_hot: self.model_space.grip_hot,
|
||||
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.statusbar_config,
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,7 @@ impl OpenCADStudio {
|
||||
Some(K::About) => crate::tr!("modal", "about"),
|
||||
Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"),
|
||||
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::FindReplace) => crate::tr!("modal", "find-replace"),
|
||||
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(
|
||||
ex,
|
||||
880,
|
||||
@@ -181,6 +197,8 @@ impl OpenCADStudio {
|
||||
crate::ui::window::options::view_window(
|
||||
&self.default_save_format,
|
||||
self.file_assoc_enabled,
|
||||
self.write_dwg_native_constraints,
|
||||
self.show_constraint_values,
|
||||
&self.ui_theme,
|
||||
&self.theme_color_inputs,
|
||||
self.language,
|
||||
|
||||