diff --git a/Cargo.lock b/Cargo.lock
index d34c62597..ef3cd2898 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
@@ -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"
@@ -893,6 +902,14 @@ dependencies = [
"spade",
]
+[[package]]
+name = "cadkernel-constraints"
+version = "0.1.0"
+source = "git+https://github.com/ramox81/cadkernel.git?rev=0ccc61978286a6ad8467ac02de566ffd8ee100b3#0ccc61978286a6ad8467ac02de566ffd8ee100b3"
+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"
diff --git a/Cargo.toml b/Cargo.toml
index 228c8a5ab..cca2c840a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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]
@@ -33,6 +32,7 @@ clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "2ecf0c57d387e293d11042aa37637322f2ab9c01", features = ["serde"] }
cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3", features = ["acis", "offset"] }
+cadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3" }
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"] }
diff --git a/assets/icons/constrain/angle.svg b/assets/icons/constrain/angle.svg
new file mode 100644
index 000000000..e632dd2c8
--- /dev/null
+++ b/assets/icons/constrain/angle.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/constrain/center_point.svg b/assets/icons/constrain/center_point.svg
new file mode 100644
index 000000000..cb4cb7333
--- /dev/null
+++ b/assets/icons/constrain/center_point.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/coincident.svg b/assets/icons/constrain/coincident.svg
new file mode 100644
index 000000000..263234192
--- /dev/null
+++ b/assets/icons/constrain/coincident.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/colinear.svg b/assets/icons/constrain/colinear.svg
new file mode 100644
index 000000000..be8d671c9
--- /dev/null
+++ b/assets/icons/constrain/colinear.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/concentric.svg b/assets/icons/constrain/concentric.svg
new file mode 100644
index 000000000..d68acc43b
--- /dev/null
+++ b/assets/icons/constrain/concentric.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/distance.svg b/assets/icons/constrain/distance.svg
new file mode 100644
index 000000000..278ee92ee
--- /dev/null
+++ b/assets/icons/constrain/distance.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/icons/constrain/equal.svg b/assets/icons/constrain/equal.svg
new file mode 100644
index 000000000..bee8e3fec
--- /dev/null
+++ b/assets/icons/constrain/equal.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/equal_distance.svg b/assets/icons/constrain/equal_distance.svg
new file mode 100644
index 000000000..9b027b7fd
--- /dev/null
+++ b/assets/icons/constrain/equal_distance.svg
@@ -0,0 +1,8 @@
+
diff --git a/assets/icons/constrain/fixed.svg b/assets/icons/constrain/fixed.svg
new file mode 100644
index 000000000..b0e75becb
--- /dev/null
+++ b/assets/icons/constrain/fixed.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/icons/constrain/horizontal.svg b/assets/icons/constrain/horizontal.svg
new file mode 100644
index 000000000..2259c70bf
--- /dev/null
+++ b/assets/icons/constrain/horizontal.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/constrain/midpoint.svg b/assets/icons/constrain/midpoint.svg
new file mode 100644
index 000000000..23ac2c0f1
--- /dev/null
+++ b/assets/icons/constrain/midpoint.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/normal.svg b/assets/icons/constrain/normal.svg
new file mode 100644
index 000000000..f4ddef0cd
--- /dev/null
+++ b/assets/icons/constrain/normal.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/constrain/parallel.svg b/assets/icons/constrain/parallel.svg
new file mode 100644
index 000000000..891fd41f1
--- /dev/null
+++ b/assets/icons/constrain/parallel.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/perpendicular.svg b/assets/icons/constrain/perpendicular.svg
new file mode 100644
index 000000000..520becabb
--- /dev/null
+++ b/assets/icons/constrain/perpendicular.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/point_on_curve.svg b/assets/icons/constrain/point_on_curve.svg
new file mode 100644
index 000000000..745845a1e
--- /dev/null
+++ b/assets/icons/constrain/point_on_curve.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/constrain/symmetric.svg b/assets/icons/constrain/symmetric.svg
new file mode 100644
index 000000000..6a73ba883
--- /dev/null
+++ b/assets/icons/constrain/symmetric.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/icons/constrain/tangent.svg b/assets/icons/constrain/tangent.svg
new file mode 100644
index 000000000..7d0422129
--- /dev/null
+++ b/assets/icons/constrain/tangent.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/constrain/vertical.svg b/assets/icons/constrain/vertical.svg
new file mode 100644
index 000000000..81c68fe6e
--- /dev/null
+++ b/assets/icons/constrain/vertical.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/modify_align.svg b/assets/icons/modify_align.svg
new file mode 100644
index 000000000..3b661c84f
--- /dev/null
+++ b/assets/icons/modify_align.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_break.svg b/assets/icons/modify_break.svg
new file mode 100644
index 000000000..83c41029a
--- /dev/null
+++ b/assets/icons/modify_break.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_breakatpoint.svg b/assets/icons/modify_breakatpoint.svg
new file mode 100644
index 000000000..588d967f8
--- /dev/null
+++ b/assets/icons/modify_breakatpoint.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_draworder.svg b/assets/icons/modify_draworder.svg
new file mode 100644
index 000000000..a5ae19f04
--- /dev/null
+++ b/assets/icons/modify_draworder.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_hatchedit.svg b/assets/icons/modify_hatchedit.svg
new file mode 100644
index 000000000..82ea41111
--- /dev/null
+++ b/assets/icons/modify_hatchedit.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_join.svg b/assets/icons/modify_join.svg
new file mode 100644
index 000000000..687232f44
--- /dev/null
+++ b/assets/icons/modify_join.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_lengthen.svg b/assets/icons/modify_lengthen.svg
new file mode 100644
index 000000000..834650c5d
--- /dev/null
+++ b/assets/icons/modify_lengthen.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_ncopy.svg b/assets/icons/modify_ncopy.svg
new file mode 100644
index 000000000..a7ce9f0ba
--- /dev/null
+++ b/assets/icons/modify_ncopy.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_overkill.svg b/assets/icons/modify_overkill.svg
new file mode 100644
index 000000000..3ddb7bfa8
--- /dev/null
+++ b/assets/icons/modify_overkill.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_pedit.svg b/assets/icons/modify_pedit.svg
new file mode 100644
index 000000000..ef1459050
--- /dev/null
+++ b/assets/icons/modify_pedit.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_reverse.svg b/assets/icons/modify_reverse.svg
new file mode 100644
index 000000000..04c802a8d
--- /dev/null
+++ b/assets/icons/modify_reverse.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_setbylayer.svg b/assets/icons/modify_setbylayer.svg
new file mode 100644
index 000000000..9ce5827de
--- /dev/null
+++ b/assets/icons/modify_setbylayer.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/modify_splinedit.svg b/assets/icons/modify_splinedit.svg
new file mode 100644
index 000000000..977c90ddc
--- /dev/null
+++ b/assets/icons/modify_splinedit.svg
@@ -0,0 +1 @@
+
diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md
deleted file mode 100644
index 8603f8a10..000000000
--- a/crates/ocs_doc_api/ARCHITECTURE.md
+++ /dev/null
@@ -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.
diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml
deleted file mode 100644
index dc0c9a7b1..000000000
--- a/crates/ocs_doc_api/Cargo.toml
+++ /dev/null
@@ -1,66 +0,0 @@
-[package]
-name = "ocs_doc_api"
-version = "0.1.0"
-edition = "2021"
-description = "Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and hosts (v2)."
-license = "MIT"
-build = "build.rs"
-
-# Dependency wiring: pin the SAME cadkernel/acadrust git revs
-# as the host so `Body`/`EntityType` types are ABI-identical.
-[dependencies]
-serde = { version = "1", features = ["derive"] }
-bincode = "1.3"
-thiserror = "1"
-
-# Host adapters convert ObjectId to acadrust handles; kernel handles stay local.
-acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "2ecf0c57d387e293d11042aa37637322f2ab9c01", features = ["serde"], optional = true }
-cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3", features = ["acis", "offset"], optional = true }
-ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] }
-
-[build-dependencies]
-serde = { version = "1", features = ["derive"] }
-serde_json = "1"
-toml = "1"
-
-[dev-dependencies]
-serde_json = "1"
-toml = "1"
-
-[features]
-# Default keeps the facade and DTOs independent of geometry dependencies.
-default = []
-# host: pull acadrust + cadkernel + the executor/backend trait (in-process side).
-host = ["acadrust", "cadkernel"]
-# ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side).
-ipc = ["acadrust", "ocs_plugin_api"]
-# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only).
-doc_api_host = ["ipc"]
-
-[lib]
-name = "ocs_doc_api"
-path = "src/lib.rs"
-
-[[test]]
-name = "mock_backend"
-required-features = ["host"]
-
-[[example]]
-name = "e1_primitives_and_queries"
-required-features = ["host"]
-
-[[example]]
-name = "e2_intersect_two_solids"
-required-features = ["host"]
-
-[[example]]
-name = "e3_transform_and_chain"
-required-features = ["host"]
-
-[[example]]
-name = "e4_2d_entities_and_bulk"
-required-features = ["host"]
-
-[[example]]
-name = "e5_failure_and_guards"
-required-features = ["host"]
diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md
deleted file mode 100644
index cc38a56ca..000000000
--- a/crates/ocs_doc_api/README.md
+++ /dev/null
@@ -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 {
- 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` 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 {
- 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.
diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md
deleted file mode 100644
index deb13c55d..000000000
--- a/crates/ocs_doc_api/bindings/README.md
+++ /dev/null
@@ -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`.
-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.
diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py
deleted file mode 100644
index d361493ba..000000000
--- a/crates/ocs_doc_api/bindings/python/generate.py
+++ /dev/null
@@ -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()
diff --git a/crates/ocs_doc_api/bindings/python/test_generate.py b/crates/ocs_doc_api/bindings/python/test_generate.py
deleted file mode 100644
index b63ed1fe8..000000000
--- a/crates/ocs_doc_api/bindings/python/test_generate.py
+++ /dev/null
@@ -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()
diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs
deleted file mode 100644
index c951f0d66..000000000
--- a/crates/ocs_doc_api/build.rs
+++ /dev/null
@@ -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,
- #[serde(default)]
- kernel_map: Vec,
-}
-
-#[derive(Deserialize)]
-struct Meta {
- envelope_version: u16,
-}
-
-#[derive(Deserialize)]
-struct Family {
- name: String,
- #[serde(default)]
- acadrust_variant: Option,
- #[serde(default)]
- handle: Option,
- #[serde(default)]
- collection: Option,
- #[serde(default)]
- description: Option,
- #[serde(default)]
- constructor: Vec,
- #[serde(default)]
- method: Vec,
-}
-
-#[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,
- #[serde(default)]
- op: Option,
- #[serde(default)]
- fixed: Option,
- #[serde(default)]
- bulk: bool,
- #[serde(default)]
- returns: Option,
- #[serde(default)]
- status: Option,
-}
-
-#[derive(Deserialize)]
-struct Method {
- name: String,
- kind: String, // "op" | "query"
- #[serde(default)]
- op: Option,
- #[serde(default)]
- query: Option,
- #[serde(default)]
- args: Vec,
- #[serde(default)]
- returns: Option,
- #[serde(default)]
- fixed: Option,
- #[serde(default)]
- status: Option,
-}
-
-#[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 = 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::>()
- .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`. 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)` 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)` (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 {
- 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 = spec
- .family
- .iter()
- .map(|fam| {
- let methods: Vec = 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::>(),
- "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::>()
- })).collect::>(),
- })
- })
- .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")
-}
diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs
deleted file mode 100644
index 8b7b7454b..000000000
--- a/crates/ocs_doc_api/examples/common/mod.rs
+++ /dev/null
@@ -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 + 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,
- kinds: HashMap,
- /// Stored 2D curve specs so `bounds()` works on non-solid entities too.
- curves: HashMap,
- /// Stored text content for Text/MText annotations.
- text_values: HashMap,
- /// Stored hatch boundary loops.
- hatch_boundaries: HashMap>>,
- /// Stored dimension measurements.
- dimension_measurements: HashMap,
- /// Stored insert attributes.
- attributes_store: HashMap>,
- /// Stored viewport views (target, height).
- viewport_views: HashMap,
- /// 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,
- /// Stored layer table.
- layers: HashMap,
- /// Layer name assigned to each entity.
- entity_layers: HashMap,
-}
-
-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 {
- Ok(self.body(id)?.clone())
- }
- fn store_solid(&mut self, body: &KernelBody) -> ApiResult {
- 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> {
- // 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::>>()?;
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- self.text_values
- .get(&id)
- .cloned()
- .ok_or(ApiError::UnknownId(id))
- }
- fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult {
- let id = self.alloc("Hatch");
- self.hatch_boundaries
- .insert(id, vec![spec.boundary.clone()]);
- Ok(id)
- }
- fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> {
- self.hatch_boundaries
- .get(&id)
- .cloned()
- .ok_or(ApiError::UnknownId(id))
- }
- fn add_dimension_linear(
- &mut self,
- spec: &ocs_doc_api::ops::DimensionSpec,
- ) -> ApiResult {
- 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 {
- 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 {
- self.add_dimension_radius(spec)
- }
- fn add_dimension_angular(
- &mut self,
- _spec: &ocs_doc_api::ops::DimensionAngularSpec,
- ) -> ApiResult {
- let id = self.alloc("Dimension");
- self.dimension_measurements.insert(id, 90.0);
- Ok(id)
- }
- fn dimension_measurement(&self, id: ObjectId) -> ApiResult {
- self.dimension_measurements
- .get(&id)
- .copied()
- .ok_or(ApiError::UnknownId(id))
- }
- fn add_attribute_definition(
- &mut self,
- spec: &ocs_doc_api::ops::AttributeDefinitionSpec,
- ) -> ApiResult {
- 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 {
- 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 {
- 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> {
- self.attributes_store
- .get(&id)
- .cloned()
- .ok_or(ApiError::UnknownId(id))
- }
- fn block_entities(&self, _block_name: &str) -> ApiResult> {
- Ok(Vec::new())
- }
- fn add_raster_image(
- &mut self,
- _spec: &ocs_doc_api::ops::RasterImageSpec,
- ) -> ApiResult {
- Ok(self.alloc("RasterImage"))
- }
- fn loft(
- &mut self,
- sections: &[(cadkernel::space::Plane, Vec)],
- ) -> ApiResult {
- 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