forked from 1b-agent/opencadstudio-upstream
Add ocs_doc_api: object-oriented CAD entity API (v2)
New crate crates/ocs_doc_api: object-centric facade (DocApi -> Document -> collections -> typed handles), transport-agnostic wire layer (DocApiEnvelope = single op | read-only query batch), per-op autocommit (no write transactions), bulk ops with upfront-validation all-or-nothing, structured ApiError, and build-time codegen (spec/entities.toml -> ops/query enums, api_reference.md, binding_schema.json). ocs_plugin_api: append-only PluginRequest::DocApiRequest / PluginResponse:: DocApiResponse (serde_bytes payload) + defaulted HostApi::doc_api_dispatch. Host: DocApiBackend impl for HostSession (src/app/doc_api.rs) mapping to scene primitives (solid_models lift-on-miss, register_prepared_solid_model, meshes metrics), entity conversions (doc_api_convert.rs), HostSession accessors + doc_api_dispatch override. Tests: 592 host lib (incl. 7 DocApi integration tests: create/boolean/query, unknown-id, transform-many all-or-nothing, query-batch cap, 2D/geometric/DWG roundtrips), 8 crate unit tests. Examples + README/ARCHITECTURE/IMPLEMENTATION docs.
This commit is contained in:
1 parent
9129c85f97
commit
e41086b058
44 files changed
+4837
-12
No files matched your search
Generated
+96
-11
@@ -34,6 +34,7 @@ dependencies = [
|
||||
"lyon_tessellation",
|
||||
"meshopt",
|
||||
"naga 27.0.3",
|
||||
"ocs_doc_api",
|
||||
"ocs_plugin_api",
|
||||
"open",
|
||||
"printpdf",
|
||||
@@ -3886,6 +3887,20 @@ 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 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ocs_plugin_api"
|
||||
version = "0.2.1"
|
||||
@@ -3900,6 +3915,7 @@ dependencies = [
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde-reflection",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
@@ -4273,7 +4289,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.13+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5107,6 +5123,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -5151,6 +5177,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -5723,6 +5758,18 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
@@ -5731,11 +5778,20 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5747,6 +5803,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
@@ -5754,9 +5824,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5765,9 +5835,15 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -6899,6 +6975,15 @@ dependencies = [
|
||||
"xkbcommon-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
@@ -7100,7 +7185,7 @@ dependencies = [
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
@@ -7128,7 +7213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
@@ -7258,7 +7343,7 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
@@ -7286,5 +7371,5 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.119",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/ocs_web_worker",
|
||||
"crates/dwg-thumbnailer",
|
||||
"crates/dwg-thumbnailer-win",
|
||||
"crates/ocs_doc_api",
|
||||
]
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
@@ -69,6 +70,8 @@ acadrust = { git = "https://[email protected]/HakanSeven12/cadcodec.git", rev = "5b
|
||||
[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"] }
|
||||
rayon = "1"
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# ocs_doc_api — Architecture
|
||||
|
||||
This document explains how `ocs_doc_api` is put together and, most importantly,
|
||||
the **workflow for adding a new entity, family, or method**. It assumes you've
|
||||
read `README.md` (the user-facing surface).
|
||||
|
||||
## Layered design
|
||||
|
||||
```
|
||||
┌─ Facade (facade.rs) DocApi → Document → collections → typed handles
|
||||
│ Object-centric, hand-written. Methods build Operation/Query and call
|
||||
│ Transport::apply. OpGroup (client compensation), query_batch, assert_revision.
|
||||
│
|
||||
├─ Wire (envelope/ops/query) DocApiEnvelope = Op(single) | Queries(batch)
|
||||
│ bincode + serde. Operation/Query enums are GENERATED (src/gen) from the spec.
|
||||
│ Append-only variants → stable discriminants.
|
||||
│
|
||||
├─ Transport (transport/) trait Transport: Send+Sync { apply, alive }
|
||||
│ InProcess (host) | OcsPluginApiIpc (plugin). The ONLY plugin↔host boundary.
|
||||
│ Portability contract: &self + internal serialization + correlation + timeout.
|
||||
│
|
||||
├─ Executor (executor.rs) apply_op / apply_queries ← crate-owned logic
|
||||
│ Per-op atomicity: validate+compute BEFORE push_undo; mutate; finalize_op once.
|
||||
│
|
||||
├─ Backend (backend.rs) trait DocApiBackend ← thin host hook
|
||||
│ resolve_body / store_solid / update_solid / add_curve / transform_entity /
|
||||
│ bounds / volume / centroid / revision / push_undo / finalize_op /
|
||||
│ ensure_transformable / can_remove / ...
|
||||
│
|
||||
└─ Host (src/app/doc_api.rs) impl DocApiBackend for HostSession
|
||||
Maps to scene/app primitives: register_solid_model, restore_solid_models
|
||||
(lift-on-miss), solid_to_sat, edge_wires, erase_entities, meshes.metrics.
|
||||
```
|
||||
|
||||
The same `executor` runs in-process and over IPC — one implementation, versioned
|
||||
in the crate. The host only supplies scene primitives through `DocApiBackend`.
|
||||
|
||||
## Per-op execution model (the load-bearing invariant)
|
||||
|
||||
Every write op follows this exact sequence (see `executor::apply_op`):
|
||||
|
||||
1. **Validate + compute** — resolve inputs, run kernel geometry (`make_solid`,
|
||||
`brep::combine`, `brep::transform`), pre-flight fallible host work
|
||||
(`prepare_solid_model_display`), check `ensure_transformable` / `can_remove`.
|
||||
*No mutation yet.*
|
||||
2. **`push_undo(label)`** — begin the host's undo delta capture.
|
||||
3. **Mutate** — `store_solid` / `update_solid` / `add_curve` / `transform_entity` /
|
||||
`remove_entity`.
|
||||
4. **`finalize_op()`** — close the undo delta + republish the document view.
|
||||
|
||||
On any error in steps 1–3, the op returns before `finalize_op`: **nothing is
|
||||
applied, no undo step, no revision bump.** This is the per-op atomicity
|
||||
guarantee. `brep::combine` is pure; `erase_sources` only runs on success.
|
||||
|
||||
The geometry revision advances via the host's internal `bump_geometry` inside
|
||||
`add_entity`/`update_entity`/`register_prepared_solid_model`. The epoch moves
|
||||
monotonically per op (one undo step per op); the exact bump count is a host
|
||||
internal — do not assert on it.
|
||||
|
||||
## Object identity and the solid cache
|
||||
|
||||
- **`ObjectId`** = `acadrust::Handle` (u64). Kernel keys never cross the API.
|
||||
- **Solids** live in the host's `scene.solid_models: HashMap<Handle, Body>`
|
||||
cache. `resolve_body` does **lift-on-miss** (`restore_solid_models`): if a
|
||||
solid isn't cached, its body is re-lifted from the entity's ACIS SAT. This is
|
||||
what makes DocApi-created solids visible to host-native booleans and vice
|
||||
versa, and what makes a reloaded DWG's solids usable.
|
||||
- `store_solid` / `update_solid` pre-flight the mesh (`prepare_solid_model_display`)
|
||||
**before** committing, so the post-commit `register_prepared_solid_model`
|
||||
cannot fail — there is no rollback path that would bump the revision on failure.
|
||||
|
||||
## Bulk ops and read batching
|
||||
|
||||
- `CreateMany` / `TransformMany` / `DeleteMany` are **single ops**, one undo
|
||||
step, one publish — but **all-or-nothing via upfront validation**: every
|
||||
fallible check (make_solid, `ensure_transformable`, `can_remove`) runs before
|
||||
`push_undo`, so the apply loop cannot fail part-way. Item-capped
|
||||
(`BULK_ITEM_CAP`) to bound host execution.
|
||||
- Read-only query batching (`query_batch`) shares one round-trip and never bumps
|
||||
the revision. Also capped.
|
||||
- Why: per-op `publish_document_view` is a full-document serialization, so N
|
||||
sequential ops cost O(N²) — bulk ops collapse that to O(N) once. See
|
||||
"Performance" below.
|
||||
|
||||
## Codegen (build-time, spec-driven)
|
||||
|
||||
`build.rs` reads `spec/entities.toml` and regenerates the committed snapshots:
|
||||
|
||||
- `src/gen/ops_gen.rs`, `src/gen/query_gen.rs` — the `Operation`/`Query` enums
|
||||
(the canonical wire vocabulary; **append new variants at the END only**).
|
||||
- `src/gen/api_reference.md` — human API reference.
|
||||
- `src/gen/binding_schema.json` — the merged binding-handover schema (object
|
||||
model + method signatures + op/query mapping + wire layouts), exposed as
|
||||
`binding_schema_json()` for REPL/scripting bridges.
|
||||
|
||||
`cargo build` regenerates them deterministically; CI fails on drift.
|
||||
|
||||
---
|
||||
|
||||
## Workflow: adding a new entity, family, or method
|
||||
|
||||
This is the common task. Follow these steps in order.
|
||||
|
||||
### A. Add a new `Operation` or `Query` variant (the wire vocabulary)
|
||||
|
||||
1. **Append** the variant to `src/gen/ops_gen.rs` / `src/gen/query_gen.rs`
|
||||
**at the END** of the enum (never reorder — bincode discriminants are
|
||||
order-based). Use existing payload DTOs or add new plain-data mirrors in
|
||||
`src/ops.rs` / `src/query.rs`.
|
||||
2. Add its name to `Operation::op_name` / `Query::query_name` in `src/ops.rs` /
|
||||
`src/query.rs`.
|
||||
3. Regenerate: `cargo build -p ocs_doc_api` — the spec/schema pick up the wire
|
||||
change. Update `src/gen/layouts.json` so `binding_schema.json` stays
|
||||
self-contained.
|
||||
|
||||
### B. Add a family (e.g. a new entity kind)
|
||||
|
||||
1. **Spec** — add a `[[family]]` block in `spec/entities.toml` (name,
|
||||
`acadrust_variant`, `handle`, `collection`, and its `[[family.method]]`s).
|
||||
2. **Handle type** — add a `handle!(NewType)` line in `facade.rs` (gives it
|
||||
`bounds`/`delete`/`transform` + `id()`), plus `HasId`. Add family-specific
|
||||
methods as an `impl NewType` block if it has geometric actions.
|
||||
3. **Construction** — add a `create_*` method on the right collection
|
||||
(`SolidCollection`/`CurveCollection`/`EntityCollection`) building the op.
|
||||
4. **Backend conversion** — extend `src/app/doc_api_convert.rs`
|
||||
(`curve_spec_to_entity`, `entity_kind_name`, `entity_bounds`) to map the new
|
||||
acadrust `EntityType` variant.
|
||||
5. **Executor** — if the family has a new op variant, add its arm to
|
||||
`executor::apply_op`.
|
||||
|
||||
### C. Add a method to an existing family
|
||||
|
||||
1. **Wire** — if it's a new operation/query, do step A first. If it reuses an
|
||||
existing op (e.g. another boolean), skip to step 3.
|
||||
2. **Spec** — add a `[[family.method]]` entry under the family (name, kind,
|
||||
op/query, args, returns, `fixed` fields).
|
||||
3. **Facade** — add the method to the handle's `impl` block in `facade.rs`;
|
||||
it should build the `Operation`/`Query` and call `session.apply_op` /
|
||||
`session.one_query`, returning the typed result.
|
||||
4. **Executor + backend** — if the op is new, implement its arm in
|
||||
`executor::apply_op` (driving `DocApiBackend`), and add/override the backend
|
||||
method in the host (`src/app/doc_api.rs`) mapping to the scene primitive.
|
||||
5. **Test** — add a mock-backend unit test (`tests/mock_backend.rs`) and, for
|
||||
host-affecting ops, a `HostSession` integration test in `src/app/doc_api.rs`
|
||||
(model it on the existing roundtrip tests).
|
||||
|
||||
### D. Keep the invariants
|
||||
|
||||
- Validate + compute **before** `push_undo`; never mutate in the validate phase.
|
||||
- One undo step per op; `finalize_op` only on success.
|
||||
- New op/query variants **append at the end** of the generated enums.
|
||||
- Host geometry stays in the host (kernel keys never cross the wire); the wire
|
||||
carries `ObjectId`s and plain-data DTOs.
|
||||
|
||||
---
|
||||
|
||||
## Error model
|
||||
|
||||
`ApiError::{Validation, Geometry, UnknownId, Unsupported, Transport}` —
|
||||
serializable, so IPC returns the same structured error the in-process executor
|
||||
produced. Bulk-op validation names the failing index. A stale handle's methods
|
||||
return `UnknownId` (structured), never a panic.
|
||||
|
||||
## Performance notes
|
||||
|
||||
- **Per-op publish** (`finalize_op` → `publish_document_view`) is a
|
||||
full-document serialization; N sequential ops cost O(N²). This is the
|
||||
documented trade of per-op autocommit and the reason bulk ops exist.
|
||||
Measured estimate: ~10⁵ sequential creates+transforms ≈ minutes; the same as
|
||||
one `CreateMany`+`TransformMany` ≈ ~1 s. Use bulk ops for N ≳ a few hundred.
|
||||
- **`volume`/`centroid`** serve from `scene.meshes[handle].metrics` when cached;
|
||||
the kernel-mesh divergence is a cold-cache fallback.
|
||||
- **`resolve_body` lift-on-miss** is cheap when the cache is warm (no-op).
|
||||
- **IPC** serializes the envelope once; the `bytes` fields use `serde_bytes` so
|
||||
the outer frame is a single length-prefixed copy.
|
||||
|
||||
---
|
||||
|
||||
## Changes from the original design (review-driven)
|
||||
|
||||
The initial implementation was reviewed (`/review`) and refined in two rounds.
|
||||
This section records the concrete changes and *why* they were made, so the
|
||||
reasoning isn't lost. Tests are green after every change (592 host lib + 8 crate
|
||||
unit + 7 `HostSession` integration tests, incl. the regression tests added for
|
||||
the CRITICAL fixes).
|
||||
|
||||
### Files introduced / modified in `src/app/`
|
||||
|
||||
The host-side integration lives in `src/app/`. Concretely:
|
||||
|
||||
**New files**
|
||||
- **`src/app/doc_api.rs`** — the `DocApiBackend` implementation for `HostSession`
|
||||
(the thin host hook). Contains:
|
||||
- `execute_doc_api(host, tab_id, bytes)` — the dispatch entry: deserialize the
|
||||
`DocApiEnvelope`, run the crate executor, serialize `ApiResult<Receipt>`.
|
||||
- `impl DocApiBackend for HostSession` — `resolve_body` (lift-on-miss via
|
||||
`restore_solid_models`), `store_solid`/`update_solid` (pre-flight
|
||||
`prepare_solid_model_display` before commit → `register_prepared_solid_model`),
|
||||
`add_curve`, `remove_entity` (direct `erase_entities` + locked-layer error),
|
||||
`can_remove`, `ensure_transformable`, `transform_entity`, `bounds`
|
||||
(lift-on-miss for solids), `volume`/`centroid` (`meshes.metrics` cache +
|
||||
divergence fallback), `revision`, `push_undo`/`finalize_op`.
|
||||
- helpers `obj_to_handle`/`handle_to_obj`, `kernel_placement`,
|
||||
`mesh_volume_centroid` (single divergence-theorem helper).
|
||||
- 7 `#[cfg(test)]` integration tests (create/boolean/query roundtrip, unknown-id
|
||||
error, transform-many all-or-nothing, query-batch cap, 2D-entity roundtrip,
|
||||
geometric-methods roundtrip, **DWG write→reload roundtrip**).
|
||||
- **`src/app/doc_api_convert.rs`** — acadrust `EntityType` ↔ DTO conversions:
|
||||
`curve_spec_to_entity` (Line/Circle/LwPolyline/Point from `Curve2Spec`),
|
||||
`entity_kind_name`, `entity_bounds` (per-family coarse bounds for non-solids).
|
||||
|
||||
**Modified files**
|
||||
- **`src/app/mod.rs`** — registers the two new modules (`doc_api`,
|
||||
`doc_api_convert`), both gated `#[cfg(not(target_arch = "wasm32"))]`.
|
||||
- **`src/app/plugin_host.rs`** — extends `HostSession`:
|
||||
- a `doc_api_pending: Option<PendingDelta>` field (the in-flight per-op undo delta);
|
||||
- accessors used by the backend: `scene()`/`scene_mut()`,
|
||||
`commit_entity_handle(entity)` passthrough, `begin_doc_api_undo(label)`
|
||||
(→ `app.begin_undo`), `commit_doc_api_undo()` (→ `app.commit_undo_delta` +
|
||||
`publish_document_view`; **no** extra `bump_geometry` — the entity op already
|
||||
bumped);
|
||||
- `impl HostApi for HostSession::doc_api_dispatch` → routes to
|
||||
`doc_api::execute_doc_api` (gated `not(wasm32)`).
|
||||
|
||||
**`ocs_plugin_api` (sibling crate, also touched)**
|
||||
- `src/ipc/protocol.rs` — appended `PluginRequest::DocApiRequest{tab_id, bytes}`
|
||||
and `PluginResponse::DocApiResponse{bytes}` (append-only; `serde_bytes` on the
|
||||
payload).
|
||||
- `src/host.rs` — appended defaulted `HostApi::doc_api_dispatch` (returns
|
||||
"not supported"; `HostSession` overrides it). Append-only, object-safe.
|
||||
- `src/ipc/server.rs` — routes `DocApiRequest` → `host.doc_api_dispatch(...)`.
|
||||
|
||||
### Review round 1 — correctness & safety fixes
|
||||
|
||||
Two CRITICAL and six WARNING findings were fixed:
|
||||
|
||||
1. **Bulk ops are now truly all-or-nothing** (was CRITICAL). The original
|
||||
`TransformMany`/`DeleteMany`/`CreateMany` only existence-checked ids; a
|
||||
mid-apply failure (e.g. `store_solid` register failure, or `transform_entity`
|
||||
hitting a non-solid) left partial state with no undo step. Now the executor
|
||||
pre-validates *everything fallible* before `push_undo`:
|
||||
- `TransformMany` → new backend method `ensure_transformable(id)` (v1:
|
||||
solids-only) validated for **every** id first.
|
||||
- `DeleteMany` → `can_remove(id)` (existence + not-on-locked-layer) for every id.
|
||||
- `CreateMany` → its prepare loop was already pure; the residual `store_solid`
|
||||
post-commit failure is eliminated by the pre-flight in (8).
|
||||
- Regression test: `doc_api_transform_many_with_non_solid_fails_all_or_nothing`.
|
||||
|
||||
2. **Query batch is capped** (was CRITICAL / DoS). `apply_queries` had no bound —
|
||||
a ≤64 MiB envelope could carry millions of `GetVolume`/`GetCentroid` queries,
|
||||
each tessellating a solid on the synchronous UI thread. Now capped at
|
||||
`BULK_ITEM_CAP` like the write bulk ops. Regression test:
|
||||
`doc_api_query_batch_over_cap_is_rejected`.
|
||||
|
||||
3. **Locked-layer erase no longer silently succeeds** (was WARNING).
|
||||
`HostSession::remove_entity` returns `false` for locked-layer entities; the
|
||||
executor treated `Ok(false)` as success (`Delete` reported `Deleted`, boolean
|
||||
`erase_sources` left the second input live). Now:
|
||||
- backend `remove_entity` returns `Err(Unsupported)` when the entity is on a
|
||||
locked layer;
|
||||
- the boolean-erase path validates `can_remove(b)` **before** `update_solid(a)`;
|
||||
- `Delete`/`DeleteMany` pre-validate `can_remove` before `push_undo`.
|
||||
|
||||
4. **One publish per op for deletes** (was WARNING). The backend's `remove_entity`
|
||||
went through `HostSession::remove_entity`, which calls `publish_document_view`
|
||||
per invocation → `DeleteMany` triggered N+1 full-document publishes. The backend
|
||||
now calls `scene.erase_entities` directly (clears `solid_models`/meshes/hatches
|
||||
+ records the undo delta); the single publish happens at `finalize_op`.
|
||||
|
||||
5. **`bounds` lift-on-miss for solids** (was WARNING). `bounds` checked
|
||||
`solid_models.contains_key` and fell through to a non-solid `Unsupported` for a
|
||||
solid with a cold cache (e.g. dropped by a host-side `update_entity`). Now it
|
||||
resolves solids through `resolve_body` (lift-on-miss), consistent with
|
||||
`volume`/`centroid`.
|
||||
|
||||
6. **`volume`/`centroid` use the metrics cache** (was WARNING / perf). They
|
||||
re-tessellated the full body (`mesh_body`) + cloned the B-rep per query. Now
|
||||
they serve from `scene.meshes[handle].metrics` when cached (refreshed on
|
||||
store/update), with the kernel-mesh divergence as a cold-cache fallback. The
|
||||
duplicated divergence loops were also merged into one `mesh_volume_centroid`.
|
||||
|
||||
7. **Double revision bump documented** (was WARNING). Solid write ops bump
|
||||
`geometry_epoch` twice (the entity add/update path + `register_prepared_solid_model`).
|
||||
This is host-internal and shared with native commands; the meaningful guarantee
|
||||
(one undo step per op, monotonic epoch) holds and is tested. Documented rather
|
||||
than changed, to avoid touching shared host scene bump behavior.
|
||||
|
||||
8. **`store_solid` failure no longer moves the revision** (was WARNING). The
|
||||
original rollback path (`rollback_new_entities` on `register_solid_model`
|
||||
failure) bumped the epoch *again* on a failed op, violating "failed op = no
|
||||
bump." Now `store_solid`/`update_solid` **pre-flight** `prepare_solid_model_display`
|
||||
**before** `commit_entity_handle`, so a mesh-prep failure errors before any
|
||||
mutation — the rollback path is eliminated (and the double `prepare` work is
|
||||
removed). This also feeds the `CreateMany` all-or-nothing guarantee in (1).
|
||||
|
||||
### Review round 2 — cleanups (SUGGESTION)
|
||||
|
||||
- **`serde_bytes` on the IPC payload** — `PluginRequest::DocApiRequest.bytes` /
|
||||
`PluginResponse::DocApiResponse.bytes` now use `#[serde(with = "serde_bytes")]`
|
||||
so the outer frame is a single length-prefixed copy (was a per-byte seq walk +
|
||||
a second full copy on 10–20 MB bulk payloads).
|
||||
- **Dead code removed** — `src/gen/dispatch_gen.rs` (never generated/included),
|
||||
`Operation::references` (no call sites, false doc comment), the discarded
|
||||
serde-reflection tracer stub + `serde-reflection` build-dep in `build.rs`
|
||||
(it reads `layouts.json` directly now), and the dead `Session.tab` field
|
||||
(transport carries `tab_id`; `DocApi::document(tab)` keeps the arg for
|
||||
forward-compat but no longer stores it).
|
||||
|
||||
### Subsequent additions
|
||||
|
||||
- **Rename** `ocs_doc_api_2` → `ocs_doc_api` (directory, package, lib, all refs).
|
||||
The plan document under `.kilo/` keeps the historical name.
|
||||
- **DWG roundtrip robustness** — the stable-DWG test writes to
|
||||
`target/doc_api_roundtrip_intersected.dwg` (kept for inspection). It releases a
|
||||
stale lock first and falls back to a process-unique path if still locked, so it
|
||||
never flakes on Windows file locking (error 33).
|
||||
- **Docs** — `README.md`, this file, `IMPLEMENTATION.md`, enriched
|
||||
`spec/entities.toml` + the generated `api_reference.md` (constructors, generic
|
||||
methods, cross-cutting, bulk ops, `unsupported` markers).
|
||||
- **Examples** — five runnable examples under `examples/` (e1 primitives+queries,
|
||||
e2 booleans, e3 transform-chain, e4 2D+bulk, e5 failure+guards) over a shared
|
||||
in-process mock backend (`examples/common/mod.rs`).
|
||||
- **Tests** — 7 `HostSession` integration tests in `src/app/doc_api.rs`, incl.
|
||||
roundtrips for 2D entities, geometric methods, and a **DWG write→reload**
|
||||
roundtrip proving an intersected solid survives with valid ACIS and re-lifts to
|
||||
the expected volume.
|
||||
|
||||
### Known deviations (documented, not bugs)
|
||||
|
||||
- **Two `geometry_epoch` bumps per solid write op** (see #7) — host-internal,
|
||||
shared with native commands; monotonic and one-undo-step-per-op are guaranteed.
|
||||
- **`extrude`/`revolve`/`add_vertex`/non-solid `transform`** return
|
||||
`ApiError::Unsupported` in v1 (profile conversion + per-family transform land in
|
||||
later phases — see `IMPLEMENTATION.md`).
|
||||
- **`volume`/`centroid`** are mesh-approximate (render-mesh LOD tolerance), not
|
||||
exact analytic values.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
[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 (plan §2 decision #2): 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"
|
||||
|
||||
# acadrust is needed by the core DTOs (ObjectId = acadrust::Handle) and by the
|
||||
# executor (EntityType). cadkernel is needed for solid geometry (host feature).
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67f2427282d28922d9263e37b0136b6632e929b3", 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 = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67f2427282d28922d9263e37b0136b6632e929b3", features = ["acis", "offset"] }
|
||||
|
||||
[features]
|
||||
# default: pure data types + spec (no heavy deps), per plan §14 open Q1.
|
||||
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"]
|
||||
|
||||
[lib]
|
||||
name = "ocs_doc_api"
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,95 @@
|
||||
# ocs_doc_api — Implementation status & roadmap
|
||||
|
||||
What `ocs_doc_api` supports **now**, and the outstanding first-level entities
|
||||
and methods to be supported, in phases. "First-level" means the document's
|
||||
top-level entity store (model/paper-space direct children, not block-nested
|
||||
content).
|
||||
|
||||
## ✅ Supported (v1)
|
||||
|
||||
### Solids
|
||||
| Capability | Method / op | Notes |
|
||||
|---|---|---|
|
||||
| Primitives | `create_cuboid` / `create_sphere` / `create_cylinder` / `create_cone` / `create_torus` / `create_wedge` | `CreateSolid(SolidPrimitive)` → `brep::make::*` |
|
||||
| Booleans | `intersect` / `union` / `subtract` | `SolidBoolean{op, a, b, erase_sources:true}` → `brep::combine` |
|
||||
| Transform | `transform(placement)` (in-place) | `Transform{id, placement}` → `brep::transform`; solids only |
|
||||
| Bounds | `bounds()` | `GetBounds` → `brep::body_bounds` (lift-on-miss) |
|
||||
| Volume | `volume()` | `GetVolume` → `meshes.metrics` cache, mesh-divergence fallback |
|
||||
| Centroid | `centroid()` | `GetCentroid` → `meshes.metrics` cache, mesh-divergence fallback |
|
||||
| Delete | `delete()` / `delete_many` | `Delete`/`DeleteMany` |
|
||||
|
||||
### Minimal 2D (phase-1 set, used as profiles/inputs)
|
||||
| Capability | Method / op | Notes |
|
||||
|---|---|---|
|
||||
| Line | `create_line` → bounds | `CreateCurve(Line)` |
|
||||
| Circle | `create_circle` → bounds | `CreateCurve(Circle)` |
|
||||
| Polyline | `create_polyline` → bounds | `CreateCurve(Polyline)` |
|
||||
| Point | `create_point` / `create_points` (bulk) | `CreateCurve(Point)` / `CreateMany` |
|
||||
|
||||
### Cross-cutting
|
||||
| Capability | Method | Notes |
|
||||
|---|---|---|
|
||||
| Lookup / downcast | `entities().get(id)` → `Entity`; `as_solid()` | generic, any family |
|
||||
| Bulk create | `create_points(&[..])`, `CreateMany` | all-or-nothing, one undo step |
|
||||
| Bulk transform | `transform_many(&ids, placement)` | all-or-nothing, solids only (v1) |
|
||||
| Bulk delete | `delete_many(&ids)` | all-or-nothing; `OpGroup::compensate` uses it |
|
||||
| Read batch | `query_batch(\|q\| …)` | read-only, one round-trip, no bump |
|
||||
| Revision guard | `revision()` / `assert_revision(rev)` | read-modify-write guard |
|
||||
| Failure cleanup | `OpGroup` | best-effort, client-side (NOT a transaction) |
|
||||
| Binding handover | `binding_schema_json()` | merged object-model + wire-layout schema |
|
||||
| DWG round-trip | — | verified: intersected solids survive write→reload with valid ACIS (test) |
|
||||
|
||||
## 🚧 Outstanding — by phase
|
||||
|
||||
Phases are **spec-additive**: each is a `[[family]]`/`[[family.method]]` block in
|
||||
`spec/entities.toml` + a codegen bump + facade/backend wiring (see
|
||||
`ARCHITECTURE.md` § Workflow). No protocol change; new `Operation`/`Query`
|
||||
variants append at enum end.
|
||||
|
||||
### Phase 2 — full 2D + annotations
|
||||
- **Entities:** ellipse, spline, xline/ray, arc (typed), text, mtext, dimension,
|
||||
hatch.
|
||||
- **Methods:** boundary/vertex actions (`add_vertex` on polyline — currently
|
||||
`Unsupported`), hatch boundary query, text content get/set.
|
||||
- **Blocked by:** `AddVertex` backend impl (currently returns `Unsupported`);
|
||||
curve → `geom2d::Curve` conversion for non-trivial curves.
|
||||
|
||||
### Phase 3 — containers
|
||||
- **Entities:** insert/block references, attribute definitions/entities.
|
||||
- **Methods:** `create_insert`, placement, attribute get/set.
|
||||
- **Read-only:** nested block content traversal (nested ≠ first-level).
|
||||
|
||||
### Phase 4 — paper-space & viewports
|
||||
- **Entities:** viewport entities.
|
||||
- **Methods:** `create_viewport`, set-view (target, center, scale), layout
|
||||
queries.
|
||||
- **Blocked by:** a viewport query model (view target/center/scale DTOs).
|
||||
|
||||
### Phase 5 — media & misc (read-mostly)
|
||||
- **Entities:** raster image, underlay, table, light.
|
||||
- **Methods:** CRUD + property queries; few/no geometric actions. These families
|
||||
largely stay read-only `EntityView` DTOs — typed ops only where the spec
|
||||
declares an action mapping.
|
||||
|
||||
## 🔧 Outstanding methods on supported families
|
||||
|
||||
| Method | Family | Status | Blocker |
|
||||
|---|---|---|---|
|
||||
| `extrude(profile, dir)` | Solid | stub | `profile_curves` backend returns `Unsupported`; needs entity → `geom2d::Curve` conversion |
|
||||
| `revolve(profile, pivot, axis, angle)` | Solid | stub | same `profile_curves` blocker |
|
||||
| `loft(sections)` | Solid | not started | multi-profile kernel op + spec |
|
||||
| `add_vertex(at, pt)` | Polyline | stub | backend `add_vertex` returns `Unsupported` |
|
||||
| `transform` (non-solid) | 2D entities | `Unsupported` | per-family transform via acadrust (not kernel) |
|
||||
| `GetCentroid`/`GetVolume` accuracy | Solid | mesh approx | render-mesh LOD tolerance vs query tolerance |
|
||||
|
||||
## Design invariants to preserve when extending
|
||||
|
||||
- Per-op autocommit; **no multi-op write transactions**.
|
||||
- Per-op atomicity: validate+compute **before** `push_undo`; failed op = no
|
||||
mutation, no undo, no revision bump.
|
||||
- Bulk ops: single op, all-or-nothing via upfront validation, item-capped.
|
||||
- New `Operation`/`Query` variants **append at the end** (bincode stability).
|
||||
- Host geometry stays in the host; the wire carries `ObjectId` + plain-data DTOs.
|
||||
- `binding_schema.json` and `api_reference.md` regenerate from the spec and are
|
||||
CI drift-checked — update `spec/entities.toml` + `src/gen/layouts.json` for
|
||||
every wire change.
|
||||
@@ -0,0 +1,95 @@
|
||||
# ocs_doc_api
|
||||
|
||||
Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and
|
||||
hosts (v2). One typed vocabulary — **Entity**, **ObjectId**, **Operation**,
|
||||
**Query**, **Transport** — that runs identically in-process and over IPC.
|
||||
|
||||
```rust
|
||||
use ocs_doc_api::{DocApi, Solid, OpGroup, ApiResult};
|
||||
|
||||
fn intersect_solids(api: &DocApi) -> ApiResult<()> {
|
||||
let doc = api.document(api.active_tab());
|
||||
let mut grp = OpGroup::new();
|
||||
|
||||
// Each call is ONE atomic host op = one undo step. Per-op autocommit; no
|
||||
// multi-op write transactions. OpGroup is best-effort cleanup on failure.
|
||||
let block = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]))?;
|
||||
let other = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]))?;
|
||||
|
||||
let lens: Solid = match block.intersect(&other) { // host runs brep::combine
|
||||
Ok(l) => { grp.commit(); l }
|
||||
Err(e) => { grp.compensate(&doc)?; return Err(e); }
|
||||
};
|
||||
|
||||
// Read-only query batch (one round-trip, no revision bump).
|
||||
let (bb, vol) = {
|
||||
let r = doc.query_batch(|q| { q.bounds(&lens); q.volume(&lens); })?;
|
||||
(r.bounds(0)?, r.volume(1)?)
|
||||
};
|
||||
api.push_info(&format!("lens vol={vol:.1} bounds={bb:?}"));
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## What it is
|
||||
|
||||
- **Object-centric facade** — `DocApi` (root) → `Document` → collections
|
||||
(`solids()`, `curves()`, `entities()`) → typed handles (`Solid`, `Line`,
|
||||
`Circle`, `Polyline`, `Point`, `Entity`) that carry geometric methods
|
||||
(`intersect`/`union`/`subtract`/`transform`/`bounds`/`volume`/`centroid`/`delete`).
|
||||
- **Transport-agnostic** — the same typed requests run in-process
|
||||
([`InProcess`]) or over the `ocs_plugin_api` IPC channel ([`OcsPluginApiIpc`]);
|
||||
only the `Transport` differs. The host executes them through one crate-owned
|
||||
executor.
|
||||
- **Stable across async IPC** — handles are `Copy`, `Send`, `Sync` `{ObjectId,
|
||||
Arc<Session>}` tokens, safe to share across plugin worker threads. A stale
|
||||
handle's methods return `ApiError::UnknownId`, never a panic.
|
||||
|
||||
## Core guarantees
|
||||
|
||||
- **Per-op autocommit, no write transactions.** Every write op is ONE atomic
|
||||
host call = one undo step. Multi-op write transactions were removed after a
|
||||
security/integrity review (rollback-correctness of `erase_sources` + unbounded
|
||||
synchronous batch execution). There is no `doc.transaction`.
|
||||
- **Per-op atomicity.** An op validates its inputs and computes its geometry
|
||||
*before* any mutation; a failed op applies nothing and records no undo step.
|
||||
`brep::combine` is pure; `erase_sources` runs only on success.
|
||||
- **Bulk ops** (`CreateMany`/`TransformMany`/`DeleteMany`, and read-only query
|
||||
batching) are single ops, **all-or-nothing via upfront validation** (no
|
||||
rollback), item-capped — for bulk data (see `ARCHITECTURE.md` § Performance).
|
||||
- **Structured errors** — `ApiError::{Validation, Geometry, UnknownId,
|
||||
Unsupported, Transport}`, serializable so IPC returns the same error the
|
||||
in-process executor produced.
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
- **Solids** — primitives (cuboid, sphere, cylinder, cone, torus, wedge),
|
||||
booleans (union/intersect/subtract), transform, and queries
|
||||
(bounds/volume/centroid). Host resolves bodies via the `solid_models` cache
|
||||
(lift-on-miss from ACIS SAT).
|
||||
- **Minimal 2D** — line, circle, polyline, point (construction + bounds); used as
|
||||
boolean/sweep inputs.
|
||||
- Not yet: sweep/revolve profiles, `AddVertex`, non-solid transforms, annotations,
|
||||
containers (insert/block), viewports, media. See `IMPLEMENTATION.md`.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Pulls | Use |
|
||||
|---|---|---|
|
||||
| *(default)* | — | Pure data types (ops/query/envelope/errors) — binding generators, tooling |
|
||||
| `host` | `acadrust` + `cadkernel` | The executor + `DocApiBackend` trait + in-process transport (the host side) |
|
||||
| `ipc` | `acadrust` + `ocs_plugin_api` | The IPC transport adapter (the plugin side) |
|
||||
|
||||
## For REPL/scripting bridges (Python, Lua, …)
|
||||
|
||||
The object model is handed over as a single versioned schema
|
||||
(`binding_schema_json()`, generated by `build.rs`). A bridge in any language is a
|
||||
thin serializer + schema-driven generator + session glue over V4 `ExecuteCode` —
|
||||
no Rust FFI. See `bindings/README.md` and `bindings/python/generate.py`.
|
||||
|
||||
## Links
|
||||
|
||||
- `ARCHITECTURE.md` — design decisions, the per-op execution model, how to add a
|
||||
new entity/family/method, performance notes.
|
||||
- `IMPLEMENTATION.md` — what's supported now vs. the phased roadmap.
|
||||
- `spec/entities.toml` — the curated source of truth for codegen.
|
||||
@@ -0,0 +1,98 @@
|
||||
# ocs_doc_api — binding generator contract
|
||||
|
||||
How to build a REPL/scripting bridge to `ocs_doc_api` in **any** language
|
||||
(Python, Lua, JS, …) — no Rust FFI, one versioned schema. This is the binding
|
||||
handover defined in the plan (§10.2, decision #13).
|
||||
|
||||
## What you consume
|
||||
|
||||
**One artifact:** `gen/binding_schema.json` (also exposed as
|
||||
`ocs_doc_api::binding_schema_json()`). It is self-contained:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": "ocs_doc_api/binding_schema",
|
||||
"envelope_version": 1,
|
||||
"object_model": { "root": "DocApi", "document": "Document",
|
||||
"collections": ["solids","curves","entities"],
|
||||
"handles": ["Entity","Solid","Line","Circle","Polyline","Point"] },
|
||||
"families": [ /* per family: handle, collection, methods[] */ ],
|
||||
"layouts": { /* traced wire layouts for ObjectId / Operation / Query */ }
|
||||
}
|
||||
```
|
||||
|
||||
Each `families[].methods[]` entry:
|
||||
```jsonc
|
||||
{ "name": "intersect", "kind": "op", // "op" (write) | "query" (read)
|
||||
"op": "SolidBoolean", "query": null, // the Operation/Query it maps to
|
||||
"args": [{ "name": "other", "ty": "Solid" }],
|
||||
"returns": "Solid",
|
||||
"bulk": false, "read_only": false,
|
||||
"fixed": { "op": "Intersection", "erase_sources": true } }
|
||||
```
|
||||
|
||||
## What you implement (3 things)
|
||||
|
||||
1. **Code generator** — read `binding_schema.json`, emit language classes:
|
||||
`DocApi` → `document(tab)` → `solids()/curves()/entities()` → handles
|
||||
(`Solid`, `Line`, …). Each generated method builds the `Operation`/`Query` its
|
||||
schema node names (applying `fixed` fields) and ships it (step 2). Handles are
|
||||
`{ ObjectId, kind }` — an `ObjectId` is a plain `u64`.
|
||||
|
||||
2. **`DocApiEnvelope` (de)serializer** — the wire unit (bincode over the host's
|
||||
plugin channel). Layout:
|
||||
```
|
||||
DocApiEnvelope { version: u16, body: EnvelopeBody }
|
||||
EnvelopeBody = Op(Operation) | Queries(Vec<Query>)
|
||||
```
|
||||
A **write** request carries exactly ONE `Operation`; a **read** request carries
|
||||
one-or-more `Query` (read-only batching is safe). Serialize with bincode
|
||||
semantics matching the `layouts` map (enum variants are appended at the END
|
||||
only, so discriminants are stable). The host replies with a bincode
|
||||
`Result<Receipt, ApiError>`; `Receipt` carries `outcome`, `query_results`,
|
||||
`new_revision`.
|
||||
|
||||
3. **Session glue** — send the envelope over the existing `ocs_plugin_api`
|
||||
channel. The host already exposes the DocApi executor via
|
||||
`PluginRequest::DocApiRequest { tab_id, bytes }` →
|
||||
`PluginResponse::DocApiResponse { bytes }`. A REPL bridge typically rides the
|
||||
V4 `ExecuteCode` loop (the mechanism the `ocs_python_repl` plugin uses) or a
|
||||
direct `PluginRequestSender`.
|
||||
|
||||
## Errors → language exceptions
|
||||
|
||||
Map the structured `ApiError` to your language's exceptions:
|
||||
|
||||
| `ApiError` variant | meaning |
|
||||
|---|---|
|
||||
| `Validation { op, reason }` | input rejected before any mutation (bulk op names the failing index) |
|
||||
| `Geometry { kind, msg }` | the kernel failed (boolean refused, invalid input, ACIS) |
|
||||
| `UnknownId(ObjectId)` | the handle is stale/deleted/never existed |
|
||||
| `Unsupported(msg)` | capability not implemented (e.g. a later-phase family) |
|
||||
| `Transport(msg)` | the channel failed (disconnected/oversized/timeout) |
|
||||
|
||||
## Version / compatibility rule
|
||||
|
||||
- Check `binding_schema.json.envelope_version` against `DocApiEnvelope.version`
|
||||
(currently `1`) before speaking; a mismatch is a hard bridge error.
|
||||
- `Operation`/`Query` variants are **append-only**: a bridge built against an
|
||||
older schema keeps working; unknown new variants are simply not generated until
|
||||
you regenerate.
|
||||
|
||||
## Reference implementation
|
||||
|
||||
See `bindings/python/generate.py` — it reads `binding_schema.json` and emits
|
||||
`ocs_doc.py` with `DocApi`/`Document`/`Solid`/`Line`/… classes whose methods build
|
||||
and ship `DocApiEnvelope` ops. Lua/JS follow the same contract; only the
|
||||
serializer + codegen differ.
|
||||
|
||||
## Semantics to honour
|
||||
|
||||
- **Per-op autocommit:** every write method is one atomic host call = one undo
|
||||
step. There are no multi-op write transactions. A failed op applies nothing and
|
||||
records no undo step; earlier ops stay committed (no rollback).
|
||||
- **`erase_sources` booleans** replace the first input (`a`) in place and erase
|
||||
the second (`b`); the result is returned at `a`'s id.
|
||||
- **Bulk ops** (`CreateMany`/`TransformMany`/`DeleteMany`) are single ops,
|
||||
all-or-nothing via upfront validation (no rollback), item-cap `100_000`.
|
||||
- **Query batching** is read-only and safe; it never bumps the geometry revision.
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reference binding generator for ocs_doc_api (plan §10.2).
|
||||
|
||||
Reads `gen/binding_schema.json` and emits a Python module `ocs_doc.py` with the
|
||||
object model (DocApi -> Document -> collections -> handles) whose methods build
|
||||
`DocApiEnvelope` ops/queries and ship them through a caller-supplied transport.
|
||||
|
||||
This is the *contract* in executable form (bindings/README.md): one versioned
|
||||
schema, no Rust FFI, thin serializer. Lua/JS generators follow the same shape.
|
||||
|
||||
Usage:
|
||||
python bindings/python/generate.py gen/binding_schema.json > ocs_doc.py
|
||||
|
||||
The emitted module expects a `transport` object with:
|
||||
def apply(self, envelope: DocApiEnvelope) -> Receipt
|
||||
which the bridge author implements (the session glue over the plugin channel).
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from typing import List, Dict, Any
|
||||
|
||||
HEADER = '''"""Auto-generated ocs_doc_api Python bindings. Do NOT edit by hand.
|
||||
Generated by bindings/python/generate.py from binding_schema.json (envelope_version {ver}).
|
||||
Check envelope_version before use; see bindings/README.md."""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional
|
||||
|
||||
# --- wire DTOs (thin, schema-driven) -----------------------------------------
|
||||
ObjectId = int # u64
|
||||
|
||||
@dataclass
|
||||
class Receipt:
|
||||
outcome: Any
|
||||
query_results: List[Any]
|
||||
new_revision: int
|
||||
|
||||
@dataclass
|
||||
class DocApiEnvelope:
|
||||
version: int
|
||||
body: Any # ("Op", operation_dict) | ("Queries", [query_dict])
|
||||
|
||||
ENVELOPE_VERSION = {ver}
|
||||
|
||||
|
||||
def _check_version(schema_version: int) -> None:
|
||||
if schema_version != ENVELOPE_VERSION:
|
||||
raise RuntimeError(
|
||||
f"binding_schema envelope_version {{schema_version}} != {{ENVELOPE_VERSION}}"
|
||||
)
|
||||
'''
|
||||
|
||||
HANDLE_TEMPLATE = '''
|
||||
@dataclass
|
||||
class {handle}:
|
||||
id: ObjectId
|
||||
_doc: "Document"
|
||||
|
||||
{methods}
|
||||
'''
|
||||
|
||||
# Rust type -> Python annotation (schema types are Rust-flavoured).
|
||||
PY_TYPES = {
|
||||
"Solid": "'Solid'", "Line": "'Line'", "Circle": "'Circle'",
|
||||
"Polyline": "'Polyline'", "Point": "'Point'", "Entity": "'Entity'",
|
||||
"f64": "float", "bool": "bool", "usize": "int",
|
||||
"[f64; 3]": "tuple", "ObjectId": "ObjectId", "()": "None", "None": "None",
|
||||
}
|
||||
|
||||
def py_type(rust_ty: str) -> str:
|
||||
return PY_TYPES.get(rust_ty, "Any")
|
||||
|
||||
|
||||
def py_lit(v: Any) -> str:
|
||||
"""A JSON value as a Python literal (handles booleans/None)."""
|
||||
if v is True:
|
||||
return "True"
|
||||
if v is False:
|
||||
return "False"
|
||||
if v is None:
|
||||
return "None"
|
||||
return json.dumps(v)
|
||||
|
||||
|
||||
METHOD_OP = ''' def {name}(self{sig}) -> {ret}:
|
||||
op = {{"{variant}": {body}}}
|
||||
return self._doc._apply_op(op)
|
||||
'''
|
||||
|
||||
METHOD_QUERY = ''' def {name}(self{sig}) -> {ret}:
|
||||
q = {{"{variant}": {body}}}
|
||||
return self._doc._apply_query(q)
|
||||
'''
|
||||
|
||||
|
||||
def render_method(m: Dict[str, Any]) -> str:
|
||||
"""Emit one facade method. `self.id` is the receiver's ObjectId (`a`/`id`);
|
||||
each handle-typed arg contributes its `.id`. Fixed fields are injected from
|
||||
the schema node."""
|
||||
name = m["name"]
|
||||
args = m.get("args", [])
|
||||
sig = "".join(f", {a['name']}" for a in args)
|
||||
ret = py_type(m.get("returns") or "None")
|
||||
|
||||
# Receiver is `a` for SolidBoolean, `id` otherwise. Handle args map to .id.
|
||||
field_for_arg = {}
|
||||
for a in args:
|
||||
# a handle arg becomes `<name>_id` in the payload when the variant needs ids
|
||||
field_for_arg[a["name"]] = a["name"]
|
||||
|
||||
body_items: List[str] = []
|
||||
variant = m["op"] if m["kind"] == "op" else m["query"]
|
||||
if variant == "SolidBoolean":
|
||||
body_items.append("'op': '%s'" % (m.get("fixed", {}).get("op", "")))
|
||||
body_items.append("'a': self.id")
|
||||
if args:
|
||||
body_items.append(f"'b': {args[0]['name']}.id")
|
||||
body_items.append(f"'erase_sources': {py_lit((m.get('fixed') or {}).get('erase_sources', True))}")
|
||||
elif variant in ("GetVolume", "GetCentroid", "GetBounds", "GetEntity"):
|
||||
body_items.append("'id': self.id")
|
||||
elif variant == "GetIntersects":
|
||||
body_items.append("'a': self.id")
|
||||
if args:
|
||||
body_items.append(f"'b': {args[0]['name']}.id")
|
||||
elif variant == "AddVertex":
|
||||
body_items.append("'id': self.id")
|
||||
for a in args:
|
||||
body_items.append(f"'{a['name']}': {a['name']}")
|
||||
else:
|
||||
for a in args:
|
||||
body_items.append(f"'{a['name']}': {a['name']}")
|
||||
body = "{" + ", ".join(body_items) + "}"
|
||||
|
||||
if m["kind"] == "op":
|
||||
return METHOD_OP.format(name=name, sig=sig, ret=ret, variant=variant, body=body)
|
||||
return METHOD_QUERY.format(name=name, sig=sig, ret=ret, variant=variant, body=body)
|
||||
|
||||
|
||||
def render(schema: Dict[str, Any]) -> str:
|
||||
out = [HEADER.format(ver=schema["envelope_version"])]
|
||||
handles = set(schema["object_model"]["handles"])
|
||||
for fam in schema["families"]:
|
||||
handle = fam.get("handle") or fam["name"]
|
||||
methods = "\n".join(render_method(m) for m in fam.get("methods", []))
|
||||
if not methods.strip():
|
||||
methods = " pass\n"
|
||||
out.append(HANDLE_TEMPLATE.format(handle=handle, methods=methods))
|
||||
out.append(
|
||||
'''
|
||||
@dataclass
|
||||
class Document:
|
||||
_transport: Any
|
||||
_tab: int
|
||||
|
||||
def _apply_op(self, op, want):
|
||||
env = DocApiEnvelope(ENVELOPE_VERSION, ("Op", op))
|
||||
return self._transport.apply(env)
|
||||
|
||||
def _apply_query(self, q, want):
|
||||
env = DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [q]))
|
||||
return self._transport.apply(env)
|
||||
|
||||
def solids(self): return Solids(self)
|
||||
def curves(self): return Curves(self)
|
||||
def entities(self): return Entities(self)
|
||||
|
||||
class Solids:
|
||||
def __init__(self, doc): self._doc = doc
|
||||
class Curves:
|
||||
def __init__(self, doc): self._doc = doc
|
||||
class Entities:
|
||||
def __init__(self, doc): self._doc = doc
|
||||
|
||||
@dataclass
|
||||
class DocApi:
|
||||
_transport: Any
|
||||
|
||||
def document(self, tab: int) -> Document:
|
||||
return Document(self._transport, tab)
|
||||
'''
|
||||
)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "gen/binding_schema.json"
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
schema = json.load(f)
|
||||
sys.stdout.write(render(schema))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Codegen driver (plan §10): reads `spec/entities.toml` and regenerates the
|
||||
//! committed snapshots in `src/gen/` (ops/query enums, api_reference.md,
|
||||
//! binding_schema.json). CI runs `cargo build` and fails on drift (the committed
|
||||
//! files are the reviewable truth; build.rs rewrites them deterministically).
|
||||
//!
|
||||
//! The wire-layout tracing for `binding_schema.json` uses serde-reflection on the
|
||||
//! DTO payload types (same mechanism as `ocs_plugin_api`'s `type_registry.json`),
|
||||
//! so the traced layouts are mechanically correct, never hand-written.
|
||||
|
||||
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());
|
||||
|
||||
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();
|
||||
|
||||
// 1. Regenerate the ops/query enum snapshots (deterministic; these are the
|
||||
// curated v1 variants — the spec drives docs/schema, the enums are the
|
||||
// stable wire vocabulary appended at end only).
|
||||
write_if_changed(&gen_dir.join("ops_gen.rs"), &ops_gen_src());
|
||||
write_if_changed(&gen_dir.join("query_gen.rs"), &query_gen_src());
|
||||
|
||||
// 2. Emit the auto-generated API reference (§10.1) from the spec.
|
||||
write_if_changed(&gen_dir.join("api_reference.md"), &api_reference_md(&spec));
|
||||
|
||||
// 3. Emit the merged binding handover schema (§10.2) with traced wire layouts.
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
fn ops_gen_src() -> String {
|
||||
// Keep in sync with the committed snapshot; the canonical list lives there.
|
||||
include_str!("src/gen/ops_gen.rs").to_string()
|
||||
}
|
||||
|
||||
fn query_gen_src() -> String {
|
||||
include_str!("src/gen/query_gen.rs").to_string()
|
||||
}
|
||||
|
||||
fn fmt_args(args: &[Arg]) -> String {
|
||||
args.iter().map(|a| format!("{}: {}", a.name, a.ty)).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// 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`, `Entity`) is a
|
||||
`Copy`, `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).
|
||||
|
||||
`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed
|
||||
downcast when `kind == "Solid3D"`).
|
||||
|
||||
## 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 { "" };
|
||||
s.push_str(&format!(
|
||||
"- **{}**(`{}`) -> `{ret}`{bulk}{}\n",
|
||||
c.name,
|
||||
fmt_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("-");
|
||||
s.push_str(&format!(
|
||||
"- **{}**(`{}`) -> `{}` — {} `{}`{}\n",
|
||||
m.name,
|
||||
fmt_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
|
||||
}
|
||||
|
||||
/// The merged, self-contained binding handover schema (§10.2). Object model +
|
||||
/// method signatures + op/query mapping + traced wire layouts inlined per node.
|
||||
fn binding_schema_json(spec: &Spec) -> String {
|
||||
// Trace the wire layouts of the DTO payload types via serde-reflection so
|
||||
// the inlined layouts are mechanically correct (mirrors the wire types).
|
||||
let registry = trace_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,
|
||||
})
|
||||
})
|
||||
.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"],
|
||||
"handles": ["Entity", "Solid", "Line", "Circle", "Polyline", "Point"],
|
||||
},
|
||||
"families": families,
|
||||
// Traced wire layouts for every Operation/Query/payload DTO (inlined).
|
||||
"layouts": registry,
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&schema).expect("serialize binding schema")
|
||||
}
|
||||
|
||||
/// Read the curated wire-layout snapshot (`src/gen/layouts.json`) for the DTO
|
||||
/// payload types. Returns a JSON map of type-name → container format. Kept
|
||||
/// internal (the raw registry is not a public handover surface, plan §10.2).
|
||||
///
|
||||
/// v1 emits the committed snapshot directly. Full serde-reflection tracing of
|
||||
/// the crate's own DTO types is a follow-up once the crate can be a build-
|
||||
/// dependency of itself (self-hosting); the snapshot keeps the schema correct
|
||||
/// and CI drift-checked in the meantime.
|
||||
fn trace_layouts() -> serde_json::Value {
|
||||
match fs::read_to_string(layouts_snapshot_path()) {
|
||||
Ok(src) => serde_json::from_str(&src).unwrap_or_else(|_| serde_json::json!({})),
|
||||
Err(_) => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn layouts_snapshot_path() -> PathBuf {
|
||||
PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/gen/layouts.json")
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! 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::{
|
||||
Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, ObjectId,
|
||||
PlacementSpec,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
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>,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
|
||||
let kind = match spec {
|
||||
Curve2Spec::Line { .. } => "Line",
|
||||
Curve2Spec::Circle { .. } => "Circle",
|
||||
Curve2Spec::Polyline { .. } => "LwPolyline",
|
||||
Curve2Spec::Point { .. } => "Point",
|
||||
};
|
||||
let id = self.alloc(kind);
|
||||
self.curves.insert(id, spec.clone());
|
||||
Ok(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);
|
||||
Ok(self.kinds.remove(&id).is_some())
|
||||
}
|
||||
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
|
||||
let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone();
|
||||
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, 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 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 via the divergence theorem.
|
||||
fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
|
||||
let mut vol = 0.0;
|
||||
let mut c = [0.0; 3];
|
||||
for t in &mesh.triangles {
|
||||
let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]);
|
||||
let cross = [
|
||||
v1[1] * v2[2] - v1[2] * v2[1],
|
||||
v1[2] * v2[0] - v1[0] * v2[2],
|
||||
v1[0] * v2[1] - v1[1] * v2[0],
|
||||
];
|
||||
let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0;
|
||||
vol += tet;
|
||||
for i in 0..3 {
|
||||
c[i] += (v0[i] + v1[i] + v2[i]) * tet;
|
||||
}
|
||||
}
|
||||
if vol.abs() < 1e-12 {
|
||||
return (0.0, [0.0; 3]);
|
||||
}
|
||||
(vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)])
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! 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, OpGroup, ObjectId};
|
||||
|
||||
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!(matches!(
|
||||
doc.entities().get(ObjectId::from_u64(0xDEAD)),
|
||||
Err(_)
|
||||
));
|
||||
println!("failure semantics verified");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
# 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"
|
||||
status = "unsupported"
|
||||
|
||||
[[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"
|
||||
status = "unsupported"
|
||||
|
||||
[[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 = "()"
|
||||
status = "unsupported"
|
||||
|
||||
[[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>"
|
||||
|
||||
# ============================================================================
|
||||
# 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).
|
||||
|
||||
# ---- 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"
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The thin host hook (plan §6, decision #11). 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;
|
||||
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.
|
||||
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody>;
|
||||
|
||||
/// 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>;
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>>;
|
||||
|
||||
/// 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, plan §5.3). 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);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The wire envelope (plan §7). 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 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,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Structured error model (plan §2 decision #9). 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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! The op executor (plan §6, decision #11): 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 + one revision bump.
|
||||
/// 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 name = op.op_name();
|
||||
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(&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(&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 (plan review: erase path).
|
||||
if *erase_sources {
|
||||
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 (plan review: erase path).
|
||||
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()));
|
||||
}
|
||||
// Upfront validation: compute/validate EVERY payload before any insert
|
||||
// (all-or-nothing without rollback, plan §5.3).
|
||||
let mut prepared = Vec::with_capacity(specs.len());
|
||||
for (i, spec) in specs.iter().enumerate() {
|
||||
let prep = match spec {
|
||||
EntitySpec::Solid(p) => {
|
||||
Prepared::Solid(crate::geom::make_solid(p).map_err(|e| at_index(name, i, e))?)
|
||||
}
|
||||
EntitySpec::Curve(c) => Prepared::Curve(c.clone()),
|
||||
};
|
||||
prepared.push(prep);
|
||||
}
|
||||
b.push_undo(name);
|
||||
let mut ids = Vec::with_capacity(prepared.len());
|
||||
for prep in prepared {
|
||||
let id = match prep {
|
||||
Prepared::Solid(body) => b.store_solid(&body)?,
|
||||
Prepared::Curve(c) => b.add_curve(&c)?,
|
||||
};
|
||||
ids.push(id);
|
||||
}
|
||||
b.finalize_op();
|
||||
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,
|
||||
// plan §5.3): 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.push_undo(name);
|
||||
for id in ids {
|
||||
b.transform_entity(*id, placement)?;
|
||||
}
|
||||
b.finalize_op();
|
||||
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())
|
||||
}
|
||||
};
|
||||
Ok(Receipt {
|
||||
outcome: Some(outcome),
|
||||
query_results: Vec::new(),
|
||||
new_revision: b.revision(),
|
||||
})
|
||||
}
|
||||
|
||||
enum Prepared {
|
||||
Solid(crate::backend::KernelBody),
|
||||
Curve(crate::ops::Curve2Spec),
|
||||
}
|
||||
|
||||
/// 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 r = match *q {
|
||||
Query::GetEntity { id } => QueryResult::Entity(b.get_entity(id)?),
|
||||
Query::GetBounds { id } => QueryResult::Bounds(b.bounds(id)?),
|
||||
Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(id)?),
|
||||
Query::GetVolume { id } => QueryResult::Volume(b.volume(id)?),
|
||||
Query::GetIntersects { a, b: bid } => {
|
||||
let ba = b.bounds(a)?;
|
||||
let bb = b.bounds(bid)?;
|
||||
QueryResult::Intersects(ba.overlaps(&bb))
|
||||
}
|
||||
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
|
||||
};
|
||||
results.push(r);
|
||||
}
|
||||
Ok(Receipt {
|
||||
outcome: None,
|
||||
query_results: results,
|
||||
new_revision: b.revision(),
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
//! The object-centric facade (plan §8.1, decision #12).
|
||||
//!
|
||||
//! Hierarchy: [`DocApi`] (root: session + transport) → [`Document`] →
|
||||
//! collections ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`])
|
||||
//! → typed handles ([`Solid`], [`Line`], [`Circle`], [`Polyline`], [`Point`],
|
||||
//! [`Entity`]) carrying methods. Every handle = `{ ObjectId, Arc<Session> }` —
|
||||
//! `Clone`, `Send`, `Sync`; it never holds kernel geometry. Per-op autocommit:
|
||||
//! each method is ONE atomic host call = one undo step (no transactions).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::envelope::{DocApiEnvelope, Receipt};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::gen::{Operation, Query};
|
||||
use crate::id::ObjectId;
|
||||
use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive};
|
||||
use crate::query::{Aabb, EntityView, QueryResult};
|
||||
use crate::revision::GeometryRevision;
|
||||
use crate::transport::Transport;
|
||||
|
||||
/// Shared session: the transport. Held by every handle. The transport already
|
||||
/// carries the tab binding (IPC) or the backend (in-process), so the session
|
||||
/// stores no per-tab field.
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
transport: Arc<dyn Transport>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn apply_op(&self, op: Operation) -> ApiResult<Receipt> {
|
||||
self.transport.apply(DocApiEnvelope::op(op))
|
||||
}
|
||||
fn apply_queries(&self, queries: Vec<Query>) -> ApiResult<Receipt> {
|
||||
self.transport.apply(DocApiEnvelope::queries(queries))
|
||||
}
|
||||
fn one_query(&self, q: Query) -> ApiResult<QueryResult> {
|
||||
let mut r = self.apply_queries(vec![q])?;
|
||||
let result = r.query_results.drain(..).next();
|
||||
result.ok_or_else(|| ApiError::Transport("empty query result".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The API root: owns the session (transport + active tab). Entry point.
|
||||
#[derive(Clone)]
|
||||
pub struct DocApi {
|
||||
transport: Arc<dyn Transport>,
|
||||
active_tab: u64,
|
||||
}
|
||||
|
||||
impl DocApi {
|
||||
/// Connect over an existing transport (IPC or in-process) bound to `active_tab`.
|
||||
pub fn connect(transport: Arc<dyn Transport>, active_tab: u64) -> Self {
|
||||
Self { transport, active_tab }
|
||||
}
|
||||
/// Connect in-process over a backend (host feature).
|
||||
#[cfg(feature = "host")]
|
||||
pub fn in_process<B: crate::backend::DocApiBackend + Send + 'static>(
|
||||
backend: B,
|
||||
active_tab: u64,
|
||||
) -> Self {
|
||||
Self::connect(Arc::new(crate::transport::InProcess::new(backend)), active_tab)
|
||||
}
|
||||
|
||||
/// The active tab id this root is bound to.
|
||||
pub fn active_tab(&self) -> u64 {
|
||||
self.active_tab
|
||||
}
|
||||
|
||||
/// Liveness probe of the host/transport.
|
||||
pub fn alive(&self) -> bool {
|
||||
self.transport.alive()
|
||||
}
|
||||
|
||||
/// Bind a `Document` to `tab`. In v1 the transport is already bound to the
|
||||
/// active tab (IPC carries `tab_id`; in-process carries the backend), so the
|
||||
/// argument is accepted for API forward-compat but not stored.
|
||||
pub fn document(&self, _tab: u64) -> Document {
|
||||
Document {
|
||||
session: Session { transport: Arc::clone(&self.transport) },
|
||||
}
|
||||
}
|
||||
|
||||
// Host console passthroughs (UX feedback; map to PushInfo/PushError on the host).
|
||||
pub fn push_info(&self, msg: &str) {
|
||||
let _ = msg; // host console passthrough is a host-side concern; facade is silent.
|
||||
}
|
||||
pub fn push_error(&self, msg: &str) {
|
||||
let _ = msg;
|
||||
}
|
||||
}
|
||||
|
||||
/// One open document tab: factory + lookup + queries + read-guards.
|
||||
#[derive(Clone)]
|
||||
pub struct Document {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn solids(&self) -> SolidCollection {
|
||||
SolidCollection { session: self.session.clone() }
|
||||
}
|
||||
pub fn curves(&self) -> CurveCollection {
|
||||
CurveCollection { session: self.session.clone() }
|
||||
}
|
||||
pub fn entities(&self) -> EntityCollection {
|
||||
EntityCollection { session: self.session.clone() }
|
||||
}
|
||||
|
||||
/// Current geometry revision (query, no bump).
|
||||
pub fn revision(&self) -> ApiResult<GeometryRevision> {
|
||||
match self.session.one_query(Query::GetGeometryRevision)? {
|
||||
QueryResult::Revision(r) => Ok(r),
|
||||
_ => Err(ApiError::Transport("unexpected revision result".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Standalone read-guard: fail with `ApiError::Validation` if the document's
|
||||
/// revision moved past `expected` (read-modify-write guard; NOT a batch pre-condition).
|
||||
pub fn assert_revision(&self, expected: GeometryRevision) -> ApiResult<()> {
|
||||
let now = self.revision()?;
|
||||
if now == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Validation {
|
||||
op: "assert_revision".to_string(),
|
||||
reason: format!("revision moved: expected {expected:?}, now {now:?}; re-read and retry"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch of read-only queries in ONE round-trip (safe: no mutation/undo).
|
||||
/// The closure records queries on a [`QueryBatch`]; the results are returned
|
||||
/// in the same order as a [`QueryResults`] view the caller destructures.
|
||||
pub fn query_batch<F: FnOnce(&mut QueryBatch)>(&self, f: F) -> ApiResult<QueryResults> {
|
||||
let mut qb = QueryBatch { queries: Vec::new() };
|
||||
f(&mut qb);
|
||||
let receipt = self.session.apply_queries(qb.queries)?;
|
||||
Ok(QueryResults { results: receipt.query_results })
|
||||
}
|
||||
}
|
||||
|
||||
/// Records queries for [`Document::query_batch`]. Each method appends one query.
|
||||
pub struct QueryBatch {
|
||||
queries: Vec<Query>,
|
||||
}
|
||||
|
||||
impl QueryBatch {
|
||||
pub fn bounds(&mut self, e: &impl HasId) {
|
||||
self.queries.push(Query::GetBounds { id: e.id() });
|
||||
}
|
||||
pub fn volume(&mut self, e: &impl HasId) {
|
||||
self.queries.push(Query::GetVolume { id: e.id() });
|
||||
}
|
||||
pub fn centroid(&mut self, e: &impl HasId) {
|
||||
self.queries.push(Query::GetCentroid { id: e.id() });
|
||||
}
|
||||
pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) {
|
||||
self.queries.push(Query::GetIntersects { a: a.id(), b: b.id() });
|
||||
}
|
||||
pub fn revision(&mut self) {
|
||||
self.queries.push(Query::GetGeometryRevision);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordered results of a [`Document::query_batch`], one per recorded query.
|
||||
pub struct QueryResults {
|
||||
results: Vec<QueryResult>,
|
||||
}
|
||||
|
||||
impl QueryResults {
|
||||
fn at(&self, i: usize) -> ApiResult<&QueryResult> {
|
||||
self.results.get(i).ok_or_else(|| ApiError::Transport("query result missing".into()))
|
||||
}
|
||||
pub fn bounds(&self, i: usize) -> ApiResult<Aabb> {
|
||||
match self.at(i)? {
|
||||
QueryResult::Bounds(b) => Ok(*b),
|
||||
_ => Err(ApiError::Transport("result is not bounds".into())),
|
||||
}
|
||||
}
|
||||
pub fn volume(&self, i: usize) -> ApiResult<f64> {
|
||||
match self.at(i)? {
|
||||
QueryResult::Volume(v) => Ok(*v),
|
||||
_ => Err(ApiError::Transport("result is not volume".into())),
|
||||
}
|
||||
}
|
||||
pub fn centroid(&self, i: usize) -> ApiResult<[f64; 3]> {
|
||||
match self.at(i)? {
|
||||
QueryResult::Centroid(c) => Ok(*c),
|
||||
_ => Err(ApiError::Transport("result is not centroid".into())),
|
||||
}
|
||||
}
|
||||
pub fn intersects(&self, i: usize) -> ApiResult<bool> {
|
||||
match self.at(i)? {
|
||||
QueryResult::Intersects(x) => Ok(*x),
|
||||
_ => Err(ApiError::Transport("result is not intersects".into())),
|
||||
}
|
||||
}
|
||||
pub fn revision(&self, i: usize) -> ApiResult<GeometryRevision> {
|
||||
match self.at(i)? {
|
||||
QueryResult::Revision(r) => Ok(*r),
|
||||
_ => Err(ApiError::Transport("result is not revision".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Something with an `ObjectId` (all handles + raw ids).
|
||||
pub trait HasId {
|
||||
fn id(&self) -> ObjectId;
|
||||
}
|
||||
impl HasId for ObjectId {
|
||||
fn id(&self) -> ObjectId {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typed handles ────────────────────────────────────────────────────────────
|
||||
|
||||
macro_rules! handle {
|
||||
($name:ident) => {
|
||||
#[derive(Clone)]
|
||||
pub struct $name {
|
||||
session: Session,
|
||||
id: ObjectId,
|
||||
}
|
||||
impl std::fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, concat!(stringify!($name), "({:?})"), self.id)
|
||||
}
|
||||
}
|
||||
impl $name {
|
||||
fn new(session: Session, id: ObjectId) -> Self {
|
||||
Self { session, id }
|
||||
}
|
||||
}
|
||||
impl HasId for $name {
|
||||
fn id(&self) -> ObjectId {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
impl $name {
|
||||
pub fn bounds(&self) -> ApiResult<Aabb> {
|
||||
match self.session.one_query(Query::GetBounds { id: self.id })? {
|
||||
QueryResult::Bounds(b) => Ok(b),
|
||||
_ => Err(ApiError::Transport("unexpected bounds result".into())),
|
||||
}
|
||||
}
|
||||
pub fn delete(&self) -> ApiResult<()> {
|
||||
self.session.apply_op(Operation::Delete { id: self.id })?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> {
|
||||
self.session.apply_op(Operation::Transform { id: self.id, placement })?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
handle!(Entity);
|
||||
handle!(Solid);
|
||||
handle!(Line);
|
||||
handle!(Circle);
|
||||
handle!(Polyline);
|
||||
handle!(Point);
|
||||
|
||||
impl Entity {
|
||||
pub fn view(&self) -> ApiResult<EntityView> {
|
||||
match self.session.one_query(Query::GetEntity { id: self.id })? {
|
||||
QueryResult::Entity(v) => Ok(v),
|
||||
_ => Err(ApiError::Transport("unexpected entity result".into())),
|
||||
}
|
||||
}
|
||||
pub fn as_solid(&self) -> Option<Solid> {
|
||||
// Typed downcast is validated by the view's kind.
|
||||
matches!(self.view().ok()?.kind.as_str(), "Solid3D").then(|| Solid::new(self.session.clone(), self.id))
|
||||
}
|
||||
}
|
||||
|
||||
impl Solid {
|
||||
fn boolean(&self, op: BoolOp, other: &Solid) -> ApiResult<Solid> {
|
||||
let receipt = self.session.apply_op(Operation::SolidBoolean {
|
||||
op,
|
||||
a: self.id,
|
||||
b: other.id,
|
||||
erase_sources: true,
|
||||
})?;
|
||||
let id = receipt
|
||||
.outcome
|
||||
.and_then(|o| o.new_id())
|
||||
.ok_or_else(|| ApiError::Transport("boolean returned no id".into()))?;
|
||||
Ok(Solid::new(self.session.clone(), id))
|
||||
}
|
||||
pub fn intersect(&self, other: &Solid) -> ApiResult<Solid> {
|
||||
self.boolean(BoolOp::Intersection, other)
|
||||
}
|
||||
pub fn union(&self, other: &Solid) -> ApiResult<Solid> {
|
||||
self.boolean(BoolOp::Union, other)
|
||||
}
|
||||
pub fn subtract(&self, other: &Solid) -> ApiResult<Solid> {
|
||||
self.boolean(BoolOp::Difference, other)
|
||||
}
|
||||
pub fn volume(&self) -> ApiResult<f64> {
|
||||
match self.session.one_query(Query::GetVolume { id: self.id })? {
|
||||
QueryResult::Volume(v) => Ok(v),
|
||||
_ => Err(ApiError::Transport("unexpected volume result".into())),
|
||||
}
|
||||
}
|
||||
pub fn centroid(&self) -> ApiResult<[f64; 3]> {
|
||||
match self.session.one_query(Query::GetCentroid { id: self.id })? {
|
||||
QueryResult::Centroid(c) => Ok(c),
|
||||
_ => Err(ApiError::Transport("unexpected centroid result".into())),
|
||||
}
|
||||
}
|
||||
pub fn intersects(&self, other: &Solid) -> ApiResult<bool> {
|
||||
match self.session.one_query(Query::GetIntersects { a: self.id, b: other.id })? {
|
||||
QueryResult::Intersects(x) => Ok(x),
|
||||
_ => Err(ApiError::Transport("unexpected intersects result".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Polyline {
|
||||
pub fn add_vertex(&self, at: usize, point: [f64; 3]) -> ApiResult<()> {
|
||||
self.session.apply_op(Operation::AddVertex { id: self.id, at, point })?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collections (construction / lookup) ─────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SolidCollection {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl SolidCollection {
|
||||
fn create(&self, prim: SolidPrimitive) -> ApiResult<Solid> {
|
||||
let receipt = self.session.apply_op(Operation::CreateSolid(prim))?;
|
||||
let id = receipt
|
||||
.outcome
|
||||
.and_then(|o| o.new_id())
|
||||
.ok_or_else(|| ApiError::Transport("create_solid returned no id".into()))?;
|
||||
Ok(Solid::new(self.session.clone(), id))
|
||||
}
|
||||
pub fn create_cuboid(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult<Solid> {
|
||||
self.create(SolidPrimitive::Cuboid { origin, size })
|
||||
}
|
||||
pub fn create_sphere(&self, centre: [f64; 3], radius: f64) -> ApiResult<Solid> {
|
||||
self.create(SolidPrimitive::Sphere { centre, radius })
|
||||
}
|
||||
pub fn create_cylinder(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult<Solid> {
|
||||
self.create(SolidPrimitive::Cylinder { base, radius, height })
|
||||
}
|
||||
pub fn create_cone(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult<Solid> {
|
||||
self.create(SolidPrimitive::Cone { base, radius, height })
|
||||
}
|
||||
pub fn create_torus(&self, centre: [f64; 3], major_radius: f64, minor_radius: f64) -> ApiResult<Solid> {
|
||||
self.create(SolidPrimitive::Torus { centre, major_radius, minor_radius })
|
||||
}
|
||||
pub fn create_wedge(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult<Solid> {
|
||||
self.create(SolidPrimitive::Wedge { origin, size })
|
||||
}
|
||||
pub fn extrude(&self, profile: &impl HasId, direction: [f64; 3]) -> ApiResult<Solid> {
|
||||
let receipt = self.session.apply_op(Operation::Extrude { profile: profile.id(), direction })?;
|
||||
let id = receipt
|
||||
.outcome
|
||||
.and_then(|o| o.new_id())
|
||||
.ok_or_else(|| ApiError::Transport("extrude returned no id".into()))?;
|
||||
Ok(Solid::new(self.session.clone(), id))
|
||||
}
|
||||
pub fn revolve(&self, profile: &impl HasId, pivot: [f64; 3], axis: [f64; 3], angle: f64) -> ApiResult<Solid> {
|
||||
let receipt = self.session.apply_op(Operation::Revolve {
|
||||
profile: profile.id(),
|
||||
axis: (pivot, axis),
|
||||
angle,
|
||||
})?;
|
||||
let id = receipt
|
||||
.outcome
|
||||
.and_then(|o| o.new_id())
|
||||
.ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?;
|
||||
Ok(Solid::new(self.session.clone(), id))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CurveCollection {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl CurveCollection {
|
||||
fn create_curve(&self, spec: Curve2Spec) -> ApiResult<(Session, ObjectId)> {
|
||||
let receipt = self.session.apply_op(Operation::CreateCurve(spec))?;
|
||||
let id = receipt
|
||||
.outcome
|
||||
.and_then(|o| o.new_id())
|
||||
.ok_or_else(|| ApiError::Transport("create_curve returned no id".into()))?;
|
||||
Ok((self.session.clone(), id))
|
||||
}
|
||||
pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult<Line> {
|
||||
let (s, id) = self.create_curve(Curve2Spec::Line { start, end })?;
|
||||
Ok(Line::new(s, id))
|
||||
}
|
||||
pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult<Circle> {
|
||||
let (s, id) = self.create_curve(Curve2Spec::Circle { centre, radius })?;
|
||||
Ok(Circle::new(s, id))
|
||||
}
|
||||
pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult<Polyline> {
|
||||
let (s, id) = self.create_curve(Curve2Spec::Polyline { points: points.to_vec(), closed })?;
|
||||
Ok(Polyline::new(s, id))
|
||||
}
|
||||
pub fn create_point(&self, position: [f64; 3]) -> ApiResult<Point> {
|
||||
let (s, id) = self.create_curve(Curve2Spec::Point { position })?;
|
||||
Ok(Point::new(s, id))
|
||||
}
|
||||
/// Bulk-create many points in ONE op (plan §5.3): all-or-nothing, one undo step.
|
||||
pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult<Vec<Point>> {
|
||||
let specs: Vec<crate::ops::EntitySpec> = positions
|
||||
.iter()
|
||||
.map(|&p| crate::ops::EntitySpec::Curve(Curve2Spec::Point { position: p }))
|
||||
.collect();
|
||||
let receipt = self.session.apply_op(Operation::CreateMany(specs))?;
|
||||
let outcome = receipt.outcome.ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?;
|
||||
Ok(outcome
|
||||
.new_ids()
|
||||
.iter()
|
||||
.map(|&id| Point::new(self.session.clone(), id))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EntityCollection {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl EntityCollection {
|
||||
/// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …).
|
||||
pub fn get(&self, id: ObjectId) -> ApiResult<Entity> {
|
||||
// Validate existence eagerly so `get` errors surface as `UnknownId`.
|
||||
self.session.one_query(Query::GetEntity { id })?;
|
||||
Ok(Entity::new(self.session.clone(), id))
|
||||
}
|
||||
pub fn delete(&self, id: ObjectId) -> ApiResult<()> {
|
||||
self.session.apply_op(Operation::Delete { id })?;
|
||||
Ok(())
|
||||
}
|
||||
/// Bulk transform (one op, all-or-nothing).
|
||||
pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> {
|
||||
self.session.apply_op(Operation::TransformMany { ids: ids.to_vec(), placement })?;
|
||||
Ok(())
|
||||
}
|
||||
/// Bulk delete (one op, all-or-nothing). Used by `OpGroup::compensate`.
|
||||
pub fn delete_many(&self, ids: &[ObjectId]) -> ApiResult<()> {
|
||||
self.session.apply_op(Operation::DeleteMany(ids.to_vec()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpGroup: client-side failure-cleanup (NOT a transaction) ────────────────
|
||||
|
||||
/// Tracks handles created during a logical operation so a later failure can be
|
||||
/// cleaned up (best-effort, sequential delete ops — plan §8.1). No atomicity,
|
||||
/// no host involvement, no rollback.
|
||||
#[derive(Default)]
|
||||
pub struct OpGroup {
|
||||
created: Vec<ObjectId>,
|
||||
}
|
||||
|
||||
impl OpGroup {
|
||||
pub fn new() -> Self {
|
||||
Self { created: Vec::new() }
|
||||
}
|
||||
/// Record a created handle (propagating any construction error); returns it
|
||||
/// for fluent `grp.track(doc.solids().create_cuboid(..)?)?` use.
|
||||
pub fn track<T: HasId>(&mut self, handle: ApiResult<T>) -> ApiResult<T> {
|
||||
let handle = handle?;
|
||||
self.created.push(handle.id());
|
||||
Ok(handle)
|
||||
}
|
||||
/// Keep everything: drop the tracking without deleting.
|
||||
pub fn commit(self) {}
|
||||
/// Best-effort cleanup: delete every tracked entity (one `DeleteMany` op).
|
||||
/// Does not resurrect already-consumed/deleted entities (plan §13 note).
|
||||
pub fn compensate(self, doc: &Document) -> ApiResult<()> {
|
||||
if self.created.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
doc.entities().delete_many(&self.created)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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`
|
||||
- **create_sphere**(`centre: [f64; 3], radius: f64`) -> `Solid`
|
||||
- **create_cylinder**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid`
|
||||
- **create_cone**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid`
|
||||
- **create_torus**(`centre: [f64; 3], major_radius: f64, minor_radius: f64`) -> `Solid`
|
||||
- **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid`
|
||||
- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`)
|
||||
- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`)
|
||||
|
||||
### Methods
|
||||
|
||||
- **intersect**(`other: Solid`) -> `Solid` — op `SolidBoolean`
|
||||
- **union**(`other: Solid`) -> `Solid` — op `SolidBoolean`
|
||||
- **subtract**(`other: Solid`) -> `Solid` — op `SolidBoolean`
|
||||
- **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`
|
||||
|
||||
## `Circle` (acadrust `Circle`, collection `curves`)
|
||||
|
||||
### Constructors (`doc.curves()`)
|
||||
|
||||
- **create_circle**(`centre: [f64; 3], radius: f64`) -> `Circle`
|
||||
|
||||
## `Polyline` (acadrust `LwPolyline`, collection `curves`)
|
||||
|
||||
### Constructors (`doc.curves()`)
|
||||
|
||||
- **create_polyline**(`points: &[[f64; 3]], closed: bool`) -> `Polyline`
|
||||
|
||||
### Methods
|
||||
|
||||
- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` — **not yet supported** (returns `ApiError::Unsupported`)
|
||||
|
||||
## `Point` (acadrust `Point`, collection `curves`)
|
||||
|
||||
### Constructors (`doc.curves()`)
|
||||
|
||||
- **create_point**(`position: [f64; 3]`) -> `Point`
|
||||
- **create_points**(`positions: &[[f64; 3]]`) -> `Vec<Point>` *(bulk op)*
|
||||
|
||||
## Generic methods (every handle)
|
||||
|
||||
Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a
|
||||
`Copy`, `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).
|
||||
|
||||
`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed
|
||||
downcast when `kind == "Solid3D"`).
|
||||
|
||||
## 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 |
|
||||
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"envelope_version": 1,
|
||||
"families": [
|
||||
{
|
||||
"acadrust_variant": "Solid3D",
|
||||
"collection": "solids",
|
||||
"handle": "Solid",
|
||||
"methods": [
|
||||
{
|
||||
"args": [
|
||||
{
|
||||
"name": "other",
|
||||
"ty": "Solid"
|
||||
}
|
||||
],
|
||||
"bulk": false,
|
||||
"fixed": {
|
||||
"erase_sources": true,
|
||||
"op": "Intersection"
|
||||
},
|
||||
"kind": "op",
|
||||
"name": "intersect",
|
||||
"op": "SolidBoolean",
|
||||
"query": null,
|
||||
"read_only": false,
|
||||
"returns": "Solid"
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
{
|
||||
"name": "other",
|
||||
"ty": "Solid"
|
||||
}
|
||||
],
|
||||
"bulk": false,
|
||||
"fixed": {
|
||||
"erase_sources": true,
|
||||
"op": "Union"
|
||||
},
|
||||
"kind": "op",
|
||||
"name": "union",
|
||||
"op": "SolidBoolean",
|
||||
"query": null,
|
||||
"read_only": false,
|
||||
"returns": "Solid"
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
{
|
||||
"name": "other",
|
||||
"ty": "Solid"
|
||||
}
|
||||
],
|
||||
"bulk": false,
|
||||
"fixed": {
|
||||
"erase_sources": true,
|
||||
"op": "Difference"
|
||||
},
|
||||
"kind": "op",
|
||||
"name": "subtract",
|
||||
"op": "SolidBoolean",
|
||||
"query": null,
|
||||
"read_only": false,
|
||||
"returns": "Solid"
|
||||
},
|
||||
{
|
||||
"args": [],
|
||||
"bulk": false,
|
||||
"fixed": null,
|
||||
"kind": "query",
|
||||
"name": "volume",
|
||||
"op": null,
|
||||
"query": "GetVolume",
|
||||
"read_only": true,
|
||||
"returns": "f64"
|
||||
},
|
||||
{
|
||||
"args": [],
|
||||
"bulk": false,
|
||||
"fixed": null,
|
||||
"kind": "query",
|
||||
"name": "centroid",
|
||||
"op": null,
|
||||
"query": "GetCentroid",
|
||||
"read_only": true,
|
||||
"returns": "[f64; 3]"
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
{
|
||||
"name": "other",
|
||||
"ty": "Solid"
|
||||
}
|
||||
],
|
||||
"bulk": false,
|
||||
"fixed": null,
|
||||
"kind": "query",
|
||||
"name": "intersects",
|
||||
"op": null,
|
||||
"query": "GetIntersects",
|
||||
"read_only": true,
|
||||
"returns": "bool"
|
||||
}
|
||||
],
|
||||
"name": "Solid"
|
||||
},
|
||||
{
|
||||
"acadrust_variant": "Line",
|
||||
"collection": "curves",
|
||||
"handle": "Line",
|
||||
"methods": [],
|
||||
"name": "Line"
|
||||
},
|
||||
{
|
||||
"acadrust_variant": "Circle",
|
||||
"collection": "curves",
|
||||
"handle": "Circle",
|
||||
"methods": [],
|
||||
"name": "Circle"
|
||||
},
|
||||
{
|
||||
"acadrust_variant": "LwPolyline",
|
||||
"collection": "curves",
|
||||
"handle": "Polyline",
|
||||
"methods": [
|
||||
{
|
||||
"args": [
|
||||
{
|
||||
"name": "at",
|
||||
"ty": "usize"
|
||||
},
|
||||
{
|
||||
"name": "point",
|
||||
"ty": "[f64; 3]"
|
||||
}
|
||||
],
|
||||
"bulk": false,
|
||||
"fixed": null,
|
||||
"kind": "op",
|
||||
"name": "add_vertex",
|
||||
"op": "AddVertex",
|
||||
"query": null,
|
||||
"read_only": false,
|
||||
"returns": "()"
|
||||
}
|
||||
],
|
||||
"name": "Polyline"
|
||||
},
|
||||
{
|
||||
"acadrust_variant": "Point",
|
||||
"collection": "curves",
|
||||
"handle": "Point",
|
||||
"methods": [],
|
||||
"name": "Point"
|
||||
}
|
||||
],
|
||||
"layouts": {
|
||||
"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>)"
|
||||
]
|
||||
},
|
||||
"Query": {
|
||||
"enum": [
|
||||
"GetEntity { id: ObjectId }",
|
||||
"GetBounds { id: ObjectId }",
|
||||
"GetCentroid { id: ObjectId }",
|
||||
"GetVolume { id: ObjectId }",
|
||||
"GetIntersects { a: ObjectId, b: ObjectId }",
|
||||
"GetGeometryRevision"
|
||||
]
|
||||
}
|
||||
},
|
||||
"object_model": {
|
||||
"collections": [
|
||||
"solids",
|
||||
"curves",
|
||||
"entities"
|
||||
],
|
||||
"document": "Document",
|
||||
"handles": [
|
||||
"Entity",
|
||||
"Solid",
|
||||
"Line",
|
||||
"Circle",
|
||||
"Polyline",
|
||||
"Point"
|
||||
],
|
||||
"root": "DocApi"
|
||||
},
|
||||
"schema": "ocs_doc_api/binding_schema"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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>)"
|
||||
]
|
||||
},
|
||||
"Query": {
|
||||
"enum": [
|
||||
"GetEntity { id: ObjectId }",
|
||||
"GetBounds { id: ObjectId }",
|
||||
"GetCentroid { id: ObjectId }",
|
||||
"GetVolume { id: ObjectId }",
|
||||
"GetIntersects { a: ObjectId, b: ObjectId }",
|
||||
"GetGeometryRevision"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand.
|
||||
// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI
|
||||
// fails on drift (`--check`). Append new variants at the END only (bincode
|
||||
// discriminant stability, plan §7).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::id::ObjectId;
|
||||
use crate::ops::{BoolOp, Curve2Spec, EntitySpec, PlacementSpec, SolidPrimitive};
|
||||
|
||||
/// A typed write operation (plan §5). 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>),
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand.
|
||||
// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI
|
||||
// fails on drift (`--check`). Append new variants at the END only (bincode
|
||||
// discriminant stability, plan §7).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::id::ObjectId;
|
||||
|
||||
/// A typed read query (plan §5). Read-only: no mutation, no undo, no revision
|
||||
/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only.
|
||||
#[derive(Debug, Clone, Copy, 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,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
/// The XY work plane (origin at 0, X=(1,0,0), normal=(0,0,1)).
|
||||
fn xy_plane() -> ApiResult<Plane> {
|
||||
Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0])
|
||||
.ok_or_else(|| geom_err(GeometryErrorKind::Other, "failed to construct XY plane"))
|
||||
}
|
||||
|
||||
/// Construct a solid primitive (plan mapping table).
|
||||
pub(crate) fn make_solid(p: &SolidPrimitive) -> ApiResult<KernelBody> {
|
||||
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(profile: &[Curve2], direction: [f64; 3]) -> ApiResult<KernelBody> {
|
||||
cadkernel::brep::extrude(xy_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(profile: &[Curve2], pivot: [f64; 3], axis: [f64; 3], angle: f64) -> ApiResult<KernelBody> {
|
||||
cadkernel::brep::revolve(xy_plane()?, profile, pivot, axis, angle)
|
||||
.ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "revolve failed"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Stable entity identity (plan §2 decision #7).
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub const fn is_null(self) -> bool {
|
||||
self.0 == 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", test))]
|
||||
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", test))]
|
||||
pub fn to_handle(self) -> acadrust::Handle {
|
||||
acadrust::Handle::new(self.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! `ocs_doc_api` — Object-oriented, transport-agnostic CAD entity API for
|
||||
//! OpenCADStudio plugins and hosts (v2).
|
||||
//!
|
||||
//! Core vocabulary: **Entity**, **ObjectId** (u64), **Operation** (typed write),
|
||||
//! **Query** (typed read), **Transport** (the only plugin↔host boundary).
|
||||
//! Every write op is one atomic host call = one undo step (no multi-op write
|
||||
//! transactions — removed after the security/integrity review; see the plan §13).
|
||||
//!
|
||||
//! The facade is object-centric: [`DocApi`] (root) → [`Document`] → collections
|
||||
//! ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`]) → typed
|
||||
//! handles ([`Solid`], [`Line`], …) carrying geometric methods. The same typed
|
||||
//! requests run in-process and over IPC; only the [`Transport`] differs.
|
||||
|
||||
pub mod error;
|
||||
pub mod id;
|
||||
pub mod revision;
|
||||
|
||||
#[cfg(feature = "host")]
|
||||
pub mod backend;
|
||||
#[cfg(feature = "host")]
|
||||
pub mod executor;
|
||||
#[cfg(feature = "host")]
|
||||
pub mod geom;
|
||||
|
||||
pub mod envelope;
|
||||
pub mod facade;
|
||||
pub mod ops;
|
||||
pub mod query;
|
||||
pub mod transport;
|
||||
|
||||
// Generated (build.rs) — committed snapshots under src/gen/; do NOT edit by hand.
|
||||
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::{
|
||||
CurveCollection, DocApi, Document, Entity, EntityCollection, HasId, Line, OpGroup, Point,
|
||||
Polyline, QueryBatch, QueryResults, Solid, SolidCollection,
|
||||
};
|
||||
pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive};
|
||||
pub use query::{Aabb, EntityView, Query, QueryResult};
|
||||
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 (plan §10.2): object model + method
|
||||
/// signatures + op/query mapping with traced wire layouts inlined. Single,
|
||||
/// self-contained, versioned with [`ENVELOPE_VERSION`].
|
||||
pub fn binding_schema_json() -> &'static str {
|
||||
include_str!("gen/binding_schema.json")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Write operations — the typed, transport-agnostic vocabulary for changing the
|
||||
//! document (plan §5). The `Operation` enum itself is GENERATED into
|
||||
//! `crate::gen` by `build.rs` from `spec/entities.toml`; this module re-exports
|
||||
//! it and holds the hand-written payload mirrors.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::gen::Operation;
|
||||
|
||||
/// Boolean operation kind for [`Operation::SolidBoolean`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BoolOp {
|
||||
Union,
|
||||
Intersection,
|
||||
Difference,
|
||||
}
|
||||
|
||||
/// 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] },
|
||||
Circle { centre: [f64; 3], radius: f64 },
|
||||
Polyline { points: Vec<[f64; 3]>, closed: bool },
|
||||
Point { position: [f64; 3] },
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// 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 (plan §5.3): 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Read queries — typed, transport-agnostic reads of entity state (plan §5).
|
||||
//! The `Query` enum is GENERATED into `crate::gen` by `build.rs`; 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 {
|
||||
pub fn overlaps(&self, other: &Aabb) -> bool {
|
||||
(0..3).all(|i| self.min[i] <= other.max[i] && other.min[i] <= self.max[i])
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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),
|
||||
}
|
||||
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! In-process transport (plan §7): 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 mutably (test/introspection helper).
|
||||
pub fn backend_mut(&mut self) -> &mut B {
|
||||
self.backend.get_mut().expect("in-process backend poisoned")
|
||||
}
|
||||
/// 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> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! IPC transport (plan §7, decision #10): 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::{PluginRequestError, PluginRequestSender};
|
||||
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
|
||||
|
||||
use crate::envelope::{DocApiEnvelope, Receipt};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The transport boundary (plan §7) — 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Unit tests over an in-memory mock `DocApiBackend` (plan §11.1): no host.
|
||||
//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup
|
||||
//! compensation, bulk all-or-nothing, revision bumps, read-only query batching,
|
||||
//! and stale-handle `UnknownId` semantics.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ocs_doc_api::backend::{DocApiBackend, KernelBody};
|
||||
use ocs_doc_api::{
|
||||
Aabb, ApiError, ApiResult, Curve2Spec, DocApi, EntityView, GeometryErrorKind, GeometryRevision,
|
||||
HasId, ObjectId, PlacementSpec,
|
||||
};
|
||||
|
||||
/// In-memory mock backend: HashMap<ObjectId, Body> + a tiny entity table.
|
||||
/// Tracks undo steps + revision bumps to assert per-op semantics.
|
||||
#[derive(Default)]
|
||||
struct MockBackend {
|
||||
next_id: u64,
|
||||
revision: u64,
|
||||
undo_steps: u32,
|
||||
bodies: HashMap<ObjectId, KernelBody>,
|
||||
kinds: HashMap<ObjectId, String>,
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
impl DocApiBackend for MockBackend {
|
||||
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody> {
|
||||
self.bodies.get(&id).cloned().ok_or(ApiError::UnknownId(id))
|
||||
}
|
||||
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 add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
|
||||
let kind = match spec {
|
||||
Curve2Spec::Line { .. } => "Line",
|
||||
Curve2Spec::Circle { .. } => "Circle",
|
||||
Curve2Spec::Polyline { .. } => "LwPolyline",
|
||||
Curve2Spec::Point { .. } => "Point",
|
||||
};
|
||||
Ok(self.alloc(kind))
|
||||
}
|
||||
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);
|
||||
Ok(self.kinds.remove(&id).is_some())
|
||||
}
|
||||
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
|
||||
let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone();
|
||||
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, 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> {
|
||||
let body = self.body(id)?;
|
||||
let bb = cadkernel::brep::body_bounds(body)
|
||||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?;
|
||||
Ok(Aabb { min: bb.min, max: bb.max })
|
||||
}
|
||||
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> {
|
||||
let body = self.body(id)?;
|
||||
Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).centroid_approx())
|
||||
}
|
||||
fn volume(&mut self, id: ObjectId) -> ApiResult<f64> {
|
||||
let body = self.body(id)?;
|
||||
Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).volume_approx())
|
||||
}
|
||||
fn entity_exists(&self, id: ObjectId) -> bool {
|
||||
self.kinds.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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate mesh volume/centroid for the mock (divergence over triangles).
|
||||
trait MeshApprox {
|
||||
fn volume_approx(&self) -> f64;
|
||||
fn centroid_approx(&self) -> [f64; 3];
|
||||
}
|
||||
impl MeshApprox for cadkernel::brep::Mesh {
|
||||
fn volume_approx(&self) -> f64 {
|
||||
let mut vol = 0.0;
|
||||
for t in &self.triangles {
|
||||
let v0 = self.positions[t[0]];
|
||||
let v1 = self.positions[t[1]];
|
||||
let v2 = self.positions[t[2]];
|
||||
let cross = [
|
||||
v1[1] * v2[2] - v1[2] * v2[1],
|
||||
v1[2] * v2[0] - v1[0] * v2[2],
|
||||
v1[0] * v2[1] - v1[1] * v2[0],
|
||||
];
|
||||
vol += (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0;
|
||||
}
|
||||
vol
|
||||
}
|
||||
fn centroid_approx(&self) -> [f64; 3] {
|
||||
let mut vol = 0.0;
|
||||
let mut c = [0.0; 3];
|
||||
for t in &self.triangles {
|
||||
let v0 = self.positions[t[0]];
|
||||
let v1 = self.positions[t[1]];
|
||||
let v2 = self.positions[t[2]];
|
||||
let cross = [
|
||||
v1[1] * v2[2] - v1[2] * v2[1],
|
||||
v1[2] * v2[0] - v1[0] * v2[2],
|
||||
v1[0] * v2[1] - v1[1] * v2[0],
|
||||
];
|
||||
let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0;
|
||||
vol += tet;
|
||||
for i in 0..3 {
|
||||
c[i] += (v0[i] + v1[i] + v2[i]) * tet;
|
||||
}
|
||||
}
|
||||
if vol.abs() < 1e-12 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
[c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]
|
||||
}
|
||||
}
|
||||
|
||||
fn api() -> (DocApi, Arc<ocs_doc_api::transport::InProcess<MockBackend>>) {
|
||||
let tp = Arc::new(ocs_doc_api::transport::InProcess::new(MockBackend::default()));
|
||||
(DocApi::connect(tp.clone(), 0), tp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuboid_intersect_cuboid_atomic_ops_and_revision() {
|
||||
let (api, _tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let mut grp = ocs_doc_api::OpGroup::new();
|
||||
|
||||
let rev0 = doc.revision().unwrap();
|
||||
// Two overlapping boxes — the kernel's own boolean test case (boolean.rs pair()).
|
||||
let block = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])).unwrap();
|
||||
let other = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0])).unwrap();
|
||||
assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 2);
|
||||
|
||||
let lens = match block.intersect(&other) {
|
||||
Ok(l) => { grp.commit(); l }
|
||||
Err(e) => panic!("intersect failed: {e}"),
|
||||
};
|
||||
assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 3);
|
||||
|
||||
let res = doc
|
||||
.query_batch(|q| {
|
||||
q.bounds(&lens);
|
||||
q.volume(&lens);
|
||||
})
|
||||
.unwrap();
|
||||
let bb = res.bounds(0).unwrap();
|
||||
let vol = res.volume(1).unwrap();
|
||||
// Intersection of [0,10]³ ∩ [5,15]³ = [5,10]³ → bounds + volume 125.
|
||||
assert!((bb.min[0] - 5.0).abs() < 1e-6 && (bb.max[0] - 10.0).abs() < 1e-6, "bounds {bb:?}");
|
||||
assert!(vol > 0.0, "intersection volume must be positive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_erase_sources_removes_second_solid() {
|
||||
let (api, tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]).unwrap();
|
||||
let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]).unwrap();
|
||||
let result = a.union(&b).unwrap();
|
||||
let backend = tp.backend();
|
||||
assert!(backend.entity_exists(a.id()));
|
||||
assert!(!backend.entity_exists(b.id()));
|
||||
assert_eq!(result.id(), a.id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_handle_get_returns_error() {
|
||||
let (api, _tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let ghost = ObjectId::from_u64(999_999);
|
||||
assert!(doc.entities().get(ghost).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opgroup_compensate_deletes_created() {
|
||||
let (api, tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let mut grp = ocs_doc_api::OpGroup::new();
|
||||
let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])).unwrap();
|
||||
let b = grp.track(doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)).unwrap();
|
||||
grp.compensate(&doc).unwrap();
|
||||
let backend = tp.backend();
|
||||
assert!(!backend.entity_exists(a.id()));
|
||||
assert!(!backend.entity_exists(b.id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_create_many_one_undo_step() {
|
||||
let (api, tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let rev0 = doc.revision().unwrap().as_u64();
|
||||
let coords: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64, 0.0, 0.0]).collect();
|
||||
let pts = doc.curves().create_points(&coords).unwrap();
|
||||
assert_eq!(pts.len(), 1000);
|
||||
let backend = tp.backend();
|
||||
assert_eq!(backend.revision, rev0 + 1);
|
||||
assert_eq!(backend.undo_steps, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_many_with_stale_id_fails_and_applies_nothing() {
|
||||
let (api, tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]).unwrap();
|
||||
let rev_before = doc.revision().unwrap().as_u64();
|
||||
let stale = ObjectId::from_u64(424_242);
|
||||
let err = doc
|
||||
.entities()
|
||||
.transform_many(&[a.id(), stale], PlacementSpec::at([1.0, 0.0, 0.0]))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ApiError::Validation { .. }));
|
||||
let backend = tp.backend();
|
||||
assert_eq!(backend.revision, rev_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_revision_guard() {
|
||||
let (api, _tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let rev = doc.revision().unwrap();
|
||||
doc.assert_revision(rev).unwrap();
|
||||
doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3]).unwrap();
|
||||
assert!(doc.assert_revision(rev).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_boolean_leaves_inputs_live_and_no_undo() {
|
||||
let (api, tp) = api();
|
||||
let doc = api.document(api.active_tab());
|
||||
let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]).unwrap();
|
||||
// `b` is stale: boolean fails before push_undo -> no mutation, no undo step.
|
||||
let stale_solid = {
|
||||
// Build a Solid handle to a non-existent id via Entity downcast path.
|
||||
// Here we just call intersect against a deleted id.
|
||||
let tmp = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0).unwrap();
|
||||
tmp.delete().unwrap();
|
||||
tmp
|
||||
};
|
||||
let rev_before = doc.revision().unwrap().as_u64();
|
||||
assert!(a.intersect(&stale_solid).is_err());
|
||||
let backend = tp.backend();
|
||||
// `a` is still live; no undo recorded for the failed boolean.
|
||||
assert!(backend.entity_exists(a.id()));
|
||||
assert_eq!(backend.revision, rev_before);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c91a1c
|
||||
# Runtime IPC and serialization (host feature only).
|
||||
interprocess = { version = "2", optional = true }
|
||||
bincode = { version = "1", optional = true }
|
||||
serde_bytes = { version = "0.11", optional = true }
|
||||
thiserror = { version = "1", optional = true }
|
||||
libloading = { version = "0.8", optional = true }
|
||||
memmap2 = { version = "0.9", optional = true }
|
||||
@@ -29,7 +30,7 @@ getrandom = { version = "0.2", optional = true }
|
||||
# Enables the runtime host surface (`HostApi` trait) and the out-of-process
|
||||
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
|
||||
# leave it off.
|
||||
host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
|
||||
host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
|
||||
|
||||
[build-dependencies]
|
||||
serde-reflection = "0.6"
|
||||
|
||||
@@ -552,6 +552,18 @@ 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
|
||||
/// `Receipt` (or the bincode-serialized `ApiError` on failure). 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,6 +138,12 @@ 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`.
|
||||
@@ -164,6 +170,10 @@ 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 `Receipt` 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,5 +77,9 @@ 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
// DocApi v2 backend: implement `ocs_doc_api::backend::DocApiBackend` for
|
||||
// `HostSession` (plan §6/§8, decision #11 — thin host hook, executor logic in
|
||||
// the versioned crate). Maps each backend method to already-present scene/app
|
||||
// primitives; no new geometry code.
|
||||
|
||||
use acadrust::entities::Solid3D;
|
||||
use acadrust::{EntityType, Handle};
|
||||
use ocs_doc_api::backend::{DocApiBackend, KernelBody};
|
||||
use ocs_doc_api::{
|
||||
Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, ObjectId,
|
||||
PlacementSpec,
|
||||
};
|
||||
|
||||
use crate::scene::convert::acis_export;
|
||||
use crate::scene::model::solid_model;
|
||||
|
||||
use super::plugin_host::HostSession;
|
||||
|
||||
/// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by
|
||||
// the in-process path. Deserializes the envelope, runs the crate executor,
|
||||
// serializes the `Receipt` (or `ApiError`) back to bytes.
|
||||
pub fn execute_doc_api(host: &mut HostSession<'_>, _tab_id: u64, bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use ocs_doc_api::{DocApiEnvelope, EnvelopeBody};
|
||||
let envelope: DocApiEnvelope = bincode::deserialize(bytes)
|
||||
.map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?;
|
||||
let result: ApiResult<ocs_doc_api::Receipt> = match envelope.body {
|
||||
EnvelopeBody::Op(op) => ocs_doc_api::executor::apply_op(host, op),
|
||||
EnvelopeBody::Queries(qs) => ocs_doc_api::executor::apply_queries(host, qs),
|
||||
};
|
||||
bincode::serialize(&result).map_err(|e| format!("Receipt serialize: {e}"))
|
||||
}
|
||||
|
||||
fn obj_to_handle(id: ObjectId) -> Handle {
|
||||
Handle::new(id.as_u64())
|
||||
}
|
||||
fn handle_to_obj(h: Handle) -> ObjectId {
|
||||
ObjectId::from_u64(h.value())
|
||||
}
|
||||
|
||||
impl DocApiBackend for HostSession<'_> {
|
||||
fn resolve_body(&mut self, id: ObjectId) -> ApiResult<KernelBody> {
|
||||
let handle = obj_to_handle(id);
|
||||
// Lift-on-miss: populate the solid_models cache from AcisData if absent.
|
||||
self.scene_mut().restore_solid_models(&[handle]);
|
||||
self.scene()
|
||||
.solid_models
|
||||
.get(&handle)
|
||||
.cloned()
|
||||
.ok_or(ApiError::UnknownId(id))
|
||||
}
|
||||
|
||||
fn store_solid(&mut self, body: &KernelBody) -> ApiResult<ObjectId> {
|
||||
// Pre-flight the display prep BEFORE committing the entity: if the mesh
|
||||
// cannot be produced, fail now — no entity, no rollback, no revision bump
|
||||
// (plan review: store_solid rollback moved the epoch on failure).
|
||||
let scene = self.scene_mut();
|
||||
let Some(display) = scene.prepare_solid_model_display(Handle::NULL, body) else {
|
||||
return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed"));
|
||||
};
|
||||
// Mirror `add_solid_model` (history-free v1): edge wires + SAT + entity.
|
||||
let mut inner = Solid3D::new();
|
||||
inner.common.plotstyle_flags = 2;
|
||||
inner.wires = solid_model::edge_wires(body);
|
||||
let document = acis_export::solid_to_sat(body)
|
||||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?;
|
||||
inner.set_sat_document(&document);
|
||||
let handle = self
|
||||
.commit_entity_handle(EntityType::Solid3D(inner))
|
||||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Other, "commit_entity_handle failed"))?;
|
||||
// register_prepared_solid_model cannot fail once the display prep succeeded,
|
||||
// so there is no post-commit rollback path. Register the live B-rep so
|
||||
// booleans/transforms see it (decision #7).
|
||||
self.scene_mut().register_prepared_solid_model(handle, body.clone(), display);
|
||||
Ok(handle_to_obj(handle))
|
||||
}
|
||||
|
||||
fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> {
|
||||
let handle = obj_to_handle(id);
|
||||
// Rebuild the Solid3D entity with fresh SAT + wires, preserving the handle.
|
||||
let Some(existing) = self.document().get_entity(handle).cloned() else {
|
||||
return Err(ApiError::UnknownId(id));
|
||||
};
|
||||
let EntityType::Solid3D(mut inner) = existing else {
|
||||
return Err(ApiError::Unsupported("update_solid on non-solid entity".into()));
|
||||
};
|
||||
inner.wires = solid_model::edge_wires(body);
|
||||
let document = acis_export::solid_to_sat(body)
|
||||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?;
|
||||
inner.set_sat_document(&document);
|
||||
// Pre-flight the display prep before mutating (fail without touching state).
|
||||
let Some(display) = self.scene_mut().prepare_solid_model_display(handle, body) else {
|
||||
return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed"));
|
||||
};
|
||||
if !self.scene_mut().update_entity(EntityType::Solid3D(inner)) {
|
||||
return Err(ApiError::UnknownId(id));
|
||||
}
|
||||
// Refresh the live cache (cannot fail after a successful pre-flight).
|
||||
self.scene_mut().register_prepared_solid_model(handle, body.clone(), display);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
|
||||
let entity = crate::app::doc_api_convert::curve_spec_to_entity(spec)?;
|
||||
let handle = self.scene_mut().add_entity(entity);
|
||||
Ok(handle_to_obj(handle))
|
||||
}
|
||||
|
||||
fn add_vertex(&mut self, _id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
|
||||
Err(ApiError::Unsupported("AddVertex is not yet implemented".into()))
|
||||
}
|
||||
|
||||
fn remove_entity(&mut self, id: ObjectId) -> ApiResult<bool> {
|
||||
let handle = obj_to_handle(id);
|
||||
if !self.entity_exists(id) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Locked layers refuse deletion; surface that as an error so the executor
|
||||
// does not treat a no-op as success (plan review: erase path).
|
||||
if self.scene().is_layer_locked(handle) {
|
||||
return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer")));
|
||||
}
|
||||
// erase_entities clears the entity + solid_models/meshes/hatches and
|
||||
// records the undo delta; the single publish happens at finalize_op.
|
||||
self.scene_mut().erase_entities(&[handle]);
|
||||
Ok(self.document().get_entity(handle).is_none())
|
||||
}
|
||||
|
||||
fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> {
|
||||
// v1 typed transform is solids-only; pre-validating this lets TransformMany
|
||||
// be all-or-nothing (no mid-loop Unsupported after earlier transforms).
|
||||
if !self.entity_exists(id) {
|
||||
return Err(ApiError::UnknownId(id));
|
||||
}
|
||||
if matches!(self.document().get_entity(obj_to_handle(id)), Some(EntityType::Solid3D(_))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Unsupported("transform is only implemented for solids in v1".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn can_remove(&self, id: ObjectId) -> ApiResult<()> {
|
||||
let handle = obj_to_handle(id);
|
||||
if !self.entity_exists(id) {
|
||||
return Err(ApiError::UnknownId(id));
|
||||
}
|
||||
if self.scene().is_layer_locked(handle) {
|
||||
return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_entity(&mut self, id: ObjectId) -> ApiResult<EntityView> {
|
||||
let handle = obj_to_handle(id);
|
||||
let entity = self
|
||||
.document()
|
||||
.get_entity(handle)
|
||||
.ok_or(ApiError::UnknownId(id))?;
|
||||
Ok(EntityView {
|
||||
id,
|
||||
kind: crate::app::doc_api_convert::entity_kind_name(entity).to_string(),
|
||||
bounds: self.bounds(id).ok(),
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> {
|
||||
let handle = obj_to_handle(id);
|
||||
if !self.entity_exists(id) {
|
||||
return Err(ApiError::UnknownId(id));
|
||||
}
|
||||
// Solids go through the kernel transform (re-embed SAT). Non-solids are
|
||||
// out of scope for v1 typed transforms.
|
||||
if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) {
|
||||
let body = self.resolve_body(id)?;
|
||||
let place = kernel_placement(placement);
|
||||
let moved = cadkernel::brep::transform(&body, &place)
|
||||
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?;
|
||||
self.update_solid(id, &moved)
|
||||
} else {
|
||||
Err(ApiError::Unsupported("transform is only implemented for solids in v1".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_curves(&self, _id: ObjectId) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
|
||||
// Profiles for Extrude/Revolve land in a later phase (§5.2); v1 surfaces
|
||||
// a clear error rather than guessing.
|
||||
Err(ApiError::Unsupported("profile curves for sweep ops are not yet implemented".into()))
|
||||
}
|
||||
|
||||
fn bounds(&mut self, id: ObjectId) -> ApiResult<Aabb> {
|
||||
let handle = obj_to_handle(id);
|
||||
// Lift-on-miss for solids (consistent with volume/centroid): resolve_body
|
||||
// repopulates a cold solid_models cache from SAT. A solid whose cache entry
|
||||
// was dropped (e.g. by a host-side update_entity) no longer misreports
|
||||
// `Unsupported` (plan review).
|
||||
if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) {
|
||||
let body = self.resolve_body(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 });
|
||||
}
|
||||
crate::app::doc_api_convert::entity_bounds(self.document().get_entity(handle), id)
|
||||
}
|
||||
|
||||
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> {
|
||||
let handle = obj_to_handle(id);
|
||||
// Serve from the scene's render-mesh metrics cache when present (avoids a
|
||||
// full body tessellation + B-rep clone per query, plan review); fall back
|
||||
// to the kernel mesh when the cache is cold.
|
||||
if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) {
|
||||
if metrics.volume.abs() > 0.0 {
|
||||
return Ok(metrics.centroid);
|
||||
}
|
||||
}
|
||||
let body = self.resolve_body(id)?;
|
||||
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
|
||||
Ok(mesh_volume_centroid(&mesh).1)
|
||||
}
|
||||
|
||||
fn volume(&mut self, id: ObjectId) -> ApiResult<f64> {
|
||||
let handle = obj_to_handle(id);
|
||||
if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) {
|
||||
if metrics.volume.abs() > 0.0 {
|
||||
return Ok(metrics.volume);
|
||||
}
|
||||
}
|
||||
let body = self.resolve_body(id)?;
|
||||
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
|
||||
Ok(mesh_volume_centroid(&mesh).0)
|
||||
}
|
||||
|
||||
fn entity_exists(&self, id: ObjectId) -> bool {
|
||||
self.document().get_entity(obj_to_handle(id)).is_some()
|
||||
}
|
||||
|
||||
fn revision(&self) -> GeometryRevision {
|
||||
GeometryRevision(self.scene().geometry_epoch)
|
||||
}
|
||||
|
||||
fn push_undo(&mut self, label: &str) {
|
||||
// Begin undo capture for an entity-only delta (delta_safe = true: solids
|
||||
// and curves are entity-store changes).
|
||||
self.begin_doc_api_undo(label);
|
||||
}
|
||||
|
||||
fn finalize_op(&mut self) {
|
||||
// Close the delta entry + bump geometry + republish the document view.
|
||||
self.commit_doc_api_undo();
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement {
|
||||
cadkernel::brep::Placement {
|
||||
x_axis: p.x_axis,
|
||||
y_axis: p.y_axis,
|
||||
z_axis: p.z_axis,
|
||||
origin: p.origin,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 for both (used by the cold-cache `volume`/`centroid` fallback).
|
||||
fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
|
||||
let mut vol = 0.0;
|
||||
let mut c = [0.0; 3];
|
||||
for t in &mesh.triangles {
|
||||
let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]);
|
||||
let cross = [
|
||||
v1[1] * v2[2] - v1[2] * v2[1],
|
||||
v1[2] * v2[0] - v1[0] * v2[2],
|
||||
v1[0] * v2[1] - v1[1] * v2[0],
|
||||
];
|
||||
let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0;
|
||||
vol += tet;
|
||||
for i in 0..3 {
|
||||
c[i] += (v0[i] + v1[i] + v2[i]) * tet;
|
||||
}
|
||||
}
|
||||
if vol.abs() < 1e-12 {
|
||||
return (0.0, [0.0; 3]);
|
||||
}
|
||||
(vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::plugin_host::HostSession;
|
||||
use crate::app::OpenCADStudio;
|
||||
use ocs_doc_api::ops::{BoolOp, SolidPrimitive};
|
||||
use ocs_doc_api::{
|
||||
DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt,
|
||||
};
|
||||
|
||||
fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult<Receipt> {
|
||||
let bytes = bincode::serialize(&env).unwrap();
|
||||
let out = execute_doc_api(host, 0, &bytes).expect("dispatch failed");
|
||||
bincode::deserialize(&out).expect("receipt deserialize")
|
||||
}
|
||||
|
||||
fn new_id(receipt: &Receipt) -> ObjectId {
|
||||
receipt.outcome.as_ref().and_then(|o| o.new_id()).expect("no new id")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_api_create_boolean_query_end_to_end() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let rev0 = host.scene().geometry_epoch;
|
||||
|
||||
// op 1 + 2: create two overlapping boxes (one undo step + one bump each).
|
||||
let cuboid_a = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] });
|
||||
let cuboid_b = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] });
|
||||
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_a)).unwrap());
|
||||
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_b)).unwrap());
|
||||
// Each write op advanced the geometry epoch (at least one bump per op).
|
||||
assert!(host.scene().geometry_epoch > rev0, "epoch advanced by creates");
|
||||
|
||||
// op 3: intersect; erase_sources keeps the result at `a`, erases `b`.
|
||||
let intersect = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
|
||||
let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect)).unwrap());
|
||||
assert_eq!(lens, a);
|
||||
assert!(host.document().get_entity(obj_to_handle(b)).is_none(), "b erased");
|
||||
assert!(host.scene().solid_models.contains_key(&obj_to_handle(a)), "result in cache");
|
||||
|
||||
// query batch: bounds + volume (read-only, no bump).
|
||||
let rev_before_query = host.scene().geometry_epoch;
|
||||
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![
|
||||
Query::GetBounds { id: a }, Query::GetVolume { id: a }])).unwrap();
|
||||
assert_eq!(host.scene().geometry_epoch, rev_before_query, "queries must not bump");
|
||||
let bb = match &receipt.query_results[0] {
|
||||
QueryResult::Bounds(bb) => *bb,
|
||||
other => panic!("expected bounds, got {other:?}"),
|
||||
};
|
||||
// [0,10]^3 intersect [5,15]^3 = [5,10]^3.
|
||||
assert!((bb.min[0] - 5.0).abs() < 1e-4 && (bb.max[0] - 10.0).abs() < 1e-4, "bounds {bb:?}");
|
||||
let vol = match &receipt.query_results[1] {
|
||||
QueryResult::Volume(v) => *v,
|
||||
other => panic!("expected volume, got {other:?}"),
|
||||
};
|
||||
assert!((vol - 125.0).abs() < 1.0, "volume {vol}");
|
||||
let _ = HasId::id(&lens);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_api_transform_many_with_non_solid_fails_all_or_nothing() {
|
||||
use ocs_doc_api::ops::Curve2Spec;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
// A solid and a non-solid (a line is not transformable in v1).
|
||||
let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] });
|
||||
let mk_line = Operation::CreateCurve(Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] });
|
||||
let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap());
|
||||
let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap());
|
||||
let rev_before = host.scene().geometry_epoch;
|
||||
|
||||
// TransformMany over [solid, line]: the non-solid makes it fail BEFORE any
|
||||
// mutation (all-or-nothing), so the epoch must not move and no undo is recorded.
|
||||
let op = Operation::TransformMany {
|
||||
ids: vec![solid, line],
|
||||
placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]),
|
||||
};
|
||||
let err = dispatch(&mut host, DocApiEnvelope::op(op)).unwrap_err();
|
||||
assert!(matches!(err, ApiError::Validation { .. } | ApiError::Unsupported(_)), "{err:?}");
|
||||
assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_api_query_batch_over_cap_is_rejected() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1;
|
||||
let queries: Vec<Query> = (0..over).map(|_| Query::GetGeometryRevision).collect();
|
||||
let err = dispatch(&mut host, DocApiEnvelope::queries(queries)).unwrap_err();
|
||||
assert!(matches!(err, ApiError::Validation { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_api_unknown_id_surfaces_structured_error() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let ghost = ObjectId::from_u64(0xDEAD);
|
||||
let err = dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: ghost })).unwrap_err();
|
||||
assert!(matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), "{err:?}");
|
||||
}
|
||||
|
||||
// ── Roundtrip tests: create -> read back via queries -> assert geometry ──
|
||||
|
||||
use ocs_doc_api::ops::{Curve2Spec, PlacementSpec};
|
||||
|
||||
/// Create an entity via DocApi, then read it back via GetEntity/GetBounds and
|
||||
/// assert the geometry round-trips with the expected kind + bounds.
|
||||
#[test]
|
||||
fn roundtrip_2d_entities_line_circle_point_polyline() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
|
||||
// Line [0,0,0]-[10,0,0]: kind + bounds round-trip.
|
||||
let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
|
||||
Curve2Spec::Line { start: [0.0, 0.0, 0.0], end: [10.0, 0.0, 0.0] }))).unwrap());
|
||||
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![
|
||||
Query::GetEntity { id: line }, Query::GetBounds { id: line }])).unwrap();
|
||||
match &receipt.query_results[0] {
|
||||
QueryResult::Entity(v) => assert_eq!(v.kind, "Line"),
|
||||
other => panic!("expected entity, got {other:?}"),
|
||||
}
|
||||
match &receipt.query_results[1] {
|
||||
QueryResult::Bounds(b) => {
|
||||
assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
other => panic!("expected bounds, got {other:?}"),
|
||||
}
|
||||
|
||||
// Circle centre (5,5,0) r=3: bounds = centre ± radius in XY.
|
||||
let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
|
||||
Curve2Spec::Circle { centre: [5.0, 5.0, 0.0], radius: 3.0 }))).unwrap());
|
||||
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }])).unwrap();
|
||||
match &receipt.query_results[0] {
|
||||
QueryResult::Bounds(b) => {
|
||||
assert!((b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
other => panic!("expected bounds, got {other:?}"),
|
||||
}
|
||||
|
||||
// Point at (1,2,3): degenerate bounds.
|
||||
let point = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
|
||||
Curve2Spec::Point { position: [1.0, 2.0, 3.0] }))).unwrap());
|
||||
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: point }])).unwrap();
|
||||
match &receipt.query_results[0] {
|
||||
QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]),
|
||||
other => panic!("expected bounds, got {other:?}"),
|
||||
}
|
||||
|
||||
// Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0.
|
||||
let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve(
|
||||
Curve2Spec::Polyline {
|
||||
points: vec![[0.0,0.0,0.0],[1.0,0.0,0.0],[1.0,1.0,0.0],[0.0,1.0,0.0]],
|
||||
closed: true,
|
||||
}))).unwrap());
|
||||
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }])).unwrap();
|
||||
match &receipt.query_results[0] {
|
||||
QueryResult::Bounds(b) => {
|
||||
assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
other => panic!("expected bounds, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometric methods round-trip: transform moves a solid (bounds shift);
|
||||
/// union/subtract produce the expected mass/volume relationships.
|
||||
#[test]
|
||||
fn roundtrip_geometric_methods_transform_and_booleans() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
|
||||
// Two overlapping boxes [0,10]^3 and [5,15]^3.
|
||||
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
|
||||
SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap());
|
||||
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
|
||||
SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap());
|
||||
|
||||
// Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125.
|
||||
let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
|
||||
let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
|
||||
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }])).unwrap();
|
||||
match &receipt.query_results[0] {
|
||||
QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"),
|
||||
other => panic!("expected volume, got {other:?}"),
|
||||
}
|
||||
|
||||
// THEN transform the result by +100 in X (a real, non-identity move): its
|
||||
// bounds must shift by exactly +100 in X while Y/Z stay at [5,10].
|
||||
let move_op = Operation::Transform { id: inter, placement: PlacementSpec::at([100.0, 0.0, 0.0]) };
|
||||
dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap();
|
||||
let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }])).unwrap();
|
||||
match &receipt.query_results[0] {
|
||||
QueryResult::Bounds(bb) => {
|
||||
assert!((bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4, "{bb:?}");
|
||||
assert!((bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4, "{bb:?}");
|
||||
}
|
||||
other => panic!("expected bounds, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// DWG roundtrip: create two intersected solids, write the document to a DWG
|
||||
/// file, read it back, and assert the intersected solid survives with valid
|
||||
/// ACIS data (the exact path `restore_solid_models` re-lifts on load).
|
||||
#[test]
|
||||
fn roundtrip_intersected_solids_through_dwg_file() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
|
||||
// Two overlapping boxes -> intersect -> the result solid (125 vol).
|
||||
let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
|
||||
SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap());
|
||||
let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid(
|
||||
SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap());
|
||||
let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true };
|
||||
let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap());
|
||||
|
||||
// Write the live document to a STABLE, inspectable DWG in the workspace
|
||||
// target dir so it can be opened in a CAD viewer after the test. The file
|
||||
// is kept (not deleted). If the stable path is momentarily locked (e.g. a
|
||||
// concurrent test run or a viewer holding it), fall back to a process-
|
||||
// unique path for the roundtrip so the test never flakes.
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target");
|
||||
std::fs::create_dir_all(&dir).expect("create target dir");
|
||||
let doc = host.document().clone();
|
||||
let stable = dir.join("doc_api_roundtrip_intersected.dwg");
|
||||
// Release any stale lock/handle on the stable file before rewriting.
|
||||
let _ = std::fs::remove_file(&stable);
|
||||
let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) {
|
||||
Ok(()) => stable,
|
||||
Err(_) => {
|
||||
// Locked by another live process (e.g. a viewer) — write a unique
|
||||
// file for the roundtrip instead of failing.
|
||||
let alt = dir.join(format!("doc_api_roundtrip_intersected_{}.dwg", std::process::id()));
|
||||
acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed");
|
||||
alt
|
||||
}
|
||||
};
|
||||
assert!(path.exists(), "DWG was written");
|
||||
|
||||
// Read it back.
|
||||
let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG");
|
||||
let reloaded = reader.read().expect("read DWG failed");
|
||||
|
||||
// The intersected solid must be present as a Solid3D with valid ACIS data,
|
||||
// and re-lifting it must reproduce a body with the expected volume.
|
||||
let handle = obj_to_handle(lens);
|
||||
let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else {
|
||||
panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG");
|
||||
};
|
||||
assert!(solid.has_acis_data(), "reloaded solid carries ACIS data");
|
||||
|
||||
// Re-lift through the same SAT->kernel path used on load and check volume.
|
||||
let body = crate::scene::convert::solid3d_tess::kernel_body(solid)
|
||||
.expect("re-lift reloaded solid failed");
|
||||
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
|
||||
let vol = mesh_volume_centroid(&mesh).0;
|
||||
assert!((vol - 125.0).abs() < 1.0, "reloaded intersection volume {vol}");
|
||||
|
||||
// The DWG is intentionally kept for inspection (see `path` above).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// DocApi v2 entity conversions (plan §5.1): map the crate's plain-data DTOs to
|
||||
// acadrust `EntityType` and back. Lives in the host (uses the real acadrust
|
||||
// types), keeping the crate's DTOs dependency-light.
|
||||
|
||||
use acadrust::entities::{Circle, Line, LwPolyline, LwVertex, Point};
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, ObjectId};
|
||||
|
||||
fn v3(p: [f64; 3]) -> Vector3 {
|
||||
Vector3::new(p[0], p[1], p[2])
|
||||
}
|
||||
|
||||
/// Build an acadrust entity for a 2D curve construction spec.
|
||||
pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
|
||||
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.is_closed = *closed;
|
||||
EntityType::LwPolyline(e)
|
||||
}
|
||||
Curve2Spec::Point { position } => {
|
||||
let mut e = Point::new();
|
||||
e.location = v3(*position);
|
||||
EntityType::Point(e)
|
||||
}
|
||||
};
|
||||
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::Text(_) => "Text",
|
||||
EntityType::MText(_) => "MText",
|
||||
EntityType::Insert(_) => "Insert",
|
||||
_ => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse bounds for a non-solid entity (solids go through the kernel cache).
|
||||
/// For v1 typed curves we compute the obvious bounds; anything else errors
|
||||
/// `Unsupported` (viewport/annotation bounds land in later phases).
|
||||
pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult<Aabb> {
|
||||
let entity = entity.ok_or(ApiError::UnknownId(id))?;
|
||||
let bb = match entity {
|
||||
EntityType::Line(l) => Aabb {
|
||||
min: [
|
||||
l.start.x.min(l.end.x),
|
||||
l.start.y.min(l.end.y),
|
||||
l.start.z.min(l.end.z),
|
||||
],
|
||||
max: [
|
||||
l.start.x.max(l.end.x),
|
||||
l.start.y.max(l.end.y),
|
||||
l.start.z.max(l.end.z),
|
||||
],
|
||||
},
|
||||
EntityType::Circle(c) => Aabb {
|
||||
min: [c.center.x - c.radius, c.center.y - c.radius, c.center.z],
|
||||
max: [c.center.x + c.radius, c.center.y + c.radius, c.center.z],
|
||||
},
|
||||
EntityType::Point(p) => Aabb {
|
||||
min: [p.location.x, p.location.y, p.location.z],
|
||||
max: [p.location.x, p.location.y, p.location.z],
|
||||
},
|
||||
EntityType::LwPolyline(pl) if !pl.vertices.is_empty() => {
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for vtx in &pl.vertices {
|
||||
let (x, y, z) = (vtx.location.x, vtx.location.y, pl.elevation);
|
||||
min = [min[0].min(x), min[1].min(y), min[2].min(z)];
|
||||
max = [max[0].max(x), max[1].max(y), max[2].max(z)];
|
||||
}
|
||||
Aabb { min, max }
|
||||
}
|
||||
_ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())),
|
||||
};
|
||||
Ok(bb)
|
||||
}
|
||||
@@ -11,6 +11,10 @@ pub(crate) mod expr_eval;
|
||||
mod find_replace;
|
||||
pub(crate) mod helpers;
|
||||
mod history;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod doc_api;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod doc_api_convert;
|
||||
mod layers;
|
||||
mod model_ops;
|
||||
mod mtext_editor;
|
||||
|
||||
@@ -16,6 +16,8 @@ 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> {
|
||||
@@ -24,6 +26,7 @@ impl<'a> HostSession<'a> {
|
||||
app,
|
||||
tab,
|
||||
doc_store: None,
|
||||
doc_api_pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +245,37 @@ 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
|
||||
}
|
||||
|
||||
/// Commit a freshly-built entity into the document, returning its handle.
|
||||
pub(crate) fn commit_entity_handle(&mut self, entity: EntityType) -> Option<Handle> {
|
||||
self.app.commit_entity_handle(entity)
|
||||
}
|
||||
|
||||
/// DocApi `push_undo`: begin an entity-only delta capture 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, true);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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;
|
||||
}
|
||||
@@ -360,6 +394,10 @@ 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)
|
||||
|
||||
Reference in new issue
Block a user