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:
Sebastian committed 2026-09-06 00:15:27 +02:00
1 parent 9129c85f97
commit e41086b058
44 files changed
+4837 -12

No files matched your search

Generated
+96 -11
View File
@@ -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",
]
+3
View File
@@ -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"
+337
View File
@@ -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.
+42
View File
@@ -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"
+95
View File
@@ -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.
+95
View File
@@ -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.
+98
View File
@@ -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()
+337
View File
@@ -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")
}
+194
View File
@@ -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(())
}
+224
View File
@@ -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"
+92
View File
@@ -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);
}
+77
View File
@@ -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,
}
+59
View File
@@ -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() }
}
}
+268
View File
@@ -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,
}
}
+490
View File
@@ -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)
}
}
+115
View File
@@ -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"
}
+28
View File
@@ -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"
]
}
}
+30
View File
@@ -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>),
}
+22
View File
@@ -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,
}
+58
View File
@@ -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"))
}
+40
View File
@@ -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)
}
}
+66
View File
@@ -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")
}
+97
View File
@@ -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",
}
}
}
+61
View File
@@ -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",
}
}
}
+16
View File
@@ -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),
}
}
}
+55
View File
@@ -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:?}"
))),
}
}
}
+36
View File
@@ -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
}
}
+301
View File
@@ -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);
}
+2 -1
View File
@@ -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"
+12
View File
@@ -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`].
+10
View File
@@ -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.
+4
View File
@@ -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),
},
}
}
+547
View File
@@ -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).
}
}
+111
View File
@@ -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)
}
+4
View File
@@ -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;
+38
View File
@@ -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)