General improvements on API

This commit is contained in:
Sebastian committed 2026-09-09 08:02:35 +02:00
1 parent 56e09cd3f5
commit 55f61a4327
30 files changed
+1820 -2565

No files matched your search

+453 -22
View File
@@ -1,27 +1,458 @@
# Document API architecture
# ocs_doc_api — Architecture
`DocApi → Document → collections → typed handles` builds DTO requests.
`Transport::apply` carries one operation or a read-only query batch to the
executor. The native adapter binds each request to its existing `HostSession`.
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).
The executor validates inputs and dispatches through `DocApiBackend`. Backends
must prepare fallible geometry and serialization before changing the document.
Bulk writes prepare all entities, begin one undo record, apply the prepared
changes, then finalize once. Cancellation releases a pending undo capture;
it is not a rollback mechanism for partially applied writes.
## Layered design
The native adapter reuses scene changes, document history, entity transforms
and profile conversion. Solid construction, sweeps, booleans, meshing and mass
properties belong to cadkernel. Profile planes preserve their source elevation
and orientation. Geometry revisions advance when the scene changes; a compound
operation may advance the revision more than once while recording one undo step.
```
┌─ 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 HAND-MAINTAINED (src/gen,
│ append-only) → stable discriminants; the spec's op/query names must map to
│ them (asserted by spec_wire_consistency tests).
│
├─ 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.
```
`Operation` and `Query` are manually maintained wire enums. Append variants;
do not reorder them. The consistency tests verify their bincode discriminants.
`spec/entities.toml` supplies family, constructor and method mappings. `build.rs`
generates the API reference and binding schema. The layout snapshot is curated
vocabulary, not a reflected binary codec schema.
The same `executor` runs in-process and over IPC — one implementation, versioned
in the crate. The host only supplies scene primitives through `DocApiBackend`.
## Flow: from the single source of truth to a committed change
The **single source of truth** for the API surface is `spec/entities.toml` —
every entity family, its construction ops, geometric actions, and queries, and
their kernel/acadrust mapping. Everything downstream derives from it. The full
flow from spec to a committed document change:
```
SINGLE SOURCE OF TRUTH
spec/entities.toml families · constructors · methods · kernel map
src/gen/layouts.json Operation/Query wire layouts
│
▼ (build.rs codegen — cargo build, CI drift-checked)
CODEGEN OUTPUT
src/gen/ops_gen.rs Operation enum (append-only)
src/gen/query_gen.rs Query enum (append-only)
src/gen/api_reference.md
src/gen/binding_schema.json (object model + layouts inlined)
│
▼
FACADE (facade.rs — what a plugin writes)
DocApi ──► Document ──► collections (solids()/curves()/entities())
│
▼
typed handles (Solid · Line · ArcCurve · Text · …)
method call → builds an Operation or Query
│
▼
WIRE (envelope/ops/query)
DocApiEnvelope = Op(Operation) | Queries(Vec<Query>) ──► request
Receipt = outcome + query_results + new_revision ◄── response
│
▼
TRANSPORT (the ONLY plugin↔host boundary)
InProcess (host / built-in plugins / tests)
OcsPluginApiIpc (plugin) PluginRequest::DocApiRequest{tab_id, bytes}
│ │
└────────────┬───────────────────────┘
▼
EXECUTOR (executor.rs — crate-owned, ONE implementation)
apply_op validate+compute → push_undo → mutate → finalize_op
apply_queries read-only batch (capped)
│
▼
HOST BACKEND (src/app/doc_api.rs — HostSession)
tab authorization reject tab_id ≠ bound tab
DocApiBackend impl resolve_body · store_solid · transform · add_*
bounds · volume · centroid · can_remove
│
├──► Scene (per tab) solid_models · meshes · erase_entities
│ geometry_epoch · doc_api_mass_cache
│ doc_api_cold_tess_used
└──► CadDocument (acadrust) entities · block_records · handles
│
▼ produce
Receipt ──► facade turns it into a typed result (Solid, Aabb, f64, …)
```
**How to read it (top to bottom = the path of a request):**
- **SINGLE SOURCE OF TRUTH** — `spec/entities.toml` + `layouts.json`. Change
*these* to change the API surface; everything else regenerates or maps onto them.
- **CODEGEN OUTPUT** — `build.rs` derives the wire enums, the human reference,
and the binding handover schema. The `Operation`/`Query` enums are the canonical
append-only wire vocabulary; the spec's `op`/`query` names must map to them
(enforced by `spec_wire_consistency` tests).
- **FACADE → WIRE → TRANSPORT** — a plugin's `solid.intersect(&other)` becomes an
`Operation`, wrapped in a `DocApiEnvelope`, and shipped via the one `Transport`
(in-process or IPC). Identical request either way.
- **EXECUTOR + HOST BACKEND** — the hot paths: the executor's per-op invariant,
tab authorization, and the per-tab `Scene` state (solid cache, geometry epoch,
the persistent cold-tessellation budget). Atomicity and the DoS boundaries live here.
- **Receipt (response)** — the host produces a `Receipt` (outcome + query results
+ new geometry revision) that the facade turns back into a typed result (`Solid`,
`Aabb`, `f64`, …).
## 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`** are **accuracy-first**: a fine tessellation
(`mesh_body(0.1, 1e-4)`) for near-analytic results, memoized per
(handle, geometry_epoch) on the per-tab `Scene`. The cold-tessellation budget
(256/tab) bounds the DoS surface and **persists across DocApi dispatches**;
when exhausted the path degrades to the approximate `meshes.metrics` value
rather than failing.
- **`resolve_body` lift-on-miss** is cheap when the cache is warm (no-op). The
read-only `with_body` accessor borrows the cache in place (O(1)) instead of
deep-cloning the B-rep — `bounds()` uses it.
- **IPC** serializes the envelope once; the `bytes` fields use `serde_bytes` so
the outer frame is a single length-prefixed copy.
- **Tab authorization**: `execute_doc_api` rejects a request whose `tab_id`
doesn't match the bound tab, so a plugin can't reach tabs it isn't bound to.
---
## 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` accuracy + budget** (was WARNING / perf). They
re-tessellated the full body (`mesh_body`) + cloned the B-rep per query. Now
they compute via a **fine tessellation** (`mesh_body(0.1, 1e-4)`) for
near-analytic accuracy, memoized per (handle, geometry_epoch); the cold-tess
budget lives on the per-tab `Scene` (persists across dispatches) and degrades
to the `meshes.metrics` value when exhausted. The duplicated divergence loops
were merged into one `mesh_volume_centroid` (single source of truth, shared
by the host + both mock backends).
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.
### Review round 3 — branch review fixes (tab auth, geometry, budget, caps)
A full branch review (all six tracks) found 3 CRITICAL + 5 WARNING + suggestions,
all fixed with regression tests:
1. **Tab authorization (confused deputy)** — `execute_doc_api` ignored `tab_id`,
so any plugin could reach any open tab. Now rejects `tab_id != host.tab_id()`
before dispatch (`doc_api_tab_mismatch_is_rejected`).
2. **Negative-bulge arc center mirrored** — `bulge_arc_segment` used signed
`tan(half)` AND a sign flip, double-counting so CW arcs got the center on the
wrong side. Now apothem magnitude + sign-picks-side (CW below / CCW above).
3. **Cold-tess budget ineffective** — the 256-tessellation budget lived on
`HostSession` (rebuilt per dispatch → reset every request). Moved `mass_cache`/
`cold_tess_used` to the per-tab `Scene` so budget + memoization persist; when
exhausted it degrades to the approximate `meshes.metrics` value (no hard-fail).
4. **Loft uncapped** — added `BULK_ITEM_CAP` (was the only uncapped multi-item op
→ unbounded kernel call from one request). Regression test.
5. **CreateMany not all-or-nothing** — Curve specs are pre-validated (ellipse ratio)
in the prepare phase before `push_undo`. Regression test.
6. **Partial-ellipse rotation** — `start`/`end_parameter` now offset by `rot_z`
(the Arc arm already did). Regression test.
7. **`resolve_body` deep-clone** — added `with_body` borrow accessor; the host
borrows the cache in place (O(1)) and `bounds()` uses it (no per-query B-rep copy).
8. **`add_raster_image` unvalidated path** — now validates non-empty + image
extension and rejects UNC/network + parent-traversal paths. Regression test.
9. **Deploy capability gate** — `API_VERSION` 5→6 so DocApi plugins are rejected
at handshake by old hosts (clean refusal, not a runtime decode error); append-only
wire + defaulted `doc_api_dispatch` keep older V2–V5 plugins loading unchanged.
10. **Dead code** — `query_name` wired into `apply_queries` error labels; the host
uses the crate's `ObjectId::from_handle`/`to_handle` (dropped local duplicates);
deleted `is_null`/`backend_mut`; test mock now stores curve specs + computes
curve bounds (aligned with the example mock); Spline/Dimension bounds delegate
to `points_aabb`.
### Known deviations (documented, not bugs)
- **Two `geometry_epoch` bumps per solid write op** (see #7 above) — host-internal,
shared with native commands; monotonic and one-undo-step-per-op are guaranteed.
- **`volume`/`centroid`** are fine-mesh-approximate (`mesh_body(0.1, 1e-4)`), not
exact analytic values; the render-mesh `meshes.metrics` is the coarse display LOD.
- **`Angular2Ln` constructor** exists; `Radius`/`Diameter`/`Angular3Pt`/`Linear`
are the supported dimension sub-types.
To extend the API, update the DTO, backend, executor, facade and spec together,
add a native behavior test, and rebuild the generated reference/schema. New
backend methods must reject unsupported behavior explicitly.
+153
View File
@@ -0,0 +1,153 @@
# 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::*` |
| Sweep | `extrude(profile, dir)` / `revolve(profile, pivot, axis, angle)` | `Extrude`/`Revolve` → `brep::extrude`/`brep::revolve`; profile = Line/Circle/Arc/LwPolyline |
| Booleans | `intersect` / `union` / `subtract` | `SolidBoolean{op, a, b, erase_sources:true}` → `brep::combine` |
| Transform | `transform(placement)` (in-place) | `Transform{id, placement}` → `brep::transform`; solids |
| Bounds | `bounds()` | `GetBounds` → `brep::body_bounds` (lift-on-miss) |
| Volume | `volume()` | `GetVolume` → fine-tessellation `mesh_body(0.1, 1e-4)` (near-analytic); memoized per handle+epoch; budget-exhausted → approximate `meshes.metrics` |
| Centroid | `centroid()` | `GetCentroid` → same accuracy path as `volume()` |
| Delete | `delete()` / `delete_many` | `Delete`/`DeleteMany` |
### Minimal 2D (phase-1 set, used as profiles/inputs)
| Capability | Method / op | Notes |
|---|---|---|
| Line | `create_line` → bounds, transform | `CreateCurve(Line)` |
| Circle | `create_circle` → bounds, transform | `CreateCurve(Circle)` |
| Arc | (create via host) → transform, profile | usable as sweep profile |
| Polyline | `create_polyline` → bounds, transform, `add_vertex` | `CreateCurve(Polyline)`; `AddVertex` inserts a vertex |
| Point | `create_point` / `create_points` (bulk) → transform | `CreateCurve(Point)` / `CreateMany` |
| Transform (2D) | `transform(placement)` / `transform_many` | `Line`/`Circle`/`Arc`/`Point`/`LwPolyline` via acadrust |
### 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 + 2D families |
| 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) |
## 🚧 Roadmap — phase status
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.
**Status:** all five phases complete — Phase 0 (supported-family methods), Phase 2
(full 2D curves + annotations incl. dimension sub-types), Phase 3 (block references
+ attributes + traversal + attribute definitions), Phase 4 (viewports + set_view +
view query), Phase 5 (media read-mostly + typed raster image + typed table). All
tested: 623 host lib + 8 crate unit + 3 spec↔wire consistency + 34 ocs_plugin_api tests green.
### Phase 2 — full 2D + annotations
- **Done (2a):** typed curve families `ArcCurve`/`Ellipse`/`Spline`/`Ray`/`XLine` —
`create_arc`/`create_ellipse`/`create_spline`/`create_ray`/`create_xline`, bounds
(Arc coarse full-circle, Ellipse major-axis radius, Spline control-point bbox;
Ray/XLine unbounded → `Unsupported`), and transform (all five + the phase-1 set)
via acadrust. Covered by `phase2_*` tests.
- **Done (2b-a):** annotations `Text`/`MText` — `create_text`/`create_mtext`
(`CreateText`/`CreateMText` wire ops), `content()`/`set_content()` (new
`GetTextContent` query + `SetTextContent` op), transform (insertion point),
coarse bounds. Covered by `phase2b_*` test.
- **Done (2b-b):** `Hatch` — `create_hatch(boundary, solid)` (`CreateHatch`,
validates ≥3 points), `boundary()` → loops (`GetHatchBoundary`), bounds from
boundary edges, delete. Covered by `phase2b_hatch_*` test.
- **Done (2b-c):** linear `Dimension` — `create_dimension_linear(first, second,
definition)` (`CreateDimensionLinear`), `measurement()` → distance
(`GetDimensionMeasurement`), bounds. Covered by `phase2c_*` test.
- **Done (2c-ii, sub-types):** `create_dimension_radius` / `create_dimension_diameter`
(radial chord) and `create_dimension_angular` (vertex + 2 legs) → radial/degrees
measurements. Covered by `phase2cii_*` test.
- **Done (2c-iii):** `create_dimension_angular2ln(vertex, first, second, arc_location)`
→ `CreateDimensionAngular2Ln` (2-line angular; `DimensionAngular2Ln::new` computes
the angle). Covered by `phase2ciii_*` test. All documented dimension sub-types done.
- **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline)
are supported (Phase 0).
### Phase 3 — containers
- **Done:** `create_insert(block_name, point, scale, rotation)` → `Operation::CreateInsert`
(validates the BlockRecord exists; unknown block → `Validation`, no mutation).
Insert `transform` moves its `insert_point`. Covered by `phase3_*` test.
- **Done (attributes + traversal):** `set_attribute(tag, value)` (adds/updates an
attribute on an insert, `SetAttribute` op), `attributes()` → (tag, value) pairs
(`GetAttributes`), and `block_entities(block_name)` → read-only nested traversal
of a block definition's entities (`GetBlockEntities`). Covered by
`phase3_attributes_and_block_traversal` test.
- **Done (attribute definitions):** `create_attribute_definition(tag, prompt,
default, point, height, rotation)` → `Operation::CreateAttributeDefinition`;
reports kind `AttributeDefinition`. Covered by `phase3ii_*` test. Phase 3 is complete.
### Phase 4 — paper-space & viewports
- **Done:** `create_viewport(center, width, height, view_target, view_height)` →
`Operation::CreateViewport`; `bounds()` = center ± width/2,height/2; `transform`
moves the paper-space center. Covered by `phase4_*` test.
- **Done (set_view + view query):** `set_view(view_target, view_height)` retargets/
re-zooms in place (`SetViewportView` op), `viewport_view()` reads the view back
(`GetViewportView` query → target + zoom height). Covered by
`phase4_set_view_and_view_query` test.
### Phase 5 — media & misc (read-mostly)
- **Done:** media/misc families report a **real `kind`** via `GetEntity`
(`RasterImage`, `Table`, `Leader`, `MultiLeader`, `MLine`, `Mesh`, `Helix`,
`Region`, `Body`, `Surface`, `Face3D`, `Dimension`, `Hatch`) instead of "Other";
generic `delete` works on all. Covered by `phase5_*` test.
- **Done (read-mostly bounds):** `bounds()` now works for the vertex/point-carrying
families — `Leader`, `Mesh`, `Face3D` (4 corners), `MLine` (vertex positions),
`Helix` (start point) — via a points-to-AABB helper (`read_mostly_family_bounds_*`
test). `RasterImage` has 4-corner bounds. `Region`/`Body`/`Surface`/`MultiLeader`
stay `Unsupported` for bounds (complex/nested geometry).
- **Done (typed create):** `create_raster_image(file_path, insertion, u, v, size)`
→ `Operation::CreateRasterImage` (host auto-registers the ImageDefinition), and
`create_table(insertion_point, data[row][col])` → `Operation::CreateTable`
(non-rectangular grid → `Validation`; cells built as text). Covered by
`phase5_create_raster_image_*` and `phase5ii_create_table_*` tests. `light` stays
read-only (no meaningful geometric action).
## 🔧 Outstanding methods on supported families
**Completed (accuracy):** `GetVolume`/`GetCentroid` compute via a **fine
tessellation** (`mesh_body(0.1, 1e-4)`) for near-analytic results — the
render-mesh metrics cache is the coarse display LOD and does not drive queries;
results memoized per (handle, geometry_epoch) on the per-tab `Scene` (persisting
across DocApi dispatches); when the cold-tess budget (256/tab) is exhausted the
path degrades to the approximate `meshes.metrics` value rather than failing.
Verified by `accurate_volume_centroid_sphere_and_cube` (sphere volume within 0.5%
of 4/3πr³, centroid at centre, cube exact).
**Completed (outstanding methods):** `loft(profiles)` — `Operation::Loft{profiles}`,
resolves each profile to curve sets (all-or-nothing), `brep::loft` (`loft_two_profiles_*`
test). **Bulge-arc polyline profiles** — `entity_to_profile_curves` now converts
`LwVertex.bulge` to `geom2d::Curve::Arc` via the bulge→arc formula (θ = 4·atan(bulge)),
so bulged profiles sweep correctly (`bulge_arc_polyline_profile_converts_to_arc` test).
**Completed (Phase 0):** `extrude`, `revolve` (profiles: Line/Circle/Arc/LwPolyline),
`add_vertex` (polyline), non-solid `transform` + `transform_many` (Line/Circle/Arc/
Point/LwPolyline). Covered by `phase0_*` tests in `src/app/doc_api.rs`.
## 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.
+100 -28
View File
@@ -1,41 +1,113 @@
# ocs_doc_api
Typed document operations and queries for OpenCADStudio plugins and hosts.
The same facade runs over an in-process backend or the plugin IPC channel.
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::{ApiResult, DocApi};
use ocs_doc_api::{DocApi, Solid, OpGroup, ApiResult};
fn intersect_solids(api: &DocApi) -> ApiResult<f64> {
fn intersect_solids(api: &DocApi) -> ApiResult<()> {
let doc = api.document(api.active_tab());
let a = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?;
let b = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?;
a.intersect(&b)?.volume()
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(())
}
```
Each successful write marks the document changed and records one undo step.
A failed operation leaves the document unchanged. Bulk creation and transforms
prepare every entity before applying changes. Separate calls commit separately;
`OpGroup` offers best-effort cleanup of creations, not rollback.
## What it is
Boolean `intersect`, `union` and `subtract` replace the first solid and erase the
second. `intersects` only tests bounding-box overlap. Volume and centroid are
computed by the kernel from a tessellated solid (chord tolerance 0.1 drawing units,
angular tolerance 0.0001 radians), then cached per geometry revision. A request
may compute at most 32 uncached mass properties; excess work returns an error.
Bulk operations and query batches are capped at 100,000 items.
- **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 `Clone`, `Send`, `Sync` `{ObjectId,
Arc<Session>}` tokens, safe to share across plugin worker threads. A stale
handle's methods return `ApiError::UnknownId`, never a panic.
Transports bind to one document tab. Typed handles from another transport and
requests for another tab are rejected. Raw `ObjectId` values are relative to the
receiving document. Errors are serialized as `ApiError` variants over IPC.
## Core guarantees
| Feature | Use |
|---|---|
| Default | DTOs, facade, transport trait and binding schema |
| `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` |
| `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host |
- **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).
`Loft` and query batches are item-capped the same way (no unbounded kernel call
from one request).
- **Accurate mass properties.** `volume`/`centroid` use a fine tessellation for
near-analytic results, memoized per (handle, geometry_epoch) on the per-tab
scene; the cold-tessellation budget (256/tab) persists across dispatches and
degrades to the coarse mesh value when exhausted.
- **Tab authorization.** A request must name the tab the session is bound to;
mismatched `tab_id` is rejected (no cross-tab access).
- **Structured errors** — `ApiError::{Validation, Geometry, UnknownId,
Unsupported, Transport}`, serializable so IPC returns the same error the
in-process executor produced.
See the [API reference](src/gen/api_reference.md) for constructors and methods,
[architecture](ARCHITECTURE.md) for backend rules, and
[binding guide](bindings/README.md) for the Python facade and transport contract.
## 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).
- **Solids** — primitives (cuboid, sphere, cylinder, cone, torus, wedge), sweep
(`extrude`/`revolve` from profiles, `loft` multi-profile), booleans
(union/intersect/subtract), transform, and queries (bounds/volume/centroid).
Host resolves bodies via the `solid_models` cache (lift-on-miss from ACIS SAT).
- **2D curves** — line, circle, arc, polyline (`add_vertex`), point (incl. bulk),
ellipse, spline, ray, xline; construction + bounds + transform (solids & 2D via
`transform`/`transform_many`).
- **Annotations** — `Text`/`MText` (create + `content`/`set_content`), `Hatch`
(create + `boundary` query), linear `Dimension` (create + `measurement`).
- **Containers** — `create_insert` (block references) + `attributes`/`set_attribute`
+ `block_entities` (read-only nested block traversal).
- **Paper-space** — `create_viewport` + `set_view` (retarget/re-zoom) + `viewport_view`.
- **Media** — `create_raster_image`; other media families read-only (`EntityView`).
- See `IMPLEMENTATION.md` for the full capability matrix and phased roadmap.
## 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.
+92 -22
View File
@@ -1,28 +1,98 @@
# Language bindings
# ocs_doc_api — binding generator contract
`src/gen/binding_schema.json` describes handle families, constructors, methods,
arguments and operation/query mappings. `build.rs` derives it from
`spec/entities.toml`. Its `layouts` section is manually maintained vocabulary;
it does not contain everything needed to generate a bincode codec.
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).
Generate and test the Python facade from the repository root:
## What you consume
```sh
python3 crates/ocs_doc_api/bindings/python/generate.py > /tmp/ocs_doc.py
python3 -m unittest discover -s crates/ocs_doc_api/bindings/python
**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 */ }
}
```
The generated `DocApi(transport, active_tab)` expects a caller-supplied
`transport.apply(envelope) -> Receipt`. It does not open a plugin connection.
An envelope has `version` and a Python tuple `("Op", operation_dict)` or
`("Queries", [query_dict])`. Enum payloads use externally tagged dictionaries,
for example `{"CreateSolid": {"Sphere": {"centre": [0, 0, 0], "radius": 2}}}`.
The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format,
sends `DocApiRequest { tab_id, bytes }`, and decodes `Result<Receipt, ApiError>`.
Host plugin API version 6 and document envelope version 1 are required.
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 } }
```
Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query
results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade
unwraps these into typed handles or values. The bridge must raise an exception
for an error response. Transport binding determines the tab; requesting another
tab or combining typed handles from different documents raises an error.
## 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.
+154 -122
View File
@@ -1,166 +1,198 @@
#!/usr/bin/env python3
"""Generate a Python facade over a caller-supplied document transport.
"""Reference binding generator for ocs_doc_api (plan §10.2).
Usage: python generate.py ../../src/gen/binding_schema.json > ocs_doc.py
The transport converts these Python DTOs to/from the Rust wire representation.
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 pathlib import Path
from typing import List, Dict, Any
HEADER = '''"""Generated document facade. See bindings/README.md for the transport contract."""
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
from typing import Any, List, Optional
@dataclass
class DocApiEnvelope:
version: int
body: Any
# --- wire DTOs (thin, schema-driven) -----------------------------------------
ObjectId = int # u64
@dataclass
class Receipt:
outcome: Any
query_results: list
query_results: List[Any]
new_revision: int
@dataclass
class Entity:
id: int
_doc: "Document"
class DocApiEnvelope:
version: int
body: Any # ("Op", operation_dict) | ("Queries", [query_dict])
def bounds(self):
return self._doc._apply_query({"GetBounds": {"id": self.id}})
ENVELOPE_VERSION = {ver}
def transform(self, placement):
self._doc._apply_op({"Transform": {"id": self.id, "placement": placement}})
def delete(self):
self._doc._apply_op({"Delete": {"id": self.id}})
def _check_version(schema_version: int) -> None:
if schema_version != ENVELOPE_VERSION:
raise RuntimeError(
f"binding_schema envelope_version {{schema_version}} != {{ENVELOPE_VERSION}}"
)
'''
DOCUMENT = '''
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'",
"ArcCurve": "'ArcCurve'", "Ellipse": "'Ellipse'", "Spline": "'Spline'",
"Ray": "'Ray'", "XLine": "'XLine'", "Text": "'Text'", "MText": "'MText'",
"Dimension": "'Dimension'",
"f64": "float", "bool": "bool", "usize": "int", "i32": "int",
"[f64; 3]": "tuple", "[f64; 2]": "tuple", "ObjectId": "ObjectId",
"String": "str", "&str": "str", "()": "None", "None": "None",
"Vec<(String, String)>": "list", "Vec<Vec<[f64; 2]>>": "list",
"([f64; 3], f64)": "tuple", "Vec<Point>": "list", "Vec<ObjectId>": "list",
}
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", "GetTextContent", "GetHatchBoundary", "GetDimensionMeasurement", "GetAttributes", "GetViewportView"):
body_items.append("'id': self.id")
elif variant in ("SetTextContent", "SetAttribute", "SetViewportView", "Transform", "Delete", "AddVertex"):
# Op variants that carry the receiver ObjectId: inject it first.
body_items.append("'id': self.id")
for a in args:
body_items.append(f"'{a['name']}': {a['name']}")
elif variant == "GetIntersects":
body_items.append("'a': self.id")
if args:
body_items.append(f"'b': {args[0]['name']}.id")
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 _id(self, value):
if isinstance(value, Entity):
if value._doc._transport is not self._transport or value._doc._tab != self._tab:
raise ValueError("handle belongs to another document")
return value.id
return int(value)
def _apply_op(self, op, want=None):
receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Op", op)))
if want is None:
return None
if want.startswith("Vec<"):
handle = globals()[want[4:-1]]
return [handle(i, self) for i in receipt.outcome["NewIds"]]
return globals()[want](receipt.outcome["NewId"], self)
env = DocApiEnvelope(ENVELOPE_VERSION, ("Op", op))
return self._transport.apply(env)
def _apply_query(self, query):
receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [query])))
if len(receipt.query_results) != 1:
raise ValueError("expected one query result")
result = receipt.query_results[0]
if not isinstance(result, dict) or len(result) != 1:
raise ValueError("invalid query result")
return next(iter(result.values()))
def revision(self):
return self._apply_query("GetGeometryRevision")
def block_entities(self, block_name):
return self._apply_query({"GetBlockEntities": {"block_name": block_name}})
def _apply_query(self, q, want=None):
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
active_tab: int = 0
def document(self, tab):
if tab != self.active_tab:
raise ValueError("transport is bound to another tab")
def document(self, tab: int) -> Document:
return Document(self._transport, tab)
'''
def render_method(method, constructor=False):
args = method.get("args", [])
signature = "".join(", " + a["name"] for a in args)
variant = method.get("op") or method["query"]
fields = {}
for arg in args:
name, ty = arg["name"], arg["ty"]
if ty in ("EntityRef", "Solid", "ObjectId"):
fields[name] = f"self._doc._id({name})"
elif ty == "&[EntityRef]":
fields[name] = f"[self._doc._id(p) for p in {name}]"
else:
fields[name] = name
fixed = dict(method.get("fixed") or {})
if variant in ("SolidBoolean", "GetIntersects"):
fields = {"a": "self.id", "b": fields.pop("other"), **fields}
fields.pop("other", None)
elif not constructor:
fields = {"id": "self.id", **fields}
if variant == "Revolve":
fields["axis"] = "(pivot, axis)"
fields.pop("pivot")
nested = fixed.pop("primitive", fixed.pop("curve", None))
fields.update({key: repr(value) for key, value in fixed.items()})
payload = "{" + ", ".join(repr(k) + ": " + v for k, v in fields.items()) + "}"
if variant == "CreateMany":
payload = "[{'Curve': {'Point': {'position': p}}} for p in positions]"
elif nested:
payload = "{" + repr(nested) + ": " + payload + "}"
message = "{" + repr(variant) + ": " + payload + "}"
if method.get("kind") == "query":
call = f"self._doc._apply_query({message})"
else:
want = method.get("returns")
if want == "()":
want = None
call = f"self._doc._apply_op({message}, {want!r})"
return f" def {method['name']}(self{signature}):\n return {call}\n"
def render(schema):
out = [HEADER, f"ENVELOPE_VERSION = {schema['envelope_version']}\n"]
handles = {name: {} for name in schema["object_model"]["handles"]}
collections = {"solids": {}, "curves": {}, "entities": {}}
for family in schema["families"]:
handle = family.get("handle") or family["name"]
for method in family.get("methods", []):
existing = handles[handle].get(method["name"])
if existing is not None and existing != method:
raise ValueError(f"conflicting method: {handle}.{method['name']}")
handles[handle][method["name"]] = method
for constructor in family.get("constructors", []):
collections[family["collection"]][constructor["name"]] = constructor
# Families sharing Entity contribute to one class, preserving all methods.
entity_methods = "\n".join(render_method(m) for m in handles.pop("Entity").values())
out[0] += entity_methods
for handle, methods in handles.items():
out.append(f"\nclass {handle}(Entity):\n" + (
"\n".join(render_method(m) for m in methods.values()) or " pass\n"))
for collection, constructors in collections.items():
out.append(f"\nclass {collection.title()}:\n def __init__(self, doc):\n self._doc = doc\n")
out.extend(render_method(c, constructor=True) for c in constructors.values())
out.append(DOCUMENT)
)
return "\n".join(out)
def main():
default = Path(__file__).resolve().parents[2] / "src/gen/binding_schema.json"
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
sys.stdout.write(render(json.loads(path.read_text(encoding="utf-8"))))
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__":
+62 -48
View File
@@ -1,4 +1,15 @@
//! Regenerate the API reference and binding schema from the curated spec.
//! Codegen driver (plan §10): reads `spec/entities.toml` and regenerates the
//! derived snapshots in `src/gen/` (`api_reference.md`, `binding_schema.json`).
//! CI runs `cargo build` and fails on drift for those two (the committed files
//! are the reviewable truth; build.rs rewrites them deterministically).
//!
//! NOTE: the `Operation`/`Query` wire enums (`ops_gen.rs`/`query_gen.rs`) are
//! HAND-MAINTAINED, append-only, and NOT derived here — the spec's `op`/`query`
//! names must map to enum variants (asserted by a test), never the reverse.
//!
//! 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;
@@ -93,40 +104,48 @@ fn main() {
let spec_path = manifest_dir.join("spec/entities.toml");
println!("cargo:rerun-if-changed={}", spec_path.display());
println!("cargo:rerun-if-changed=src/gen/layouts.json");
let spec_src = fs::read_to_string(&spec_path).expect("read spec/entities.toml");
let spec: Spec = toml::from_str(&spec_src).expect("parse spec/entities.toml");
let gen_dir = manifest_dir.join("src/gen");
fs::create_dir_all(&gen_dir).unwrap();
// 1. Emit the HAND-MAINTAINED ops/query enum snapshots verbatim. The enums are
// NOT derived from the spec here — they are the canonical append-only wire
// vocabulary; the spec's `op`/`query` names must map to variants (asserted
// by a test). `write_if_changed` is effectively a no-op round-trip.
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));
write_if_changed(
&gen_dir.join("binding_schema.json"),
&binding_schema_json(&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);
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()
}
/// Render a method/constructor's `fixed` fields compactly (e.g.
/// `, op="Intersection", erase_sources=true`). Empty when no fixed fields.
fn fmt_fixed(fixed: Option<&toml::Value>) -> String {
let Some(fixed) = fixed else {
return String::new();
};
let Some(tbl) = fixed.as_table() else {
return String::new();
};
let Some(fixed) = fixed else { return String::new() };
let Some(tbl) = fixed.as_table() else { return String::new() };
if tbl.is_empty() {
return String::new();
}
@@ -139,10 +158,7 @@ fn fmt_args(args: &[Arg]) -> String {
if args.is_empty() {
return String::new();
}
args.iter()
.map(|a| format!("{}: {}", a.name, a.ty))
.collect::<Vec<_>>()
.join(", ")
args.iter().map(|a| format!("{}: {}", a.name, a.ty)).collect::<Vec<_>>().join(", ")
}
/// The parenthesised argument list: `(a: T, b: U)` or `()` for zero args.
@@ -218,10 +234,7 @@ fn status_suffix(status: Option<&str>) -> &'static str {
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(&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 {
@@ -289,14 +302,13 @@ fn api_reference_md(spec: &Spec) -> String {
/// The wire vocabulary section: every `Operation`/`Query` variant with its payload,
/// read from `src/gen/layouts.json` (the curated wire-layout snapshot).
fn wire_vocabulary_md() -> String {
let layouts = read_layouts();
let layouts = match fs::read_to_string(layouts_snapshot_path()) {
Ok(src) => serde_json::from_str::<serde_json::Value>(&src).unwrap_or_else(|_| serde_json::json!({})),
Err(_) => serde_json::json!({}),
};
let mut s = String::from("## Wire vocabulary\n\nEvery `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec<Query>)` for a read batch.\n\n");
s.push_str("### `Operation`\n\n");
if let Some(variants) = layouts
.get("Operation")
.and_then(|o| o.get("enum"))
.and_then(|v| v.as_array())
{
if let Some(variants) = layouts.get("Operation").and_then(|o| o.get("enum")).and_then(|v| v.as_array()) {
for v in variants {
if let Some(name) = v.as_str() {
s.push_str(&format!("- `{name}`\n"));
@@ -304,11 +316,7 @@ fn wire_vocabulary_md() -> String {
}
}
s.push_str("\n### `Query`\n\n");
if let Some(variants) = layouts
.get("Query")
.and_then(|o| o.get("enum"))
.and_then(|v| v.as_array())
{
if let Some(variants) = layouts.get("Query").and_then(|o| o.get("enum")).and_then(|v| v.as_array()) {
for v in variants {
if let Some(name) = v.as_str() {
s.push_str(&format!("- `{name}`\n"));
@@ -324,10 +332,10 @@ fn error_and_envelope_md() -> String {
| Variant | Meaning |
|---|---|
| `Validation { op, reason }` | Input rejected before any document mutation. |
| `Validation { op, reason }` | Input rejected **before** any mutation (bulk ops name the failing index). |
| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). |
| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). |
| `Unsupported(String)` | Capability not supported for this entity or backend. |
| `Unsupported(String)` | Capability not implemented (e.g. a later-phase family or sub-type). |
| `Transport(String)` | The channel failed (disconnected / oversized / timeout). |
`ApiError` is serializable, so IPC returns the same structured error the in-process
@@ -346,7 +354,7 @@ Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries
}
/// The merged, self-contained binding handover schema (§10.2). Object model +
/// method signatures + op/query mapping + curated wire vocabulary inlined per node.
/// method signatures + op/query mapping + traced wire layouts inlined per node.
/// The handle types for the binding schema, derived from the spec's families
/// ("Entity" always first, then the deduped family handles in spec order).
fn handles_for_schema(spec: &Spec) -> Vec<String> {
@@ -361,7 +369,9 @@ fn handles_for_schema(spec: &Spec) -> Vec<String> {
}
fn binding_schema_json(spec: &Spec) -> String {
let registry = read_layouts();
// 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
@@ -390,11 +400,6 @@ fn binding_schema_json(spec: &Spec) -> String {
"collection": fam.collection,
"acadrust_variant": fam.acadrust_variant,
"methods": methods,
"constructors": fam.constructor.iter().map(|c| serde_json::json!({
"name": c.name, "op": c.op, "fixed": c.fixed, "bulk": c.bulk,
"returns": c.returns.as_deref().unwrap_or(fam.handle.as_deref().unwrap_or(&fam.name)),
"args": c.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::<Vec<_>>()
})).collect::<Vec<_>>(),
})
})
.collect();
@@ -411,17 +416,26 @@ fn binding_schema_json(spec: &Spec) -> String {
"handles": handles_for_schema(spec),
},
"families": families,
// Curated wire vocabulary (not a complete binary codec schema).
// Traced wire layouts for every Operation/Query/payload DTO (inlined).
"layouts": registry,
});
serde_json::to_string_pretty(&schema).expect("serialize binding schema")
}
/// Read the manually maintained wire vocabulary; malformed snapshots fail the build.
fn read_layouts() -> serde_json::Value {
let src = fs::read_to_string(layouts_snapshot_path()).expect("read layouts.json");
serde_json::from_str(&src).expect("parse layouts.json")
/// 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 {
+31 -136
View File
@@ -8,11 +8,11 @@ use std::collections::HashMap;
use ocs_doc_api::backend::{DocApiBackend, KernelBody};
use ocs_doc_api::{
Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision,
ObjectId, PlacementSpec,
Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, ObjectId,
PlacementSpec,
};
#[derive(Default, Clone)]
#[derive(Default)]
pub struct MockBackend {
next_id: u64,
revision: u64,
@@ -68,10 +68,7 @@ fn curve_bounds(spec: &Curve2Spec) -> 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::Point { position } => Aabb { min: *position, max: *position },
Curve2Spec::Polyline { points, .. } => {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
@@ -87,17 +84,9 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb {
min: [centre[0] - radius, centre[1] - radius, centre[2]],
max: [centre[0] + radius, centre[1] + radius, centre[2]],
},
Curve2Spec::Ellipse {
centre, major_axis, ..
} => {
let r = (major_axis[0] * major_axis[0]
+ major_axis[1] * major_axis[1]
+ major_axis[2] * major_axis[2])
.sqrt();
Aabb {
min: [centre[0] - r, centre[1] - r, centre[2] - r],
max: [centre[0] + r, centre[1] + r, centre[2] + r],
}
Curve2Spec::Ellipse { centre, major_axis, .. } => {
let r = (major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2]).sqrt();
Aabb { min: [centre[0] - r, centre[1] - r, centre[2] - r], max: [centre[0] + r, centre[1] + r, centre[2] + r] }
}
Curve2Spec::Spline { control_points, .. } => {
let mut min = [f64::INFINITY; 3];
@@ -110,10 +99,7 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb {
}
Aabb { min, max }
}
Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb {
min: *origin,
max: *origin,
},
Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { min: *origin, max: *origin },
}
}
@@ -132,35 +118,6 @@ impl DocApiBackend for MockBackend {
}
Ok(())
}
fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> {
let mut next = self.clone();
let ids = specs
.iter()
.map(|spec| match spec {
ocs_doc_api::EntitySpec::Solid(spec) => {
next.store_solid(&ocs_doc_api::geom::make_solid(spec)?)
}
ocs_doc_api::EntitySpec::Curve(spec) => {
ocs_doc_api::convert::curve_spec_to_entity(spec)?;
next.add_curve(spec)
}
})
.collect::<ApiResult<Vec<_>>>()?;
next.push_undo("CreateMany");
next.finalize_op();
*self = next;
Ok(ids)
}
fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> {
let mut next = self.clone();
for &id in ids {
next.transform_entity(id, placement)?;
}
next.push_undo("TransformMany");
next.finalize_op();
*self = next;
Ok(())
}
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
let kind = match spec {
Curve2Spec::Line { .. } => "Line",
@@ -185,8 +142,7 @@ impl DocApiBackend for MockBackend {
}
fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Viewport");
self.viewport_views
.insert(id, (spec.view_target, spec.view_height));
self.viewport_views.insert(id, (spec.view_target, spec.view_height));
Ok(id)
}
fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> {
@@ -208,27 +164,17 @@ impl DocApiBackend for MockBackend {
}
}
fn text_content(&self, id: ObjectId) -> ApiResult<String> {
self.text_values
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Hatch");
self.hatch_boundaries
.insert(id, vec![spec.boundary.clone()]);
self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]);
Ok(id)
}
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> {
self.hatch_boundaries
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
self.hatch_boundaries.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn add_dimension_linear(
&mut self,
spec: &ocs_doc_api::ops::DimensionSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_linear(&mut self, spec: &ocs_doc_api::ops::DimensionSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
let d = ((spec.first_point[0] - spec.second_point[0]).powi(2)
+ (spec.first_point[1] - spec.second_point[1]).powi(2)
@@ -237,54 +183,30 @@ impl DocApiBackend for MockBackend {
self.dimension_measurements.insert(id, d);
Ok(id)
}
fn add_dimension_radius(
&mut self,
spec: &ocs_doc_api::ops::DimensionRadialSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_radius(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
let d = ((spec.center[0] - spec.point[0]).powi(2)
+ (spec.center[1] - spec.point[1]).powi(2)
+ (spec.center[2] - spec.point[2]).powi(2))
.sqrt();
let d = ((spec.center[0] - spec.point[0]).powi(2) + (spec.center[1] - spec.point[1]).powi(2) + (spec.center[2] - spec.point[2]).powi(2)).sqrt();
self.dimension_measurements.insert(id, d);
Ok(id)
}
fn add_dimension_diameter(
&mut self,
spec: &ocs_doc_api::ops::DimensionRadialSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_diameter(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult<ObjectId> {
self.add_dimension_radius(spec)
}
fn add_dimension_angular(
&mut self,
_spec: &ocs_doc_api::ops::DimensionAngularSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_angular(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
self.dimension_measurements.insert(id, 90.0);
Ok(id)
}
fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64> {
self.dimension_measurements
.get(&id)
.copied()
.ok_or(ApiError::UnknownId(id))
self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id))
}
fn add_attribute_definition(
&mut self,
spec: &ocs_doc_api::ops::AttributeDefinitionSpec,
) -> ApiResult<ObjectId> {
fn add_attribute_definition(&mut self, spec: &ocs_doc_api::ops::AttributeDefinitionSpec) -> ApiResult<ObjectId> {
if spec.tag.is_empty() {
return Err(ApiError::validation(
"CreateAttributeDefinition",
"empty tag",
));
return Err(ApiError::validation("CreateAttributeDefinition", "empty tag"));
}
Ok(self.alloc("AttributeDefinition"))
}
fn add_dimension_angular2ln(
&mut self,
_spec: &ocs_doc_api::ops::DimensionAngularSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_angular2ln(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
self.dimension_measurements.insert(id, 90.0);
Ok(id)
@@ -292,10 +214,7 @@ impl DocApiBackend for MockBackend {
fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult<ObjectId> {
let cols = spec.data.first().map(|r| r.len()).unwrap_or(0);
if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) {
return Err(ApiError::validation(
"CreateTable",
"table needs a non-empty rectangular grid",
));
return Err(ApiError::validation("CreateTable", "table needs a non-empty rectangular grid"));
}
Ok(self.alloc("Table"))
}
@@ -312,24 +231,15 @@ impl DocApiBackend for MockBackend {
Ok(())
}
fn attributes(&self, id: ObjectId) -> ApiResult<Vec<(String, String)>> {
self.attributes_store
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
self.attributes_store.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn block_entities(&self, _block_name: &str) -> ApiResult<Vec<EntityView>> {
Ok(Vec::new())
}
fn add_raster_image(
&mut self,
_spec: &ocs_doc_api::ops::RasterImageSpec,
) -> ApiResult<ObjectId> {
fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult<ObjectId> {
Ok(self.alloc("RasterImage"))
}
fn loft(
&mut self,
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
) -> ApiResult<ObjectId> {
fn loft(&mut self, sections: &[Vec<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId> {
if sections.len() < 2 {
return Err(ApiError::validation("Loft", "loft needs >= 2 profiles"));
}
@@ -337,12 +247,7 @@ impl DocApiBackend for MockBackend {
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?;
self.store_solid(&body)
}
fn set_viewport_view(
&mut self,
id: ObjectId,
view_target: [f64; 3],
view_height: f64,
) -> ApiResult<()> {
fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
@@ -350,10 +255,7 @@ impl DocApiBackend for MockBackend {
Ok(())
}
fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> {
self.viewport_views
.get(&id)
.copied()
.ok_or(ApiError::UnknownId(id))
self.viewport_views.get(&id).copied().ok_or(ApiError::UnknownId(id))
}
fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
if self.entity_exists(id) {
@@ -369,10 +271,7 @@ impl DocApiBackend for MockBackend {
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,
})
cadkernel::brep::body_bounds(b).map(|bb| Aabb { min: bb.min, max: bb.max })
});
Ok(EntityView { id, kind, bounds })
}
@@ -387,9 +286,8 @@ impl DocApiBackend for MockBackend {
z_axis: placement.z_axis,
origin: placement.origin,
};
let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| {
ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed")
})?;
let moved = cadkernel::brep::transform(&body, &place)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?;
self.bodies.insert(id, moved);
}
Ok(())
@@ -402,10 +300,7 @@ impl DocApiBackend for MockBackend {
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,
});
return Ok(Aabb { min: bb.min, max: bb.max });
}
if let Some(spec) = self.curves.get(&id) {
return Ok(curve_bounds(spec));
@@ -15,9 +15,7 @@ fn main() -> ocs_doc_api::ApiResult<()> {
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 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)?;
@@ -15,28 +15,19 @@ fn main() -> ocs_doc_api::ApiResult<()> {
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 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],
],
&[[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!("line bounds: {:?}", line.bounds()?); // [0,10] in X
println!("circle bounds: {:?}", circle.bounds()?); // centre ± radius
println!("poly bounds: {:?}", poly.bounds()?); // unit square
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 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);
@@ -8,7 +8,7 @@
mod common;
use ocs_doc_api::{ApiError, DocApi, HasId, ObjectId, OpGroup};
use ocs_doc_api::{ApiError, DocApi, HasId, OpGroup, ObjectId};
fn main() -> ocs_doc_api::ApiResult<()> {
let api = DocApi::in_process(common::MockBackend::default(), 0);
@@ -27,11 +27,7 @@ fn main() -> ocs_doc_api::ApiResult<()> {
}
// `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"
);
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();
+10 -53
View File
@@ -1,4 +1,4 @@
//! The thin host hook. The host implements this trait;
//! 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.
@@ -48,16 +48,6 @@ pub trait DocApiBackend {
/// Add a 2D curve entity; returns the fresh `ObjectId`.
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId>;
/// Prepare every entity before recording one undo step and committing the batch.
fn create_many(&mut self, specs: &[crate::ops::EntitySpec]) -> ApiResult<Vec<ObjectId>>;
/// Prepare all transformed geometry before committing any entity.
fn transform_many(
&mut self,
ids: &[ObjectId],
placement: &crate::PlacementSpec,
) -> ApiResult<()>;
/// Add a block reference (`INSERT`). Validates `block_name` exists; returns
/// the fresh `ObjectId`. `Validation` if the block is unknown.
fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult<ObjectId>;
@@ -87,37 +77,22 @@ pub trait DocApiBackend {
fn add_dimension_linear(&mut self, spec: &crate::ops::DimensionSpec) -> ApiResult<ObjectId>;
/// Add a radial `DIMENSION`; returns the fresh `ObjectId`.
fn add_dimension_radius(
&mut self,
spec: &crate::ops::DimensionRadialSpec,
) -> ApiResult<ObjectId>;
fn add_dimension_radius(&mut self, spec: &crate::ops::DimensionRadialSpec) -> ApiResult<ObjectId>;
/// Add a diameter `DIMENSION`; returns the fresh `ObjectId`.
fn add_dimension_diameter(
&mut self,
spec: &crate::ops::DimensionRadialSpec,
) -> ApiResult<ObjectId>;
fn add_dimension_diameter(&mut self, spec: &crate::ops::DimensionRadialSpec) -> ApiResult<ObjectId>;
/// Add a 3-point angular `DIMENSION`; returns the fresh `ObjectId`.
fn add_dimension_angular(
&mut self,
spec: &crate::ops::DimensionAngularSpec,
) -> ApiResult<ObjectId>;
fn add_dimension_angular(&mut self, spec: &crate::ops::DimensionAngularSpec) -> ApiResult<ObjectId>;
/// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`.
fn add_attribute_definition(
&mut self,
spec: &crate::ops::AttributeDefinitionSpec,
) -> ApiResult<ObjectId>;
fn add_attribute_definition(&mut self, spec: &crate::ops::AttributeDefinitionSpec) -> ApiResult<ObjectId>;
/// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`.
fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult<ObjectId>;
/// Add a 2-line angular `DIMENSION`; returns the fresh `ObjectId`.
fn add_dimension_angular2ln(
&mut self,
spec: &crate::ops::DimensionAngularSpec,
) -> ApiResult<ObjectId>;
fn add_dimension_angular2ln(&mut self, spec: &crate::ops::DimensionAngularSpec) -> ApiResult<ObjectId>;
/// A dimension's measured value (distance for linear/radius, degrees for angular).
fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64>;
@@ -132,12 +107,7 @@ pub trait DocApiBackend {
fn block_entities(&self, block_name: &str) -> ApiResult<Vec<EntityView>>;
/// Set a viewport's view target + zoom height in place. Viewport-only.
fn set_viewport_view(
&mut self,
id: ObjectId,
view_target: [f64; 3],
view_height: f64,
) -> ApiResult<()>;
fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()>;
/// A viewport's view (target WCS + zoom height). Viewport-only.
fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>;
@@ -147,10 +117,7 @@ pub trait DocApiBackend {
/// Loft a solid through >= 2 profile curve sets (each already resolved to
/// `geom2d::Curve`s on the XY plane). Returns the fresh `ObjectId`.
fn loft(
&mut self,
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
) -> ApiResult<ObjectId>;
fn loft(&mut self, sections: &[Vec<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId>;
/// Can `id` be modified in place right now (exists, is the expected family,
/// not on a locked layer)? Read-only pre-check used before mutations.
@@ -185,20 +152,12 @@ pub trait DocApiBackend {
/// sweep ops (`Extrude`/`Revolve`). `UnknownId`/`Unsupported` if not a profile.
fn profile_curves(&self, id: ObjectId) -> ApiResult<Vec<cadkernel::geom2d::Curve>>;
fn profile_plane(&self, _id: ObjectId) -> ApiResult<cadkernel::space::Plane> {
Ok(cadkernel::space::Plane::XY)
}
/// Apply a rigid similarity to any entity in place (same `ObjectId`).
fn transform_entity(
&mut self,
id: ObjectId,
placement: &crate::ops::PlacementSpec,
) -> ApiResult<()>;
fn transform_entity(&mut self, id: ObjectId, placement: &crate::ops::PlacementSpec) -> ApiResult<()>;
/// Validate that `id` exists and can be transformed, WITHOUT mutating.
/// Used by `TransformMany` to pre-validate all ids before `push_undo` so the
/// apply loop cannot fail part-way (all-or-nothing, the bulk contract). Default:
/// 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) {
@@ -225,6 +184,4 @@ pub trait DocApiBackend {
/// Called AFTER a successful write op (one DeltaSnapshot + one geometry bump
/// + one publish). NOT called on failure (a failed op records nothing).
fn finalize_op(&mut self);
/// Discard an undo capture after preparation or validation failed.
fn cancel_op(&mut self) {}
}
+325 -88
View File
@@ -1,11 +1,9 @@
//! Entity conversions: map the crate's plain-data DTOs to acadrust
//! Entity conversions (plan §5.1): map the crate's plain-data DTOs to acadrust
//! `EntityType` and back. Host feature only (uses the real acadrust types). This
//! module is host-side-capable but host-independent — it operates purely on
//! acadrust entities and crate DTOs, so any host can reuse it.
use acadrust::entities::{
Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine,
};
use acadrust::entities::{Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
@@ -32,23 +30,26 @@ pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult<Vec<cadkernel:
})],
EntityType::LwPolyline(pl) => {
if pl.vertices.len() < 2 {
return Err(ApiError::validation(
"profile",
"polyline profile needs at least two vertices",
));
return Err(ApiError::validation("profile", "polyline profile needs >= 2 vertices"));
}
KCurve::Polyline(cadkernel::geom2d::Polyline {
vertices: pl
.vertices
.iter()
.map(|v| cadkernel::geom2d::PolylineVertex {
position: [v.location.x, v.location.y],
bulge: v.bulge,
})
.collect(),
closed: pl.is_closed,
})
.segments()
// Convert each segment: straight for bulge==0, a circular arc for
// bulge!=0 (bulge = tan(included_angle/4)). This supports bulged
// polyline profiles for Extrude/Revolve instead of rejecting them.
let n = pl.vertices.len();
let count = if pl.is_closed { n } else { n - 1 };
let mut segs = Vec::with_capacity(count);
for i in 0..count {
let a = pl.vertices[i];
let b = pl.vertices[(i + 1) % n];
let start = [a.location.x, a.location.y];
let end = [b.location.x, b.location.y];
if a.bulge.abs() < 1e-12 {
segs.push(KCurve::Line(KLine { start, end }));
} else {
segs.push(bulge_arc_segment(start, end, a.bulge)?);
}
}
segs
}
_ => {
return Err(ApiError::Unsupported(
@@ -67,18 +68,126 @@ pub fn transform_entity_geometry(
entity: &EntityType,
p: &crate::PlacementSpec,
) -> ApiResult<EntityType> {
crate::geom::validate_placement(p)?;
let matrix = acadrust::types::Matrix4 {
m: [
[p.x_axis[0], p.y_axis[0], p.z_axis[0], p.origin[0]],
[p.x_axis[1], p.y_axis[1], p.z_axis[1], p.origin[1]],
[p.x_axis[2], p.y_axis[2], p.z_axis[2], p.origin[2]],
[0.0, 0.0, 0.0, 1.0],
],
// Placement = R·S·v + t where the axis COLUMNS carry rotation AND uniform
// scale (|x_axis| = s). A point maps v -> axes·v + origin (axes already embed
// s — do NOT pre-multiply by s or scale is applied twice). A direction maps
// v -> axes·v (no origin). Scalar fields (radius) scale by s = |x_axis|.
let s = (p.x_axis[0] * p.x_axis[0] + p.x_axis[1] * p.x_axis[1] + p.x_axis[2] * p.x_axis[2]).sqrt();
// Z-rotation of the placement in the XY plane (for arc angles / insert rotation).
let rot_z = p.x_axis[1].atan2(p.x_axis[0]);
let apply = |v: Vector3| -> Vector3 {
Vector3::new(
p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z + p.origin[0],
p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z + p.origin[1],
p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z + p.origin[2],
)
};
let mut moved = entity.clone();
moved.apply_transform(&acadrust::types::Transform::from_matrix(matrix));
Ok(moved)
// Direction/vector transform: rotation+scale only, NO translation.
let apply_dir = |v: Vector3| -> Vector3 {
Vector3::new(
p.x_axis[0] * v.x + p.y_axis[0] * v.y + p.z_axis[0] * v.z,
p.x_axis[1] * v.x + p.y_axis[1] * v.y + p.z_axis[1] * v.z,
p.x_axis[2] * v.x + p.y_axis[2] * v.y + p.z_axis[2] * v.z,
)
};
let entity = match entity {
EntityType::Line(l) => {
let mut l = l.clone();
l.start = apply(l.start);
l.end = apply(l.end);
EntityType::Line(l)
}
EntityType::Circle(c) => {
let mut c = c.clone();
c.center = apply(c.center);
c.radius *= s;
EntityType::Circle(c)
}
EntityType::Arc(a) => {
let mut a = a.clone();
a.center = apply(a.center);
a.radius *= s;
// A Z-rotation shifts both angles by the placement's XY rotation.
a.start_angle += rot_z;
a.end_angle += rot_z;
EntityType::Arc(a)
}
EntityType::Ellipse(e) => {
let mut e = e.clone();
e.center = apply(e.center);
// The major-axis is a direction+length vector: rotate+scale it (no origin).
e.major_axis = apply_dir(e.major_axis);
// A Z-rotation also shifts the partial-arc sweep (eccentric-anomaly params).
e.start_parameter += rot_z;
e.end_parameter += rot_z;
EntityType::Ellipse(e)
}
EntityType::Spline(sp) => {
let mut sp = sp.clone();
for cp in &mut sp.control_points {
*cp = apply(*cp);
}
for fp in &mut sp.fit_points {
*fp = apply(*fp);
}
EntityType::Spline(sp)
}
EntityType::Ray(r) => {
let mut r = r.clone();
r.base_point = apply(r.base_point);
r.direction = apply_dir(r.direction);
EntityType::Ray(r)
}
EntityType::XLine(x) => {
let mut x = x.clone();
x.base_point = apply(x.base_point);
x.direction = apply_dir(x.direction);
EntityType::XLine(x)
}
EntityType::Insert(ins) => {
let mut ins = ins.clone();
ins.insert_point = apply(ins.insert_point);
// Compose the block rotation with the placement's Z-rotation.
ins.rotation += rot_z;
EntityType::Insert(ins)
}
EntityType::Viewport(vp) => {
let mut vp = vp.clone();
vp.center = apply(vp.center);
EntityType::Viewport(vp)
}
EntityType::Text(t) => {
let mut t = t.clone();
t.insertion_point = apply(t.insertion_point);
EntityType::Text(t)
}
EntityType::MText(t) => {
let mut t = t.clone();
t.insertion_point = apply(t.insertion_point);
EntityType::MText(t)
}
EntityType::Point(pt) => {
let mut pt = pt.clone();
pt.location = apply(pt.location);
EntityType::Point(pt)
}
EntityType::LwPolyline(pl) => {
let mut pl = pl.clone();
let z0 = pl.elevation;
for v in &mut pl.vertices {
let t = apply(Vector3::new(v.location.x, v.location.y, z0));
v.location = Vector2::new(t.x, t.y);
pl.elevation = t.z;
}
EntityType::LwPolyline(pl)
}
_ => {
return Err(ApiError::Unsupported(
"transform is supported for Line/Circle/Arc/Point/LwPolyline (and solids) in v1".into(),
))
}
};
Ok(entity)
}
fn v3(p: [f64; 3]) -> Vector3 {
@@ -87,31 +196,41 @@ fn v3(p: [f64; 3]) -> Vector3 {
/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a
/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise.
pub fn bulge_arc_segment(
start: [f64; 2],
end: [f64; 2],
bulge: f64,
) -> ApiResult<cadkernel::geom2d::Curve> {
pub fn bulge_arc_segment(start: [f64; 2], end: [f64; 2], bulge: f64) -> ApiResult<cadkernel::geom2d::Curve> {
use cadkernel::geom2d::{Arc as KArc, Curve as KCurve};
let arc = cadkernel::geom2d::BulgeArc::from_bulge(start, end, bulge)
.ok_or_else(|| ApiError::validation("profile", "invalid bulge segment"))?;
// The kernel's Arc is counter-clockwise; reverse clockwise endpoints.
let (start_angle, end_angle) = if arc.sweep < 0.0 {
(arc.end_angle, arc.start_angle)
} else {
(arc.start_angle, arc.end_angle)
};
Ok(KCurve::Arc(KArc {
centre: arc.center,
radius: arc.radius,
start_angle,
end_angle,
}))
let chord_x = end[0] - start[0];
let chord_y = end[1] - start[1];
let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt();
if chord_len < 1e-12 {
return Err(ApiError::validation("profile", "bulge segment with coincident endpoints"));
}
// Included angle θ = 4·atan(bulge); radius = (chord/2) / sin(θ/2).
let theta = 4.0 * bulge.atan();
let half = theta / 2.0;
let sin_half = half.sin();
if sin_half.abs() < 1e-9 {
return Err(ApiError::validation("profile", "bulge segment with degenerate angle"));
}
let radius = (chord_len / 2.0) / sin_half.abs();
// Center: chord midpoint + perpendicular offset. The apothem MAGNITUDE is
// (chord/2)/|tan(θ/2)| (always positive); the SIGN of the bulge alone picks the
// side (CCW=+90° perp, CW=-90° perp). Using signed tan(half) AND the sign flip
// would double-count and mirror CW arcs to the wrong side.
let mid_x = (start[0] + end[0]) / 2.0;
let mid_y = (start[1] + end[1]) / 2.0;
let apothem = (chord_len / 2.0) / half.tan().abs();
let sign = if bulge >= 0.0 { 1.0 } else { -1.0 };
let perp_x = -chord_y / chord_len * sign;
let perp_y = chord_x / chord_len * sign;
let centre = [mid_x + perp_x * apothem, mid_y + perp_y * apothem];
// Start/end angles about the centre.
let start_angle = (start[1] - centre[1]).atan2(start[0] - centre[0]);
let end_angle = (end[1] - centre[1]).atan2(end[0] - centre[0]);
Ok(KCurve::Arc(KArc { centre, radius, start_angle, end_angle }))
}
/// Build an acadrust entity for a 2D curve construction spec.
pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
crate::validation::curve(spec)?;
let entity = match spec {
Curve2Spec::Line { start, end } => {
let mut e = Line::new();
@@ -137,13 +256,6 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
vertex_id: 0,
})
.collect();
e.elevation = points.first().map_or(0.0, |p| p[2]);
if points.iter().any(|p| p[2] != e.elevation) {
return Err(ApiError::validation(
"CreateCurve",
"polyline points must share an elevation",
));
}
e.is_closed = *closed;
EntityType::LwPolyline(e)
}
@@ -152,12 +264,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
e.location = v3(*position);
EntityType::Point(e)
}
Curve2Spec::Arc {
centre,
radius,
start_angle,
end_angle,
} => {
Curve2Spec::Arc { centre, radius, start_angle, end_angle } => {
let mut e = Arc::new();
e.center = v3(*centre);
e.radius = *radius;
@@ -165,13 +272,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
e.end_angle = *end_angle;
EntityType::Arc(e)
}
Curve2Spec::Ellipse {
centre,
major_axis,
ratio,
start,
end,
} => {
Curve2Spec::Ellipse { centre, major_axis, ratio, start, end } => {
// Validate the minor/major ratio so the (major-axis) bounds never
// under-bound: ratio must be in (0, 1].
if !(*ratio > 0.0 && *ratio <= 1.0) {
@@ -188,12 +289,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult<EntityType> {
e.end_parameter = *end;
EntityType::Ellipse(e)
}
Curve2Spec::Spline {
degree,
control_points,
knots,
weights,
} => {
Curve2Spec::Spline { degree, control_points, knots, weights } => {
let mut e = Spline::new();
e.degree = *degree;
e.control_points = control_points.iter().map(|p| v3(*p)).collect();
@@ -249,20 +345,161 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str {
}
}
/// Existing entity bounds; unbounded or empty geometry returns an error.
/// 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))?;
if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) {
return Err(ApiError::Unsupported("unbounded entity".into()));
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 }
}
EntityType::Arc(a) => {
// Coarse: full-circle bounds (exact arc bounds is a later refinement).
Aabb {
min: [a.center.x - a.radius, a.center.y - a.radius, a.center.z],
max: [a.center.x + a.radius, a.center.y + a.radius, a.center.z],
}
}
EntityType::Ellipse(e) => {
// Coarse: use the major-axis length as the bounding radius.
let r = (e.major_axis.x * e.major_axis.x
+ e.major_axis.y * e.major_axis.y
+ e.major_axis.z * e.major_axis.z)
.sqrt();
Aabb {
min: [e.center.x - r, e.center.y - r, e.center.z - r],
max: [e.center.x + r, e.center.y + r, e.center.z + r],
}
}
EntityType::Spline(s) => points_aabb(s.control_points.iter().collect(), id)?,
EntityType::Viewport(vp) => Aabb {
min: [
vp.center.x - vp.width / 2.0,
vp.center.y - vp.height / 2.0,
vp.center.z,
],
max: [
vp.center.x + vp.width / 2.0,
vp.center.y + vp.height / 2.0,
vp.center.z,
],
},
EntityType::RasterImage(img) => {
// Bracket ALL FOUR image corners (origin, +u*W, +v*H, +u*W+v*H) per
// axis so rotated/flipped images are not under-bounded.
let (w, h) = (img.size.x, img.size.y);
let ux = img.u_vector.x * w;
let uy = img.u_vector.y * w;
let vx = img.v_vector.x * h;
let vy = img.v_vector.y * h;
let x0 = img.insertion_point.x;
let y0 = img.insertion_point.y;
let xs = [x0, x0 + ux, x0 + vx, x0 + ux + vx];
let ys = [y0, y0 + uy, y0 + vy, y0 + uy + vy];
let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min);
let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min);
let max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
Aabb {
min: [min_x, min_y, img.insertion_point.z],
max: [max_x, max_y, img.insertion_point.z],
}
}
EntityType::Text(t) => Aabb {
// Coarse: a square of side height*N around the insertion point.
min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z],
max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z],
},
EntityType::MText(t) => Aabb {
min: [t.insertion_point.x, t.insertion_point.y - t.height, t.insertion_point.z],
max: [t.insertion_point.x + t.height * t.value.len() as f64, t.insertion_point.y, t.insertion_point.z],
},
EntityType::Dimension(acadrust::entities::Dimension::Linear(d)) => points_aabb(
[&d.first_point, &d.second_point, &d.definition_point].into_iter().collect(),
id,
)?,
EntityType::Hatch(h) => {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
let mut any = false;
for path in &h.paths {
for edge in &path.edges {
if let acadrust::entities::BoundaryEdge::Line(le) = edge {
for (x, y) in [(le.start.x, le.start.y), (le.end.x, le.end.y)] {
min = [min[0].min(x), min[1].min(y), min[2].min(0.0)];
max = [max[0].max(x), max[1].max(y), max[2].max(0.0)];
any = true;
}
}
}
}
if !any {
return Err(ApiError::Unsupported("hatch has no line-edge boundary".into()));
}
Aabb { min, max }
}
// Read-mostly families with vertex/point data: bounds from their points.
EntityType::Leader(l) => points_aabb(l.vertices.iter().collect(), id)?,
EntityType::Mesh(m) => points_aabb(m.vertices.iter().collect(), id)?,
EntityType::Face3D(f) => points_aabb(
[&f.first_corner, &f.second_corner, &f.third_corner, &f.fourth_corner].into_iter().collect(),
id,
)?,
EntityType::MLine(m) => points_aabb(m.vertices.iter().map(|v| &v.position).collect(), id)?,
EntityType::Helix(h) => {
// Bound the evaluated spline's control polygon (conservative); the
// single start_point alone would be a degenerate zero-size box.
if h.spline.control_points.is_empty() {
points_aabb([&h.start_point].into_iter().collect(), id)?
} else {
points_aabb(h.spline.control_points.iter().collect(), id)?
}
}
// Ray/XLine are unbounded by definition — no finite bounds.
_ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())),
};
Ok(bb)
}
/// AABB over a set of 3D points. A geometry-Empty error if there are no points
/// to bound (the entity exists — do NOT misclassify as a stale/deleted id).
fn points_aabb(pts: Vec<&Vector3>, _id: ObjectId) -> ApiResult<Aabb> {
if pts.is_empty() {
return Err(ApiError::geometry(GeometryErrorKind::Empty, "entity has no points to bound"));
}
let bounds = entity.as_entity().bounding_box();
let min = [bounds.min.x, bounds.min.y, bounds.min.z];
let max = [bounds.max.x, bounds.max.y, bounds.max.z];
if min.iter().chain(&max).any(|v| !v.is_finite()) {
return Err(ApiError::geometry(
GeometryErrorKind::Empty,
"entity has no finite bounds",
));
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for p in pts {
min = [min[0].min(p.x), min[1].min(p.y), min[2].min(p.z)];
max = [max[0].max(p.x), max[1].max(p.y), max[2].max(p.z)];
}
Ok(Aabb { min, max })
}
+3 -19
View File
@@ -1,4 +1,4 @@
//! The wire envelope. One request = ONE write op OR a query batch
//! 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};
@@ -18,27 +18,11 @@ pub struct DocApiEnvelope {
}
impl DocApiEnvelope {
pub fn validate_version(&self) -> crate::ApiResult<()> {
if self.version != ENVELOPE_VERSION {
return Err(crate::ApiError::Unsupported(format!(
"envelope version {}",
self.version
)));
}
Ok(())
}
pub fn op(operation: Operation) -> Self {
Self {
version: ENVELOPE_VERSION,
body: EnvelopeBody::Op(operation),
}
Self { version: ENVELOPE_VERSION, body: EnvelopeBody::Op(operation) }
}
pub fn queries(queries: Vec<Query>) -> Self {
Self {
version: ENVELOPE_VERSION,
body: EnvelopeBody::Queries(queries),
}
Self { version: ENVELOPE_VERSION, body: EnvelopeBody::Queries(queries) }
}
}
+4 -13
View File
@@ -1,4 +1,4 @@
//! Structured error model. Serializable so IPC returns the
//! Structured error model (plan §2 decision #9). Serializable so IPC returns the
//! same error the in-process executor produced.
use serde::{Deserialize, Serialize};
@@ -32,10 +32,7 @@ pub enum ApiError {
/// The geometry kernel reported a failure (a mapped `Snag`).
#[error("geometry kernel: {kind:?}: {msg}")]
Geometry {
kind: GeometryErrorKind,
msg: String,
},
Geometry { kind: GeometryErrorKind, msg: String },
/// An `ObjectId` does not resolve to a live entity (stale/deleted/never existed).
#[error("unknown ObjectId {0:?}")]
@@ -54,15 +51,9 @@ 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(),
}
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(),
}
ApiError::Geometry { kind, msg: msg.into() }
}
}
+82 -123
View File
@@ -1,4 +1,4 @@
//! The op executor: crate-owned logic mapping each
//! 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
@@ -12,32 +12,10 @@ use crate::id::ObjectId;
use crate::ops::{BoolOp, EntitySpec, BULK_ITEM_CAP};
use crate::query::QueryResult;
/// Apply ONE write op atomically. On success: one undo step and an advanced revision.
/// 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 result = apply_op_inner(b, op);
if result.is_err() {
b.cancel_op();
}
result
}
fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Receipt> {
let name = op.op_name();
crate::validation::operation(&op)?;
if let Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) = &op {
let unique: std::collections::HashSet<_> = ids.iter().collect();
if ids.is_empty() || unique.len() != ids.len() {
return Err(ApiError::validation(
name,
"batch ids must be nonempty and unique",
));
}
}
if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op
{
crate::geom::validate_placement(placement)?;
}
let outcome = match &op {
Operation::CreateSolid(prim) => {
let body = crate::geom::make_solid(prim)?;
@@ -54,21 +32,16 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
}
Operation::Extrude { profile, direction } => {
let curves = profile_curves(b, *profile, name)?;
let body = crate::geom::extrude(b.profile_plane(*profile)?, &curves, *direction)?;
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,
} => {
Operation::Revolve { profile, axis, angle } => {
let curves = profile_curves(b, *profile, name)?;
let (pivot, axis_dir) = *axis;
let body =
crate::geom::revolve(b.profile_plane(*profile)?, &curves, pivot, axis_dir, *angle)?;
let body = crate::geom::revolve(&curves, pivot, axis_dir, *angle)?;
b.push_undo(name);
let id = b.store_solid(&body)?;
b.finalize_op();
@@ -81,26 +54,15 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
b.finalize_op();
OpOutcome::Updated(*id)
}
Operation::SolidBoolean {
op: bop,
a,
b: bid,
erase_sources,
} => {
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 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.
// already committed a partial mutation (plan review: erase path).
if *erase_sources {
if a == bid {
return Err(ApiError::validation(name, "boolean source ids must differ"));
}
b.can_modify(*a)?;
b.can_remove(*bid)?;
}
b.push_undo(name);
@@ -126,11 +88,9 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
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.
// 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:?}"))
}
ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")),
other => other,
})?;
b.push_undo(name);
@@ -142,15 +102,33 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
if specs.len() > BULK_ITEM_CAP {
return Err(over_cap(name, specs.len()));
}
if specs.is_empty() {
return Err(ApiError::validation(name, "batch must not be empty"));
// Upfront validation: compute/validate EVERY payload before any insert
// (all-or-nothing without rollback, plan §5.3). This includes validating
// the curve spec's fallible conditions (e.g. ellipse ratio) that
// `curve_spec_to_entity` would otherwise reject mid-apply-loop.
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) => {
validate_curve_spec(c).map_err(|e| at_index(name, i, e))?;
Prepared::Curve(c.clone())
}
};
prepared.push(prep);
}
for spec in specs {
if let EntitySpec::Curve(curve) = spec {
crate::validation::curve(curve)?;
}
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);
}
let ids = b.create_many(specs)?;
b.finalize_op();
OpOutcome::NewIds(ids)
}
Operation::TransformMany { ids, placement } => {
@@ -158,7 +136,7 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
return Err(over_cap(name, ids.len()));
}
// Pre-validate EVERYTHING fallible before push_undo (all-or-nothing,
// the bulk contract): every id must exist AND be transformable, so the apply
// 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() {
@@ -173,7 +151,11 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
},
})?;
}
b.transform_many(ids, placement)?;
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) => {
@@ -226,9 +208,7 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
}
Operation::SetTextContent { id, value } => {
b.can_modify(*id).map_err(|e| match e {
ApiError::UnknownId(_) => {
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
}
ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")),
other => other,
})?;
b.push_undo(name);
@@ -239,10 +219,7 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
Operation::CreateHatch(spec) => {
// Validate the boundary (>= 3 points) before committing.
if spec.boundary.len() < 3 {
return Err(ApiError::validation(
name,
"hatch boundary needs >= 3 points",
));
return Err(ApiError::validation(name, "hatch boundary needs >= 3 points"));
}
b.push_undo(name);
let id = b.add_hatch(spec)?;
@@ -257,9 +234,7 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
}
Operation::SetAttribute { id, tag, value } => {
b.can_modify(*id).map_err(|e| match e {
ApiError::UnknownId(_) => {
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
}
ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")),
other => other,
})?;
b.push_undo(name);
@@ -267,15 +242,9 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
b.finalize_op();
OpOutcome::Updated(*id)
}
Operation::SetViewportView {
id,
view_target,
view_height,
} => {
Operation::SetViewportView { id, view_target, view_height } => {
b.can_modify(*id).map_err(|e| match e {
ApiError::UnknownId(_) => {
ApiError::validation(name, format!("unknown ObjectId {id:?}"))
}
ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")),
other => other,
})?;
b.push_undo(name);
@@ -301,8 +270,9 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
}
let mut sections = Vec::with_capacity(profiles.len());
for (i, p) in profiles.iter().enumerate() {
let curves = profile_curves(b, *p, name).map_err(|e| at_index(name, i, e))?;
sections.push((b.profile_plane(*p)?, curves));
let curves = profile_curves(b, *p, name)
.map_err(|e| at_index(name, i, e))?;
sections.push(curves);
}
b.push_undo(name);
let id = b.loft(&sections)?;
@@ -337,10 +307,7 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
// Validate a non-empty, rectangular grid before committing.
let cols = spec.data.first().map(|r| r.len()).unwrap_or(0);
if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) {
return Err(ApiError::validation(
name,
"table needs a non-empty rectangular grid",
));
return Err(ApiError::validation(name, "table needs a non-empty rectangular grid"));
}
b.push_undo(name);
let id = b.add_table(spec)?;
@@ -361,6 +328,11 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
})
}
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
@@ -369,50 +341,28 @@ pub fn apply_queries<B: DocApiBackend>(b: &mut B, queries: Vec<Query>) -> ApiRes
if queries.len() > BULK_ITEM_CAP {
return Err(ApiError::validation(
"Queries",
format!(
"query batch over cap: {} > {}",
queries.len(),
BULK_ITEM_CAP
),
format!("query batch over cap: {} > {}", queries.len(), BULK_ITEM_CAP),
));
}
let mut results = Vec::with_capacity(queries.len());
for q in &queries {
let qname = q.query_name();
let r = match q {
Query::GetEntity { id } => {
QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?)
}
Query::GetBounds { id } => {
QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?)
}
Query::GetCentroid { id } => {
QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?)
}
Query::GetVolume { id } => {
QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?)
}
Query::GetEntity { id } => QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?),
Query::GetBounds { id } => QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?),
Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?),
Query::GetVolume { id } => QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?),
Query::GetIntersects { a, b: bid } => {
let ba = b.bounds(*a).map_err(|e| label(qname, e))?;
let bb = b.bounds(*bid).map_err(|e| label(qname, e))?;
QueryResult::Intersects(ba.overlaps(&bb))
}
Query::GetGeometryRevision => QueryResult::Revision(b.revision()),
Query::GetTextContent { id } => {
QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?)
}
Query::GetHatchBoundary { id } => {
QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?)
}
Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(
b.dimension_measurement(*id).map_err(|e| label(qname, e))?,
),
Query::GetAttributes { id } => {
QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?)
}
Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(
b.block_entities(block_name).map_err(|e| label(qname, e))?,
),
Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?),
Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?),
Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(*id).map_err(|e| label(qname, e))?),
Query::GetAttributes { id } => QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?),
Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(b.block_entities(block_name).map_err(|e| label(qname, e))?),
Query::GetViewportView { id } => {
let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?;
QueryResult::ViewportView { target, height }
@@ -442,15 +392,26 @@ fn profile_curves<B: DocApiBackend>(
op: &'static str,
) -> ApiResult<Vec<cadkernel::geom2d::Curve>> {
if !b.entity_exists(profile) {
return Err(ApiError::validation(
op,
format!("unknown ObjectId {profile:?}"),
));
return Err(ApiError::validation(op, format!("unknown ObjectId {profile:?}")));
}
b.profile_curves(profile)
}
/// Validate that an entity exists before performing an operation.
/// Validate a `Curve2Spec`'s fallible conditions BEFORE mutation (the same rules
/// the host's `curve_spec_to_entity` enforces), so `CreateMany` stays all-or-nothing.
/// Currently: ellipse minor/major ratio must be in (0, 1].
fn validate_curve_spec(spec: &crate::ops::Curve2Spec) -> ApiResult<()> {
if let crate::ops::Curve2Spec::Ellipse { ratio, .. } = spec {
if !(*ratio > 0.0 && *ratio <= 1.0) {
return Err(ApiError::validation(
"CreateMany",
format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"),
));
}
}
Ok(())
}
fn require_exists<B: DocApiBackend>(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> {
if b.entity_exists(id) {
Ok(())
@@ -469,9 +430,7 @@ fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError {
fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError {
match e {
ApiError::Validation { reason, .. } => {
ApiError::validation(op, format!("index {i}: {reason}"))
}
ApiError::Validation { reason, .. } => ApiError::validation(op, format!("index {i}: {reason}")),
other => other,
}
}
+133 -465
View File
@@ -1,4 +1,4 @@
//! The object-centric facade.
//! The object-centric facade (plan §8.1, decision #12).
//!
//! Hierarchy: [`DocApi`] (root: session + transport) → [`Document`] →
//! collections ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`])
@@ -24,36 +24,13 @@ use crate::transport::Transport;
#[derive(Clone)]
pub struct Session {
transport: Arc<dyn Transport>,
valid_tab: bool,
}
impl Session {
fn validate_tab(&self) -> ApiResult<()> {
if !self.valid_tab {
return Err(ApiError::validation(
"document",
"transport is bound to another tab",
));
}
Ok(())
}
fn same_transport(&self, other: &Session) -> ApiResult<()> {
self.validate_tab()?;
other.validate_tab()?;
if !Arc::ptr_eq(&self.transport, &other.transport) {
return Err(ApiError::validation(
"document",
"handles belong to different sessions",
));
}
Ok(())
}
fn apply_op(&self, op: Operation) -> ApiResult<Receipt> {
self.validate_tab()?;
self.transport.apply(DocApiEnvelope::op(op))
}
fn apply_queries(&self, queries: Vec<Query>) -> ApiResult<Receipt> {
self.validate_tab()?;
self.transport.apply(DocApiEnvelope::queries(queries))
}
fn one_query(&self, q: Query) -> ApiResult<QueryResult> {
@@ -73,10 +50,7 @@ pub struct DocApi {
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,
}
Self { transport, active_tab }
}
/// Connect in-process over a backend (host feature).
#[cfg(feature = "host")]
@@ -84,10 +58,7 @@ impl DocApi {
backend: B,
active_tab: u64,
) -> Self {
Self::connect(
Arc::new(crate::transport::InProcess::new(backend)),
active_tab,
)
Self::connect(Arc::new(crate::transport::InProcess::new(backend)), active_tab)
}
/// The active tab id this root is bound to.
@@ -100,15 +71,22 @@ impl DocApi {
self.transport.alive()
}
/// Bind a document to the transport's tab. Requests for another tab fail.
pub fn document(&self, tab: u64) -> Document {
/// 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),
valid_tab: tab == self.active_tab,
},
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.
@@ -119,19 +97,13 @@ pub struct Document {
impl Document {
pub fn solids(&self) -> SolidCollection {
SolidCollection {
session: self.session.clone(),
}
SolidCollection { session: self.session.clone() }
}
pub fn curves(&self) -> CurveCollection {
CurveCollection {
session: self.session.clone(),
}
CurveCollection { session: self.session.clone() }
}
pub fn entities(&self) -> EntityCollection {
EntityCollection {
session: self.session.clone(),
}
EntityCollection { session: self.session.clone() }
}
/// Current geometry revision (query, no bump).
@@ -151,22 +123,16 @@ impl Document {
} else {
Err(ApiError::Validation {
op: "assert_revision".to_string(),
reason: format!(
"revision moved: expected {expected:?}, now {now:?}; re-read and retry"
),
reason: format!("revision moved: expected {expected:?}, now {now:?}; re-read and retry"),
})
}
}
/// Read-only traversal of a block definition's entities (ids + kinds + bounds).
pub fn block_entities(&self, block_name: &str) -> ApiResult<Vec<EntityView>> {
match self.session.one_query(Query::GetBlockEntities {
block_name: block_name.to_string(),
})? {
match self.session.one_query(Query::GetBlockEntities { block_name: block_name.to_string() })? {
QueryResult::BlockEntities(v) => Ok(v),
_ => Err(ApiError::Transport(
"unexpected block-entities result".into(),
)),
_ => Err(ApiError::Transport("unexpected block-entities result".into())),
}
}
@@ -174,54 +140,30 @@ impl Document {
/// 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(),
session: self.session.clone(),
error: None,
};
let mut qb = QueryBatch { queries: Vec::new() };
f(&mut qb);
if let Some(error) = qb.error {
return Err(error);
}
let receipt = self.session.apply_queries(qb.queries)?;
Ok(QueryResults {
results: receipt.query_results,
})
Ok(QueryResults { results: receipt.query_results })
}
}
/// Records queries for [`Document::query_batch`]. Each method appends one query.
pub struct QueryBatch {
queries: Vec<Query>,
session: Session,
error: Option<ApiError>,
}
impl QueryBatch {
fn check(&mut self, e: &impl HasId) {
if let Err(error) = e.validate_session(&self.session) {
self.error = Some(error);
}
}
pub fn bounds(&mut self, e: &impl HasId) {
self.check(e);
self.queries.push(Query::GetBounds { id: e.id() });
}
pub fn volume(&mut self, e: &impl HasId) {
self.check(e);
self.queries.push(Query::GetVolume { id: e.id() });
}
pub fn centroid(&mut self, e: &impl HasId) {
self.check(e);
self.queries.push(Query::GetCentroid { id: e.id() });
}
pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) {
self.check(a);
self.check(b);
self.queries.push(Query::GetIntersects {
a: a.id(),
b: b.id(),
});
self.queries.push(Query::GetIntersects { a: a.id(), b: b.id() });
}
pub fn revision(&mut self) {
self.queries.push(Query::GetGeometryRevision);
@@ -235,9 +177,7 @@ pub struct QueryResults {
impl QueryResults {
fn at(&self, i: usize) -> ApiResult<&QueryResult> {
self.results
.get(i)
.ok_or_else(|| ApiError::Transport("query result missing".into()))
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)? {
@@ -274,16 +214,6 @@ impl QueryResults {
/// Something with an `ObjectId` (all handles + raw ids).
pub trait HasId {
fn id(&self) -> ObjectId;
/// Typed handles retain their session; raw ids are relative to the receiver.
fn session(&self) -> Option<&Session> {
None
}
fn validate_session(&self, target: &Session) -> ApiResult<()> {
if let Some(session) = self.session() {
session.same_transport(target)?;
}
Ok(())
}
}
impl HasId for ObjectId {
fn id(&self) -> ObjectId {
@@ -311,9 +241,6 @@ macro_rules! handle {
}
}
impl HasId for $name {
fn session(&self) -> Option<&Session> {
Some(&self.session)
}
fn id(&self) -> ObjectId {
self.id
}
@@ -330,10 +257,7 @@ macro_rules! handle {
Ok(())
}
pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> {
self.session.apply_op(Operation::Transform {
id: self.id,
placement,
})?;
self.session.apply_op(Operation::Transform { id: self.id, placement })?;
Ok(())
}
}
@@ -359,10 +283,7 @@ impl Dimension {
/// The measured value of this dimension (distance for linear/radius, degrees
/// for angular).
pub fn measurement(&self) -> ApiResult<f64> {
match self
.session
.one_query(Query::GetDimensionMeasurement { id: self.id })?
{
match self.session.one_query(Query::GetDimensionMeasurement { id: self.id })? {
QueryResult::DimensionMeasurement(v) => Ok(v),
_ => Err(ApiError::Transport("unexpected measurement result".into())),
}
@@ -378,54 +299,36 @@ impl Entity {
}
/// This insert's attributes as (tag, value) pairs. Insert-only.
pub fn attributes(&self) -> ApiResult<Vec<(String, String)>> {
match self
.session
.one_query(Query::GetAttributes { id: self.id })?
{
match self.session.one_query(Query::GetAttributes { id: self.id })? {
QueryResult::Attributes(v) => Ok(v),
_ => Err(ApiError::Transport("unexpected attributes result".into())),
}
}
/// Set an attribute `value` for `tag` on this insert (adds if absent). One undo step.
pub fn set_attribute(&self, tag: &str, value: &str) -> ApiResult<()> {
self.session.apply_op(Operation::SetAttribute {
id: self.id,
tag: tag.to_string(),
value: value.to_string(),
})?;
self.session.apply_op(Operation::SetAttribute { id: self.id, tag: tag.to_string(), value: value.to_string() })?;
Ok(())
}
/// This viewport's view (target WCS + zoom height). Viewport-only.
pub fn viewport_view(&self) -> ApiResult<([f64; 3], f64)> {
match self
.session
.one_query(Query::GetViewportView { id: self.id })?
{
match self.session.one_query(Query::GetViewportView { id: self.id })? {
QueryResult::ViewportView { target, height } => Ok((target, height)),
_ => Err(ApiError::Transport(
"unexpected viewport-view result".into(),
)),
_ => Err(ApiError::Transport("unexpected viewport-view result".into())),
}
}
/// Retarget / re-zoom this viewport (one undo step). Viewport-only.
pub fn set_view(&self, view_target: [f64; 3], view_height: f64) -> ApiResult<()> {
self.session.apply_op(Operation::SetViewportView {
id: self.id,
view_target,
view_height,
})?;
self.session.apply_op(Operation::SetViewportView { id: self.id, view_target, view_height })?;
Ok(())
}
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))
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> {
self.session.same_transport(&other.session)?;
let receipt = self.session.apply_op(Operation::SolidBoolean {
op,
a: self.id,
@@ -460,11 +363,7 @@ impl Solid {
}
}
pub fn intersects(&self, other: &Solid) -> ApiResult<bool> {
self.session.same_transport(&other.session)?;
match self.session.one_query(Query::GetIntersects {
a: self.id,
b: other.id,
})? {
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())),
}
@@ -473,11 +372,7 @@ impl Solid {
impl Polyline {
pub fn add_vertex(&self, at: usize, point: [f64; 3]) -> ApiResult<()> {
self.session.apply_op(Operation::AddVertex {
id: self.id,
at,
point,
})?;
self.session.apply_op(Operation::AddVertex { id: self.id, at, point })?;
Ok(())
}
}
@@ -487,20 +382,14 @@ macro_rules! text_handle {
impl $name {
/// The text content of this annotation.
pub fn content(&self) -> ApiResult<String> {
match self
.session
.one_query(Query::GetTextContent { id: self.id })?
{
match self.session.one_query(Query::GetTextContent { id: self.id })? {
QueryResult::TextContent(s) => Ok(s),
_ => Err(ApiError::Transport("unexpected content result".into())),
}
}
/// Replace the text content (one undo step).
pub fn set_content(&self, value: &str) -> ApiResult<()> {
self.session.apply_op(Operation::SetTextContent {
id: self.id,
value: value.to_string(),
})?;
self.session.apply_op(Operation::SetTextContent { id: self.id, value: value.to_string() })?;
Ok(())
}
}
@@ -532,54 +421,26 @@ impl SolidCollection {
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,
})
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,
})
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_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> {
profile.validate_session(&self.session)?;
let receipt = self.session.apply_op(Operation::Extrude {
profile: profile.id(),
direction,
})?;
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> {
profile.validate_session(&self.session)?;
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),
@@ -593,15 +454,9 @@ impl SolidCollection {
}
/// Loft a solid through >= 2 profile entities (polylines/circles/arcs).
pub fn loft(&self, profiles: &[impl HasId]) -> ApiResult<Solid> {
for profile in profiles {
profile.validate_session(&self.session)?;
}
let ids: Vec<ObjectId> = profiles.iter().map(|p| p.id()).collect();
let receipt = self.session.apply_op(Operation::Loft { profiles: ids })?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("loft returned no id".into()))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("loft returned no id".into()))?;
Ok(Solid::new(self.session.clone(), id))
}
}
@@ -629,55 +484,22 @@ impl CurveCollection {
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,
})?;
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))
}
pub fn create_arc(
&self,
centre: [f64; 3],
radius: f64,
start_angle: f64,
end_angle: f64,
) -> ApiResult<ArcCurve> {
let (s, id) = self.create_curve(Curve2Spec::Arc {
centre,
radius,
start_angle,
end_angle,
})?;
pub fn create_arc(&self, centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64) -> ApiResult<ArcCurve> {
let (s, id) = self.create_curve(Curve2Spec::Arc { centre, radius, start_angle, end_angle })?;
Ok(ArcCurve::new(s, id))
}
pub fn create_ellipse(
&self,
centre: [f64; 3],
major_axis: [f64; 3],
ratio: f64,
start: f64,
end: f64,
) -> ApiResult<Ellipse> {
let (s, id) = self.create_curve(Curve2Spec::Ellipse {
centre,
major_axis,
ratio,
start,
end,
})?;
pub fn create_ellipse(&self, centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64) -> ApiResult<Ellipse> {
let (s, id) = self.create_curve(Curve2Spec::Ellipse { centre, major_axis, ratio, start, end })?;
Ok(Ellipse::new(s, id))
}
pub fn create_spline(
&self,
degree: i32,
control_points: &[[f64; 3]],
knots: &[f64],
weights: &[f64],
) -> ApiResult<Spline> {
pub fn create_spline(&self, degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]) -> ApiResult<Spline> {
let (s, id) = self.create_curve(Curve2Spec::Spline {
degree,
control_points: control_points.to_vec(),
@@ -695,81 +517,42 @@ impl CurveCollection {
Ok(XLine::new(s, id))
}
/// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition).
pub fn create_raster_image(
&self,
file_path: &str,
insertion_point: [f64; 3],
u_vector: [f64; 3],
v_vector: [f64; 3],
size: [f64; 2],
) -> ApiResult<Entity> {
let receipt =
self.session
.apply_op(Operation::CreateRasterImage(crate::ops::RasterImageSpec {
file_path: file_path.to_string(),
insertion_point,
u_vector,
v_vector,
size,
}))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_raster_image returned no id".into()))?;
pub fn create_raster_image(&self, file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]) -> ApiResult<Entity> {
let receipt = self.session.apply_op(Operation::CreateRasterImage(crate::ops::RasterImageSpec {
file_path: file_path.to_string(),
insertion_point,
u_vector,
v_vector,
size,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_raster_image returned no id".into()))?;
Ok(Entity::new(self.session.clone(), id))
}
/// A solid (or pattern) HATCH over a single closed polyline boundary.
pub fn create_hatch(&self, boundary: &[[f64; 2]], solid: bool) -> ApiResult<Entity> {
let receipt = self
.session
.apply_op(Operation::CreateHatch(crate::ops::HatchSpec {
boundary: boundary.to_vec(),
solid,
}))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_hatch returned no id".into()))?;
let receipt = self.session.apply_op(Operation::CreateHatch(crate::ops::HatchSpec {
boundary: boundary.to_vec(),
solid,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_hatch returned no id".into()))?;
Ok(Entity::new(self.session.clone(), id))
}
/// A 2-line angular DIMENSION: angle between lines (vertex→first) and (vertex→second).
pub fn create_dimension_angular2ln(
&self,
vertex: [f64; 3],
first_point: [f64; 3],
second_point: [f64; 3],
arc_location: [f64; 3],
) -> ApiResult<Dimension> {
let receipt = self.session.apply_op(Operation::CreateDimensionAngular2Ln(
crate::ops::DimensionAngularSpec {
vertex,
first_point,
second_point,
arc_location,
},
))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
pub fn create_dimension_angular2ln(&self, vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> ApiResult<Dimension> {
let receipt = self.session.apply_op(Operation::CreateDimensionAngular2Ln(crate::ops::DimensionAngularSpec {
vertex, first_point, second_point, arc_location,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
Ok(Dimension::new(self.session.clone(), id))
}
/// A radial DIMENSION for the circle centered at `center` through `point`.
pub fn create_dimension_radius(
&self,
center: [f64; 3],
point: [f64; 3],
) -> ApiResult<Dimension> {
pub fn create_dimension_radius(&self, center: [f64; 3], point: [f64; 3]) -> ApiResult<Dimension> {
self.create_dim_radial(Operation::CreateDimensionRadius, center, point)
}
/// A diameter DIMENSION for the circle with chord points `center`/`point`.
pub fn create_dimension_diameter(
&self,
center: [f64; 3],
point: [f64; 3],
) -> ApiResult<Dimension> {
pub fn create_dimension_diameter(&self, center: [f64; 3], point: [f64; 3]) -> ApiResult<Dimension> {
self.create_dim_radial(Operation::CreateDimensionDiameter, center, point)
}
fn create_dim_radial(
@@ -778,111 +561,57 @@ impl CurveCollection {
center: [f64; 3],
point: [f64; 3],
) -> ApiResult<Dimension> {
let receipt = self
.session
.apply_op(op(crate::ops::DimensionRadialSpec { center, point }))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
let receipt = self.session.apply_op(op(crate::ops::DimensionRadialSpec { center, point }))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
Ok(Dimension::new(self.session.clone(), id))
}
/// A 3-point angular DIMENSION (vertex + one point on each leg).
pub fn create_dimension_angular(
&self,
vertex: [f64; 3],
first_point: [f64; 3],
second_point: [f64; 3],
arc_location: [f64; 3],
) -> ApiResult<Dimension> {
let receipt = self.session.apply_op(Operation::CreateDimensionAngular(
crate::ops::DimensionAngularSpec {
vertex,
first_point,
second_point,
arc_location,
},
))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
pub fn create_dimension_angular(&self, vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> ApiResult<Dimension> {
let receipt = self.session.apply_op(Operation::CreateDimensionAngular(crate::ops::DimensionAngularSpec {
vertex, first_point, second_point, arc_location,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
Ok(Dimension::new(self.session.clone(), id))
}
/// A linear DIMENSION between `first_point` and `second_point`, with the
/// dimension line placed at `definition_point`. `measurement()` reads the value.
pub fn create_dimension_linear(
&self,
first_point: [f64; 3],
second_point: [f64; 3],
definition_point: [f64; 3],
) -> ApiResult<Dimension> {
let receipt = self.session.apply_op(Operation::CreateDimensionLinear(
crate::ops::DimensionSpec {
first_point,
second_point,
definition_point,
},
))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
pub fn create_dimension_linear(&self, first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]) -> ApiResult<Dimension> {
let receipt = self.session.apply_op(Operation::CreateDimensionLinear(crate::ops::DimensionSpec {
first_point, second_point, definition_point,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?;
Ok(Dimension::new(self.session.clone(), id))
}
/// Single-line TEXT annotation.
pub fn create_text(
&self,
value: &str,
insertion_point: [f64; 3],
height: f64,
rotation: f64,
) -> ApiResult<Text> {
let receipt = self
.session
.apply_op(Operation::CreateText(crate::ops::TextSpec {
value: value.to_string(),
insertion_point,
height,
rotation,
}))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_text returned no id".into()))?;
pub fn create_text(&self, value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> ApiResult<Text> {
let receipt = self.session.apply_op(Operation::CreateText(crate::ops::TextSpec {
value: value.to_string(),
insertion_point,
height,
rotation,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_text returned no id".into()))?;
Ok(Text::new(self.session.clone(), id))
}
/// Multi-line MTEXT annotation.
pub fn create_mtext(
&self,
value: &str,
insertion_point: [f64; 3],
height: f64,
) -> ApiResult<MText> {
let receipt = self
.session
.apply_op(Operation::CreateMText(crate::ops::MTextSpec {
value: value.to_string(),
insertion_point,
height,
}))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_mtext returned no id".into()))?;
pub fn create_mtext(&self, value: &str, insertion_point: [f64; 3], height: f64) -> ApiResult<MText> {
let receipt = self.session.apply_op(Operation::CreateMText(crate::ops::MTextSpec {
value: value.to_string(),
insertion_point,
height,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_mtext returned no id".into()))?;
Ok(MText::new(self.session.clone(), id))
}
/// Bulk-create many points in ONE op: all-or-nothing, one undo step.
/// 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()))?;
let outcome = receipt.outcome.ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?;
Ok(outcome
.new_ids()
.iter()
@@ -899,23 +628,10 @@ pub struct EntityCollection {
impl EntityCollection {
/// Create a paper-space `VIEWPORT` (a `width`×`height` viewport at `center`
/// looking at `view_target` with `view_height` zoom).
pub fn create_viewport(
&self,
center: [f64; 3],
width: f64,
height: f64,
view_target: [f64; 3],
view_height: f64,
) -> ApiResult<Entity> {
let receipt =
self.session
.apply_op(Operation::CreateViewport(crate::ops::ViewportSpec {
center,
width,
height,
view_target,
view_height,
}))?;
pub fn create_viewport(&self, center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64) -> ApiResult<Entity> {
let receipt = self.session.apply_op(Operation::CreateViewport(crate::ops::ViewportSpec {
center, width, height, view_target, view_height,
}))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
@@ -924,66 +640,37 @@ impl EntityCollection {
}
/// Create a TABLE from a grid of cell strings (`data[row][column]`), at a point.
pub fn create_table(
&self,
insertion_point: [f64; 3],
data: &[Vec<String>],
) -> ApiResult<Entity> {
let receipt = self
.session
.apply_op(Operation::CreateTable(crate::ops::TableSpec {
insertion_point,
data: data.to_vec(),
}))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_table returned no id".into()))?;
pub fn create_table(&self, insertion_point: [f64; 3], data: &[Vec<String>]) -> ApiResult<Entity> {
let receipt = self.session.apply_op(Operation::CreateTable(crate::ops::TableSpec {
insertion_point,
data: data.to_vec(),
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_table returned no id".into()))?;
Ok(Entity::new(self.session.clone(), id))
}
/// Create an ATTDEF (in-block attribute definition): tag/prompt/default at a point.
pub fn create_attribute_definition(
&self,
tag: &str,
prompt: &str,
default_value: &str,
insertion_point: [f64; 3],
height: f64,
rotation: f64,
) -> ApiResult<Entity> {
let receipt = self.session.apply_op(Operation::CreateAttributeDefinition(
crate::ops::AttributeDefinitionSpec {
tag: tag.to_string(),
prompt: prompt.to_string(),
default_value: default_value.to_string(),
insertion_point,
height,
rotation,
},
))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| {
ApiError::Transport("create_attribute_definition returned no id".into())
})?;
pub fn create_attribute_definition(&self, tag: &str, prompt: &str, default_value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> ApiResult<Entity> {
let receipt = self.session.apply_op(Operation::CreateAttributeDefinition(crate::ops::AttributeDefinitionSpec {
tag: tag.to_string(),
prompt: prompt.to_string(),
default_value: default_value.to_string(),
insertion_point,
height,
rotation,
}))?;
let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| ApiError::Transport("create_attribute_definition returned no id".into()))?;
Ok(Entity::new(self.session.clone(), id))
}
/// Place a block reference (`INSERT`) for `block_name` (must exist).
pub fn create_insert(
&self,
block_name: &str,
insert_point: [f64; 3],
scale: f64,
rotation: f64,
) -> ApiResult<Entity> {
let receipt = self
.session
.apply_op(Operation::CreateInsert(crate::ops::InsertSpec {
block_name: block_name.to_string(),
insert_point,
scale,
rotation,
}))?;
pub fn create_insert(&self, block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> ApiResult<Entity> {
let receipt = self.session.apply_op(Operation::CreateInsert(crate::ops::InsertSpec {
block_name: block_name.to_string(),
insert_point,
scale,
rotation,
}))?;
let id = receipt
.outcome
.and_then(|o| o.new_id())
@@ -1003,10 +690,7 @@ impl EntityCollection {
}
/// 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,
})?;
self.session.apply_op(Operation::TransformMany { ids: ids.to_vec(), placement })?;
Ok(())
}
/// Bulk delete (one op, all-or-nothing). Used by `OpGroup::compensate`.
@@ -1019,45 +703,29 @@ impl EntityCollection {
// ── OpGroup: client-side failure-cleanup (NOT a transaction) ────────────────
/// Tracks handles created during a logical operation so a later failure can be
/// cleaned up with a bulk delete. Separate writes remain committed;
/// 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>,
session: Option<Session>,
}
impl OpGroup {
pub fn new() -> Self {
Self {
created: Vec::new(),
session: None,
}
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?;
if let Some(session) = handle.session() {
if let Some(previous) = &self.session {
previous.same_transport(session)?;
} else {
self.session = Some(session.clone());
}
}
if !self.created.contains(&handle.id()) {
self.created.push(handle.id());
}
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.
/// Does not resurrect already-consumed/deleted entities (plan §13 note).
pub fn compensate(self, doc: &Document) -> ApiResult<()> {
if let Some(session) = &self.session {
session.same_transport(&doc.session)?;
}
if self.created.is_empty() {
return Ok(());
}
+2 -2
View File
@@ -304,10 +304,10 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-
| Variant | Meaning |
|---|---|
| `Validation { op, reason }` | Input rejected before any document mutation. |
| `Validation { op, reason }` | Input rejected **before** any mutation (bulk ops name the failing index). |
| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). |
| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). |
| `Unsupported(String)` | Capability not supported for this entity or backend. |
| `Unsupported(String)` | Capability not implemented (e.g. a later-phase family or sub-type). |
| `Transport(String)` | The channel failed (disconnected / oversized / timeout). |
`ApiError` is serializable, so IPC returns the same structured error the in-process
@@ -4,189 +4,6 @@
{
"acadrust_variant": "Solid3D",
"collection": "solids",
"constructors": [
{
"args": [
{
"name": "origin",
"ty": "[f64; 3]"
},
{
"name": "size",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": {
"primitive": "Cuboid"
},
"name": "create_cuboid",
"op": "CreateSolid",
"returns": "Solid"
},
{
"args": [
{
"name": "centre",
"ty": "[f64; 3]"
},
{
"name": "radius",
"ty": "f64"
}
],
"bulk": false,
"fixed": {
"primitive": "Sphere"
},
"name": "create_sphere",
"op": "CreateSolid",
"returns": "Solid"
},
{
"args": [
{
"name": "base",
"ty": "[f64; 3]"
},
{
"name": "radius",
"ty": "f64"
},
{
"name": "height",
"ty": "f64"
}
],
"bulk": false,
"fixed": {
"primitive": "Cylinder"
},
"name": "create_cylinder",
"op": "CreateSolid",
"returns": "Solid"
},
{
"args": [
{
"name": "base",
"ty": "[f64; 3]"
},
{
"name": "radius",
"ty": "f64"
},
{
"name": "height",
"ty": "f64"
}
],
"bulk": false,
"fixed": {
"primitive": "Cone"
},
"name": "create_cone",
"op": "CreateSolid",
"returns": "Solid"
},
{
"args": [
{
"name": "centre",
"ty": "[f64; 3]"
},
{
"name": "major_radius",
"ty": "f64"
},
{
"name": "minor_radius",
"ty": "f64"
}
],
"bulk": false,
"fixed": {
"primitive": "Torus"
},
"name": "create_torus",
"op": "CreateSolid",
"returns": "Solid"
},
{
"args": [
{
"name": "origin",
"ty": "[f64; 3]"
},
{
"name": "size",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": {
"primitive": "Wedge"
},
"name": "create_wedge",
"op": "CreateSolid",
"returns": "Solid"
},
{
"args": [
{
"name": "profile",
"ty": "EntityRef"
},
{
"name": "direction",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": null,
"name": "extrude",
"op": "Extrude",
"returns": "Solid"
},
{
"args": [
{
"name": "profile",
"ty": "EntityRef"
},
{
"name": "pivot",
"ty": "[f64; 3]"
},
{
"name": "axis",
"ty": "[f64; 3]"
},
{
"name": "angle",
"ty": "f64"
}
],
"bulk": false,
"fixed": null,
"name": "revolve",
"op": "Revolve",
"returns": "Solid"
},
{
"args": [
{
"name": "profiles",
"ty": "&[EntityRef]"
}
],
"bulk": false,
"fixed": null,
"name": "loft",
"op": "Loft",
"returns": "Solid"
}
],
"handle": "Solid",
"methods": [
{
@@ -290,27 +107,6 @@
{
"acadrust_variant": "Line",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "start",
"ty": "[f64; 3]"
},
{
"name": "end",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": {
"curve": "Line"
},
"name": "create_line",
"op": "CreateCurve",
"returns": "Line"
}
],
"handle": "Line",
"methods": [],
"name": "Line"
@@ -318,27 +114,6 @@
{
"acadrust_variant": "Circle",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "centre",
"ty": "[f64; 3]"
},
{
"name": "radius",
"ty": "f64"
}
],
"bulk": false,
"fixed": {
"curve": "Circle"
},
"name": "create_circle",
"op": "CreateCurve",
"returns": "Circle"
}
],
"handle": "Circle",
"methods": [],
"name": "Circle"
@@ -346,27 +121,6 @@
{
"acadrust_variant": "LwPolyline",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "points",
"ty": "&[[f64; 3]]"
},
{
"name": "closed",
"ty": "bool"
}
],
"bulk": false,
"fixed": {
"curve": "Polyline"
},
"name": "create_polyline",
"op": "CreateCurve",
"returns": "Polyline"
}
],
"handle": "Polyline",
"methods": [
{
@@ -395,38 +149,6 @@
{
"acadrust_variant": "Point",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "position",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": {
"curve": "Point"
},
"name": "create_point",
"op": "CreateCurve",
"returns": "Point"
},
{
"args": [
{
"name": "positions",
"ty": "&[[f64; 3]]"
}
],
"bulk": true,
"fixed": {
"curve": "Point"
},
"name": "create_points",
"op": "CreateMany",
"returns": "Vec<Point>"
}
],
"handle": "Point",
"methods": [],
"name": "Point"
@@ -434,35 +156,6 @@
{
"acadrust_variant": "Arc",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "centre",
"ty": "[f64; 3]"
},
{
"name": "radius",
"ty": "f64"
},
{
"name": "start_angle",
"ty": "f64"
},
{
"name": "end_angle",
"ty": "f64"
}
],
"bulk": false,
"fixed": {
"curve": "Arc"
},
"name": "create_arc",
"op": "CreateCurve",
"returns": "ArcCurve"
}
],
"handle": "ArcCurve",
"methods": [],
"name": "Arc"
@@ -470,39 +163,6 @@
{
"acadrust_variant": "Ellipse",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "centre",
"ty": "[f64; 3]"
},
{
"name": "major_axis",
"ty": "[f64; 3]"
},
{
"name": "ratio",
"ty": "f64"
},
{
"name": "start",
"ty": "f64"
},
{
"name": "end",
"ty": "f64"
}
],
"bulk": false,
"fixed": {
"curve": "Ellipse"
},
"name": "create_ellipse",
"op": "CreateCurve",
"returns": "Ellipse"
}
],
"handle": "Ellipse",
"methods": [],
"name": "Ellipse"
@@ -510,35 +170,6 @@
{
"acadrust_variant": "Spline",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "degree",
"ty": "i32"
},
{
"name": "control_points",
"ty": "&[[f64; 3]]"
},
{
"name": "knots",
"ty": "&[f64]"
},
{
"name": "weights",
"ty": "&[f64]"
}
],
"bulk": false,
"fixed": {
"curve": "Spline"
},
"name": "create_spline",
"op": "CreateCurve",
"returns": "Spline"
}
],
"handle": "Spline",
"methods": [],
"name": "Spline"
@@ -546,27 +177,6 @@
{
"acadrust_variant": "Ray",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "origin",
"ty": "[f64; 3]"
},
{
"name": "direction",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": {
"curve": "Ray"
},
"name": "create_ray",
"op": "CreateCurve",
"returns": "Ray"
}
],
"handle": "Ray",
"methods": [],
"name": "Ray"
@@ -574,27 +184,6 @@
{
"acadrust_variant": "XLine",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "origin",
"ty": "[f64; 3]"
},
{
"name": "direction",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": {
"curve": "XLine"
},
"name": "create_xline",
"op": "CreateCurve",
"returns": "XLine"
}
],
"handle": "XLine",
"methods": [],
"name": "XLine"
@@ -602,33 +191,6 @@
{
"acadrust_variant": "Text",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "value",
"ty": "&str"
},
{
"name": "insertion_point",
"ty": "[f64; 3]"
},
{
"name": "height",
"ty": "f64"
},
{
"name": "rotation",
"ty": "f64"
}
],
"bulk": false,
"fixed": null,
"name": "create_text",
"op": "CreateText",
"returns": "Text"
}
],
"handle": "Text",
"methods": [
{
@@ -664,29 +226,6 @@
{
"acadrust_variant": "MText",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "value",
"ty": "&str"
},
{
"name": "insertion_point",
"ty": "[f64; 3]"
},
{
"name": "height",
"ty": "f64"
}
],
"bulk": false,
"fixed": null,
"name": "create_mtext",
"op": "CreateMText",
"returns": "MText"
}
],
"handle": "MText",
"methods": [
{
@@ -722,25 +261,6 @@
{
"acadrust_variant": "Hatch",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "boundary",
"ty": "&[[f64; 2]]"
},
{
"name": "solid",
"ty": "bool"
}
],
"bulk": false,
"fixed": null,
"name": "create_hatch",
"op": "CreateHatch",
"returns": "Entity"
}
],
"handle": "Entity",
"methods": [
{
@@ -760,113 +280,6 @@
{
"acadrust_variant": "Dimension",
"collection": "curves",
"constructors": [
{
"args": [
{
"name": "first_point",
"ty": "[f64; 3]"
},
{
"name": "second_point",
"ty": "[f64; 3]"
},
{
"name": "definition_point",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": null,
"name": "create_dimension_linear",
"op": "CreateDimensionLinear",
"returns": "Dimension"
},
{
"args": [
{
"name": "center",
"ty": "[f64; 3]"
},
{
"name": "point",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": null,
"name": "create_dimension_radius",
"op": "CreateDimensionRadius",
"returns": "Dimension"
},
{
"args": [
{
"name": "center",
"ty": "[f64; 3]"
},
{
"name": "point",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": null,
"name": "create_dimension_diameter",
"op": "CreateDimensionDiameter",
"returns": "Dimension"
},
{
"args": [
{
"name": "vertex",
"ty": "[f64; 3]"
},
{
"name": "first_point",
"ty": "[f64; 3]"
},
{
"name": "second_point",
"ty": "[f64; 3]"
},
{
"name": "arc_location",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": null,
"name": "create_dimension_angular",
"op": "CreateDimensionAngular",
"returns": "Dimension"
},
{
"args": [
{
"name": "vertex",
"ty": "[f64; 3]"
},
{
"name": "first_point",
"ty": "[f64; 3]"
},
{
"name": "second_point",
"ty": "[f64; 3]"
},
{
"name": "arc_location",
"ty": "[f64; 3]"
}
],
"bulk": false,
"fixed": null,
"name": "create_dimension_angular2ln",
"op": "CreateDimensionAngular2Ln",
"returns": "Dimension"
}
],
"handle": "Dimension",
"methods": [
{
@@ -886,33 +299,6 @@
{
"acadrust_variant": "Insert",
"collection": "entities",
"constructors": [
{
"args": [
{
"name": "block_name",
"ty": "&str"
},
{
"name": "insert_point",
"ty": "[f64; 3]"
},
{
"name": "scale",
"ty": "f64"
},
{
"name": "rotation",
"ty": "f64"
}
],
"bulk": false,
"fixed": null,
"name": "create_insert",
"op": "CreateInsert",
"returns": "Entity"
}
],
"handle": "Entity",
"methods": [
{
@@ -952,41 +338,6 @@
{
"acadrust_variant": "AttributeDefinition",
"collection": "entities",
"constructors": [
{
"args": [
{
"name": "tag",
"ty": "&str"
},
{
"name": "prompt",
"ty": "&str"
},
{
"name": "default_value",
"ty": "&str"
},
{
"name": "insertion_point",
"ty": "[f64; 3]"
},
{
"name": "height",
"ty": "f64"
},
{
"name": "rotation",
"ty": "f64"
}
],
"bulk": false,
"fixed": null,
"name": "create_attribute_definition",
"op": "CreateAttributeDefinition",
"returns": "Entity"
}
],
"handle": "Entity",
"methods": [],
"name": "AttributeDefinition"
@@ -994,37 +345,6 @@
{
"acadrust_variant": "Viewport",
"collection": "entities",
"constructors": [
{
"args": [
{
"name": "center",
"ty": "[f64; 3]"
},
{
"name": "width",
"ty": "f64"
},
{
"name": "height",
"ty": "f64"
},
{
"name": "view_target",
"ty": "[f64; 3]"
},
{
"name": "view_height",
"ty": "f64"
}
],
"bulk": false,
"fixed": null,
"name": "create_viewport",
"op": "CreateViewport",
"returns": "Entity"
}
],
"handle": "Entity",
"methods": [
{
@@ -1064,37 +384,6 @@
{
"acadrust_variant": "RasterImage",
"collection": "entities",
"constructors": [
{
"args": [
{
"name": "file_path",
"ty": "&str"
},
{
"name": "insertion_point",
"ty": "[f64; 3]"
},
{
"name": "u_vector",
"ty": "[f64; 3]"
},
{
"name": "v_vector",
"ty": "[f64; 3]"
},
{
"name": "size",
"ty": "[f64; 2]"
}
],
"bulk": false,
"fixed": null,
"name": "create_raster_image",
"op": "CreateRasterImage",
"returns": "Entity"
}
],
"handle": "Entity",
"methods": [],
"name": "RasterImage"
@@ -1102,25 +391,6 @@
{
"acadrust_variant": "Table",
"collection": "entities",
"constructors": [
{
"args": [
{
"name": "insertion_point",
"ty": "[f64; 3]"
},
{
"name": "data",
"ty": "&[Vec<String>]"
}
],
"bulk": false,
"fixed": null,
"name": "create_table",
"op": "CreateTable",
"returns": "Entity"
}
],
"handle": "Entity",
"methods": [],
"name": "Table"
+14 -55
View File
@@ -2,18 +2,14 @@
// from spec/entities.toml by build.rs — the enum is the canonical append-only
// wire vocabulary; the spec's `op` names must each map to a variant here
// (enforced by a test). Append new variants at the END only (bincode discriminant
// stability). Do NOT reorder or remove variants.
// stability, plan §7). Do NOT reorder or remove variants.
use serde::{Deserialize, Serialize};
use crate::id::ObjectId;
use crate::ops::{
AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec,
DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec,
SolidPrimitive, TableSpec, TextSpec, ViewportSpec,
};
use crate::ops::{AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TableSpec, TextSpec, ViewportSpec};
/// A typed write operation. Each variant is ONE atomic host call = one
/// 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]
@@ -21,40 +17,16 @@ pub enum Operation {
// construction
CreateSolid(SolidPrimitive),
CreateCurve(Curve2Spec),
Extrude {
profile: ObjectId,
direction: [f64; 3],
},
Revolve {
profile: ObjectId,
axis: ([f64; 3], [f64; 3]),
angle: f64,
},
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,
},
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,
},
TransformMany { ids: Vec<ObjectId>, placement: PlacementSpec },
DeleteMany(Vec<ObjectId>),
// containers
CreateInsert(InsertSpec),
@@ -63,32 +35,19 @@ pub enum Operation {
// annotations
CreateText(TextSpec),
CreateMText(MTextSpec),
SetTextContent {
id: ObjectId,
value: String,
},
SetTextContent { id: ObjectId, value: String },
// hatch
CreateHatch(HatchSpec),
// dimensions
CreateDimensionLinear(DimensionSpec),
// attributes
SetAttribute {
id: ObjectId,
tag: String,
value: String,
},
SetAttribute { id: ObjectId, tag: String, value: String },
// viewport view
SetViewportView {
id: ObjectId,
view_target: [f64; 3],
view_height: f64,
},
SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 },
// media
CreateRasterImage(RasterImageSpec),
// multi-profile solid
Loft {
profiles: Vec<ObjectId>,
},
Loft { profiles: Vec<ObjectId> },
// dimension sub-types
CreateDimensionRadius(DimensionRadialSpec),
CreateDimensionDiameter(DimensionRadialSpec),
+13 -36
View File
@@ -1,58 +1,35 @@
// HAND-MAINTAINED wire vocabulary (the Query enum). NOT derived from the spec by
// build.rs — the enum is the canonical append-only wire vocabulary; the spec's
// `query` names must each map to a variant here (enforced by a test). Append new
// variants at the END only (bincode discriminant stability).
// variants at the END only (bincode discriminant stability, plan §7).
use serde::{Deserialize, Serialize};
use crate::id::ObjectId;
/// A typed read query. Read-only: no mutation, no undo, no revision
/// 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.
/// (Not `Copy`: some variants carry owned data like a block name.)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Query {
GetEntity {
id: ObjectId,
},
GetBounds {
id: ObjectId,
},
GetCentroid {
id: ObjectId,
},
GetVolume {
id: ObjectId,
},
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,
},
GetIntersects { a: ObjectId, b: ObjectId },
GetGeometryRevision,
/// The text content of a Text/MText annotation.
GetTextContent {
id: ObjectId,
},
GetTextContent { id: ObjectId },
/// The boundary loops of a Hatch (outer + islands) as polylines.
GetHatchBoundary {
id: ObjectId,
},
GetHatchBoundary { id: ObjectId },
/// The measured value of a Dimension (distance or angle).
GetDimensionMeasurement {
id: ObjectId,
},
GetDimensionMeasurement { id: ObjectId },
/// An insert's attributes as (tag, value) pairs.
GetAttributes {
id: ObjectId,
},
GetAttributes { id: ObjectId },
/// The entity ids + kinds inside a block definition (read-only traversal).
GetBlockEntities {
block_name: String,
},
GetBlockEntities { block_name: String },
/// A viewport's view (target + zoom height).
GetViewportView {
id: ObjectId,
},
GetViewportView { id: ObjectId },
}
+37 -54
View File
@@ -17,55 +17,47 @@ fn geom_err(kind: GeometryErrorKind, msg: impl Into<String>) -> ApiError {
/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source
/// of truth shared by the host backend and the test/example mock backends.
pub fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
mesh.mass_properties().unwrap_or((0.0, [0.0; 3]))
}
pub fn validate_placement(p: &crate::PlacementSpec) -> ApiResult<()> {
let placement = cadkernel::brep::Placement {
origin: p.origin,
x_axis: p.x_axis,
y_axis: p.y_axis,
z_axis: p.z_axis,
};
if placement.scale().is_none() || p.origin.iter().any(|v| !v.is_finite()) {
return Err(ApiError::validation(
"Transform",
"placement must be a finite, nonzero similarity",
));
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;
}
}
Ok(())
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)])
}
/// Construct a solid primitive.
pub fn make_solid(p: &SolidPrimitive) -> ApiResult<KernelBody> {
crate::validation::solid(p)?;
/// 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::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",
)
})
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]))`.
@@ -78,23 +70,14 @@ pub(crate) fn boolean(a: &KernelBody, b: &KernelBody, op: BoolOp) -> ApiResult<K
}
/// Extrude a closed 2D profile into a solid (`brep::extrude`).
pub(crate) fn extrude(
plane: Plane,
profile: &[Curve2],
direction: [f64; 3],
) -> ApiResult<KernelBody> {
cadkernel::brep::extrude(plane, profile, direction)
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(
plane: Plane,
profile: &[Curve2],
pivot: [f64; 3],
axis: [f64; 3],
angle: f64,
) -> ApiResult<KernelBody> {
cadkernel::brep::revolve(plane, profile, pivot, axis, angle)
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"))
}
+3 -3
View File
@@ -1,4 +1,4 @@
//! Stable entity identity.
//! Stable entity identity (plan §2 decision #7).
use serde::{Deserialize, Serialize};
@@ -23,13 +23,13 @@ impl ObjectId {
}
/// Build from an `acadrust::Handle` (host/ipc feature).
#[cfg(any(feature = "host", feature = "ipc"))]
#[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"))]
#[cfg(any(feature = "host", feature = "ipc", test))]
pub fn to_handle(self) -> acadrust::Handle {
acadrust::Handle::new(self.0)
}
+16 -7
View File
@@ -1,4 +1,15 @@
//! Typed document operations, queries and facades over in-process or IPC transports.
//! `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;
@@ -7,13 +18,11 @@ pub mod revision;
#[cfg(feature = "host")]
pub mod backend;
#[cfg(feature = "host")]
pub mod convert;
#[cfg(feature = "host")]
pub mod executor;
#[cfg(feature = "host")]
pub mod geom;
#[cfg(feature = "host")]
mod validation;
pub mod convert;
pub mod envelope;
pub mod facade;
@@ -21,7 +30,7 @@ pub mod ops;
pub mod query;
pub mod transport;
// Append-only wire enums, maintained alongside the DTOs and spec.
// Generated (build.rs) — committed snapshots under src/gen/; do NOT edit by hand.
pub mod gen {
pub mod ops {
include!("gen/ops_gen.rs");
@@ -58,8 +67,8 @@ pub use transport::Transport;
/// Bridges must check this before speaking (see `bindings/README.md`).
pub const ENVELOPE_VERSION: u16 = 1;
/// The generated binding handover schema: object model + method
/// signatures + op/query mapping with curated wire vocabulary. Single,
/// 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")
+18 -75
View File
@@ -1,5 +1,5 @@
//! Write operations — the typed, transport-agnostic vocabulary for changing the
//! document. The `Operation` enum is **hand-maintained** (append-only)
//! document (plan §5). The `Operation` enum is **hand-maintained** (append-only)
//! in `src/gen/ops_gen.rs` — it is NOT derived from the spec by build.rs; the
//! spec's `op` names must each map to a variant here (asserted by a test). This
//! module re-exports it and holds the hand-written payload mirrors.
@@ -20,86 +20,32 @@ pub enum BoolOp {
/// 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],
},
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],
},
Line { start: [f64; 3], end: [f64; 3] },
Circle { centre: [f64; 3], radius: f64 },
Polyline { points: Vec<[f64; 3]>, closed: bool },
Point { position: [f64; 3] },
/// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians).
Arc {
centre: [f64; 3],
radius: f64,
start_angle: f64,
end_angle: f64,
},
Arc { centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64 },
/// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`;
/// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π).
Ellipse {
centre: [f64; 3],
major_axis: [f64; 3],
ratio: f64,
start: f64,
end: f64,
},
Ellipse { centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64 },
/// NURBS spline (control points + knots + weights; `degree` typically 3).
Spline {
degree: i32,
control_points: Vec<[f64; 3]>,
knots: Vec<f64>,
weights: Vec<f64>,
},
Spline { degree: i32, control_points: Vec<[f64; 3]>, knots: Vec<f64>, weights: Vec<f64> },
/// A ray from `origin` along `direction` (bounded at origin only).
Ray {
origin: [f64; 3],
direction: [f64; 3],
},
Ray { origin: [f64; 3], direction: [f64; 3] },
/// An infinite construction line through `origin` along `direction`.
XLine {
origin: [f64; 3],
direction: [f64; 3],
},
XLine { origin: [f64; 3], direction: [f64; 3] },
}
/// A generic entity-construction payload used by the bulk op
@@ -246,14 +192,11 @@ impl PlacementSpec {
/// Pure translation to `origin` (axes unchanged).
pub fn at(origin: [f64; 3]) -> Self {
Self {
origin,
..Self::IDENTITY
}
Self { origin, ..Self::IDENTITY }
}
}
/// The item-count cap for a single bulk op envelope: bounds
/// 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;
+3 -14
View File
@@ -1,4 +1,4 @@
//! Read queries — typed, transport-agnostic reads of entity state.
//! Read queries — typed, transport-agnostic reads of entity state (plan §5).
//! The `Query` enum is **hand-maintained** (append-only) in `src/gen/query_gen.rs`
//! — NOT derived from the spec by build.rs (the spec's `query` names must map to
//! a variant here, asserted by a test). This module re-exports it and holds the
@@ -19,16 +19,8 @@ pub struct Aabb {
}
impl Aabb {
#[cfg(feature = "host")]
pub fn overlaps(&self, other: &Aabb) -> bool {
cadkernel::brep::Aabb {
min: self.min,
max: self.max,
}
.overlaps(&cadkernel::brep::Aabb {
min: other.min,
max: other.max,
})
(0..3).all(|i| self.min[i] <= other.max[i] && other.min[i] <= self.max[i])
}
}
@@ -64,10 +56,7 @@ pub enum QueryResult {
/// The entities inside a block definition (read-only traversal).
BlockEntities(Vec<EntityView>),
/// A viewport's view: target point (WCS) + zoom height.
ViewportView {
target: [f64; 3],
height: f64,
},
ViewportView { target: [f64; 3], height: f64 },
}
/// Convenience: the query name for diagnostics.
+5 -9
View File
@@ -1,4 +1,4 @@
//! In-process transport: drives a `DocApiBackend` directly, inline.
//! 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).
@@ -17,9 +17,7 @@ pub struct InProcess<B: DocApiBackend> {
impl<B: DocApiBackend> InProcess<B> {
pub fn new(backend: B) -> Self {
Self {
backend: Mutex::new(backend),
}
Self { backend: Mutex::new(backend) }
}
/// Borrow the backend immutably (test/introspection helper).
pub fn backend(&self) -> std::sync::MutexGuard<'_, B> {
@@ -29,11 +27,9 @@ impl<B: DocApiBackend> InProcess<B> {
impl<B: DocApiBackend + Send> Transport for InProcess<B> {
fn apply(&self, req: DocApiEnvelope) -> ApiResult<Receipt> {
req.validate_version()?;
let mut backend = self
.backend
.lock()
.map_err(|_| crate::error::ApiError::Transport("in-process backend poisoned".into()))?;
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),
+2 -5
View File
@@ -1,4 +1,4 @@
//! IPC transport: serializes `DocApiEnvelope` into the
//! 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.
@@ -35,10 +35,7 @@ impl Transport for OcsPluginApiIpc {
.map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?;
let resp = self
.sender
.request(PluginRequest::DocApiRequest {
tab_id: self.tab_id,
bytes,
})
.request(PluginRequest::DocApiRequest { tab_id: self.tab_id, bytes })
.map_err(Self::transport_err)?;
match resp {
PluginResponse::DocApiResponse { bytes } => {
+1 -1
View File
@@ -1,4 +1,4 @@
//! The transport boundary — the ONLY plugin↔host boundary.
//! 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,
+49 -223
View File
@@ -1,5 +1,5 @@
//! Unit tests over an in-memory mock `DocApiBackend`: no host.
//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup
//! 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.
@@ -17,30 +17,15 @@ use ocs_doc_api::{
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]),
],
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 } | Curve2Spec::Arc { 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, .. }
| Curve2Spec::Spline {
control_points: points,
..
} => {
Curve2Spec::Point { position } => Aabb { min: *position, max: *position },
Curve2Spec::Polyline { points, .. } | Curve2Spec::Spline { control_points: points, .. } => {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for p in points {
@@ -51,28 +36,17 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb {
}
Aabb { min, max }
}
Curve2Spec::Ellipse {
centre, major_axis, ..
} => {
let r = (major_axis[0] * major_axis[0]
+ major_axis[1] * major_axis[1]
+ major_axis[2] * major_axis[2])
.sqrt();
Aabb {
min: [centre[0] - r, centre[1] - r, centre[2] - r],
max: [centre[0] + r, centre[1] + r, centre[2] + r],
}
Curve2Spec::Ellipse { centre, major_axis, .. } => {
let r = (major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2]).sqrt();
Aabb { min: [centre[0] - r, centre[1] - r, centre[2] - r], max: [centre[0] + r, centre[1] + r, centre[2] + r] }
}
Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb {
min: *origin,
max: *origin,
},
Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { min: *origin, max: *origin },
}
}
/// In-memory mock backend: HashMap<ObjectId, Body> + a tiny entity table.
/// Tracks undo steps + revision bumps to assert per-op semantics.
#[derive(Default, Clone)]
#[derive(Default)]
struct MockBackend {
next_id: u64,
revision: u64,
@@ -121,35 +95,6 @@ impl DocApiBackend for MockBackend {
}
Ok(())
}
fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult<Vec<ObjectId>> {
let mut next = self.clone();
let ids = specs
.iter()
.map(|spec| match spec {
ocs_doc_api::EntitySpec::Solid(spec) => {
next.store_solid(&ocs_doc_api::geom::make_solid(spec)?)
}
ocs_doc_api::EntitySpec::Curve(spec) => {
ocs_doc_api::convert::curve_spec_to_entity(spec)?;
next.add_curve(spec)
}
})
.collect::<ApiResult<Vec<_>>>()?;
next.push_undo("CreateMany");
next.finalize_op();
*self = next;
Ok(ids)
}
fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> {
let mut next = self.clone();
for &id in ids {
next.transform_entity(id, placement)?;
}
next.push_undo("TransformMany");
next.finalize_op();
*self = next;
Ok(())
}
fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult<ObjectId> {
let kind = match spec {
Curve2Spec::Line { .. } => "Line",
@@ -174,8 +119,7 @@ impl DocApiBackend for MockBackend {
}
fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Viewport");
self.viewport_views
.insert(id, (spec.view_target, spec.view_height));
self.viewport_views.insert(id, (spec.view_target, spec.view_height));
Ok(id)
}
fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult<ObjectId> {
@@ -197,27 +141,17 @@ impl DocApiBackend for MockBackend {
}
}
fn text_content(&self, id: ObjectId) -> ApiResult<String> {
self.text_values
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Hatch");
self.hatch_boundaries
.insert(id, vec![spec.boundary.clone()]);
self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]);
Ok(id)
}
fn hatch_boundary(&self, id: ObjectId) -> ApiResult<Vec<Vec<[f64; 2]>>> {
self.hatch_boundaries
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
self.hatch_boundaries.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn add_dimension_linear(
&mut self,
spec: &ocs_doc_api::ops::DimensionSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_linear(&mut self, spec: &ocs_doc_api::ops::DimensionSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
let d = ((spec.first_point[0] - spec.second_point[0]).powi(2)
+ (spec.first_point[1] - spec.second_point[1]).powi(2)
@@ -226,54 +160,30 @@ impl DocApiBackend for MockBackend {
self.dimension_measurements.insert(id, d);
Ok(id)
}
fn add_dimension_radius(
&mut self,
spec: &ocs_doc_api::ops::DimensionRadialSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_radius(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
let d = ((spec.center[0] - spec.point[0]).powi(2)
+ (spec.center[1] - spec.point[1]).powi(2)
+ (spec.center[2] - spec.point[2]).powi(2))
.sqrt();
let d = ((spec.center[0] - spec.point[0]).powi(2) + (spec.center[1] - spec.point[1]).powi(2) + (spec.center[2] - spec.point[2]).powi(2)).sqrt();
self.dimension_measurements.insert(id, d);
Ok(id)
}
fn add_dimension_diameter(
&mut self,
spec: &ocs_doc_api::ops::DimensionRadialSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_diameter(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult<ObjectId> {
self.add_dimension_radius(spec)
}
fn add_dimension_angular(
&mut self,
_spec: &ocs_doc_api::ops::DimensionAngularSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_angular(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees
Ok(id)
}
fn dimension_measurement(&self, id: ObjectId) -> ApiResult<f64> {
self.dimension_measurements
.get(&id)
.copied()
.ok_or(ApiError::UnknownId(id))
self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id))
}
fn add_attribute_definition(
&mut self,
spec: &ocs_doc_api::ops::AttributeDefinitionSpec,
) -> ApiResult<ObjectId> {
fn add_attribute_definition(&mut self, spec: &ocs_doc_api::ops::AttributeDefinitionSpec) -> ApiResult<ObjectId> {
if spec.tag.is_empty() {
return Err(ApiError::validation(
"CreateAttributeDefinition",
"empty tag",
));
return Err(ApiError::validation("CreateAttributeDefinition", "empty tag"));
}
Ok(self.alloc("AttributeDefinition"))
}
fn add_dimension_angular2ln(
&mut self,
_spec: &ocs_doc_api::ops::DimensionAngularSpec,
) -> ApiResult<ObjectId> {
fn add_dimension_angular2ln(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult<ObjectId> {
let id = self.alloc("Dimension");
self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees
Ok(id)
@@ -281,10 +191,7 @@ impl DocApiBackend for MockBackend {
fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult<ObjectId> {
let cols = spec.data.first().map(|r| r.len()).unwrap_or(0);
if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) {
return Err(ApiError::validation(
"CreateTable",
"table needs a non-empty rectangular grid",
));
return Err(ApiError::validation("CreateTable", "table needs a non-empty rectangular grid"));
}
Ok(self.alloc("Table"))
}
@@ -301,24 +208,15 @@ impl DocApiBackend for MockBackend {
Ok(())
}
fn attributes(&self, id: ObjectId) -> ApiResult<Vec<(String, String)>> {
self.attributes_store
.get(&id)
.cloned()
.ok_or(ApiError::UnknownId(id))
self.attributes_store.get(&id).cloned().ok_or(ApiError::UnknownId(id))
}
fn block_entities(&self, _block_name: &str) -> ApiResult<Vec<EntityView>> {
Ok(Vec::new())
}
fn add_raster_image(
&mut self,
_spec: &ocs_doc_api::ops::RasterImageSpec,
) -> ApiResult<ObjectId> {
fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult<ObjectId> {
Ok(self.alloc("RasterImage"))
}
fn loft(
&mut self,
sections: &[(cadkernel::space::Plane, Vec<cadkernel::geom2d::Curve>)],
) -> ApiResult<ObjectId> {
fn loft(&mut self, sections: &[Vec<cadkernel::geom2d::Curve>]) -> ApiResult<ObjectId> {
if sections.len() < 2 {
return Err(ApiError::validation("Loft", "loft needs >= 2 profiles"));
}
@@ -327,12 +225,7 @@ impl DocApiBackend for MockBackend {
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?;
self.store_solid(&body)
}
fn set_viewport_view(
&mut self,
id: ObjectId,
view_target: [f64; 3],
view_height: f64,
) -> ApiResult<()> {
fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> {
if !self.entity_exists(id) {
return Err(ApiError::UnknownId(id));
}
@@ -340,10 +233,7 @@ impl DocApiBackend for MockBackend {
Ok(())
}
fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> {
self.viewport_views
.get(&id)
.copied()
.ok_or(ApiError::UnknownId(id))
self.viewport_views.get(&id).copied().ok_or(ApiError::UnknownId(id))
}
fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> {
if self.entity_exists(id) {
@@ -359,10 +249,7 @@ impl DocApiBackend for MockBackend {
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,
})
cadkernel::brep::body_bounds(b).map(|bb| Aabb { min: bb.min, max: bb.max })
});
Ok(EntityView { id, kind, bounds })
}
@@ -377,9 +264,8 @@ impl DocApiBackend for MockBackend {
z_axis: placement.z_axis,
origin: placement.origin,
};
let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| {
ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed")
})?;
let moved = cadkernel::brep::transform(&body, &place)
.ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?;
self.bodies.insert(id, moved);
}
Ok(())
@@ -396,10 +282,7 @@ impl DocApiBackend for MockBackend {
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,
})
Ok(Aabb { min: bb.min, max: bb.max })
}
fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> {
let body = self.body(id)?;
@@ -422,10 +305,9 @@ impl DocApiBackend for MockBackend {
}
}
fn api() -> (DocApi, Arc<ocs_doc_api::transport::InProcess<MockBackend>>) {
let tp = Arc::new(ocs_doc_api::transport::InProcess::new(
MockBackend::default(),
));
let tp = Arc::new(ocs_doc_api::transport::InProcess::new(MockBackend::default()));
(DocApi::connect(tp.clone(), 0), tp)
}
@@ -436,26 +318,13 @@ fn cuboid_intersect_cuboid_atomic_ops_and_revision() {
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();
// 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
}
Ok(l) => { grp.commit(); l }
Err(e) => panic!("intersect failed: {e}"),
};
assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 3);
@@ -468,11 +337,8 @@ fn cuboid_intersect_cuboid_atomic_ops_and_revision() {
.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:?}"
);
// 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");
}
@@ -480,14 +346,8 @@ fn cuboid_intersect_cuboid_atomic_ops_and_revision() {
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 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()));
@@ -508,12 +368,8 @@ 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();
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()));
@@ -537,10 +393,7 @@ fn bulk_create_many_one_undo_step() {
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 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
@@ -558,9 +411,7 @@ fn assert_revision_guard() {
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();
doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3]).unwrap();
assert!(doc.assert_revision(rev).is_err());
}
@@ -568,10 +419,7 @@ fn assert_revision_guard() {
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();
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.
@@ -587,25 +435,3 @@ fn failed_boolean_leaves_inputs_live_and_no_undo() {
assert!(backend.entity_exists(a.id()));
assert_eq!(backend.revision, rev_before);
}
#[test]
fn typed_handles_reject_foreign_sessions_and_tabs() {
let first = DocApi::in_process(MockBackend::default(), 10);
let second = DocApi::in_process(MockBackend::default(), 10);
let doc = first.document(10);
let foreign = second
.document(10)
.curves()
.create_circle([0.0; 3], 2.0)
.unwrap();
assert!(doc.solids().extrude(&foreign, [0.0, 0.0, 2.0]).is_err());
assert!(doc
.solids()
.loft(&[foreign.clone(), foreign.clone()])
.is_err());
assert!(doc.query_batch(|q| q.bounds(&foreign)).is_err());
let mut group = ocs_doc_api::OpGroup::new();
group.track(Ok(foreign)).unwrap();
assert!(group.compensate(&doc).is_err());
assert!(first.document(11).curves().create_point([0.0; 3]).is_err());
}
+47 -189
View File
@@ -1,4 +1,4 @@
//! Spec↔wire consistency tests (enforces the codegen contract the headers
//! Spec↔wire consistency tests (enforces the codegen contract the headers
//! promise): every `op`/`query` name in `spec/entities.toml` maps to an existing
//! `Operation`/`Query` variant, and the wire enums are append-only relative to a
//! recorded baseline (bincode discriminant stability).
@@ -37,151 +37,50 @@ fn spec() -> Spec {
/// The variant names of the `Operation` enum, in declaration order.
fn operation_variants() -> Vec<String> {
use ocs_doc_api::ops::{
BoolOp, Curve2Spec, InsertSpec, Operation, PlacementSpec, SolidPrimitive, ViewportSpec,
};
use ocs_doc_api::ops::{BoolOp, Curve2Spec, InsertSpec, Operation, PlacementSpec, SolidPrimitive, ViewportSpec};
use ocs_doc_api::ObjectId;
let _marker: Vec<Operation> = vec![
Operation::CreateSolid(SolidPrimitive::Sphere {
centre: [0.0; 3],
radius: 1.0,
}),
Operation::CreateSolid(SolidPrimitive::Sphere { centre: [0.0; 3], radius: 1.0 }),
Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] }),
Operation::Extrude {
profile: ObjectId::from_u64(0),
direction: [0.0; 3],
},
Operation::Revolve {
profile: ObjectId::from_u64(0),
axis: ([0.0; 3], [0.0; 3]),
angle: 0.0,
},
Operation::Transform {
id: ObjectId::from_u64(0),
placement: PlacementSpec::IDENTITY,
},
Operation::SolidBoolean {
op: BoolOp::Union,
a: ObjectId::from_u64(0),
b: ObjectId::from_u64(0),
erase_sources: true,
},
Operation::AddVertex {
id: ObjectId::from_u64(0),
at: 0,
point: [0.0; 3],
},
Operation::Delete {
id: ObjectId::from_u64(0),
},
Operation::Extrude { profile: ObjectId::from_u64(0), direction: [0.0; 3] },
Operation::Revolve { profile: ObjectId::from_u64(0), axis: ([0.0; 3], [0.0; 3]), angle: 0.0 },
Operation::Transform { id: ObjectId::from_u64(0), placement: PlacementSpec::IDENTITY },
Operation::SolidBoolean { op: BoolOp::Union, a: ObjectId::from_u64(0), b: ObjectId::from_u64(0), erase_sources: true },
Operation::AddVertex { id: ObjectId::from_u64(0), at: 0, point: [0.0; 3] },
Operation::Delete { id: ObjectId::from_u64(0) },
Operation::CreateMany(vec![]),
Operation::TransformMany {
ids: vec![],
placement: PlacementSpec::IDENTITY,
},
Operation::TransformMany { ids: vec![], placement: PlacementSpec::IDENTITY },
Operation::DeleteMany(vec![]),
Operation::CreateInsert(InsertSpec {
block_name: String::new(),
insert_point: [0.0; 3],
scale: 1.0,
rotation: 0.0,
}),
Operation::CreateViewport(ViewportSpec {
center: [0.0; 3],
width: 0.0,
height: 0.0,
view_target: [0.0; 3],
view_height: 0.0,
}),
Operation::CreateText(ocs_doc_api::ops::TextSpec {
value: String::new(),
insertion_point: [0.0; 3],
height: 0.0,
rotation: 0.0,
}),
Operation::CreateMText(ocs_doc_api::ops::MTextSpec {
value: String::new(),
insertion_point: [0.0; 3],
height: 0.0,
}),
Operation::SetTextContent {
id: ObjectId::from_u64(0),
value: String::new(),
},
Operation::CreateHatch(ocs_doc_api::ops::HatchSpec {
boundary: vec![],
solid: true,
}),
Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec {
first_point: [0.0; 3],
second_point: [0.0; 3],
definition_point: [0.0; 3],
}),
Operation::SetAttribute {
id: ObjectId::from_u64(0),
tag: String::new(),
value: String::new(),
},
Operation::SetViewportView {
id: ObjectId::from_u64(0),
view_target: [0.0; 3],
view_height: 0.0,
},
Operation::CreateInsert(InsertSpec { block_name: String::new(), insert_point: [0.0; 3], scale: 1.0, rotation: 0.0 }),
Operation::CreateViewport(ViewportSpec { center: [0.0; 3], width: 0.0, height: 0.0, view_target: [0.0; 3], view_height: 0.0 }),
Operation::CreateText(ocs_doc_api::ops::TextSpec { value: String::new(), insertion_point: [0.0; 3], height: 0.0, rotation: 0.0 }),
Operation::CreateMText(ocs_doc_api::ops::MTextSpec { value: String::new(), insertion_point: [0.0; 3], height: 0.0 }),
Operation::SetTextContent { id: ObjectId::from_u64(0), value: String::new() },
Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { boundary: vec![], solid: true }),
Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec { first_point: [0.0; 3], second_point: [0.0; 3], definition_point: [0.0; 3] }),
Operation::SetAttribute { id: ObjectId::from_u64(0), tag: String::new(), value: String::new() },
Operation::SetViewportView { id: ObjectId::from_u64(0), view_target: [0.0; 3], view_height: 0.0 },
Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec {
file_path: String::new(),
insertion_point: [0.0; 3],
u_vector: [0.0; 3],
v_vector: [0.0; 3],
size: [0.0; 2],
file_path: String::new(), insertion_point: [0.0; 3], u_vector: [0.0; 3], v_vector: [0.0; 3], size: [0.0; 2],
}),
Operation::Loft { profiles: vec![] },
Operation::CreateDimensionRadius(ocs_doc_api::ops::DimensionRadialSpec {
center: [0.0; 3],
point: [0.0; 3],
}),
Operation::CreateDimensionDiameter(ocs_doc_api::ops::DimensionRadialSpec {
center: [0.0; 3],
point: [0.0; 3],
}),
Operation::CreateDimensionAngular(ocs_doc_api::ops::DimensionAngularSpec {
vertex: [0.0; 3],
first_point: [0.0; 3],
second_point: [0.0; 3],
arc_location: [0.0; 3],
}),
Operation::CreateDimensionRadius(ocs_doc_api::ops::DimensionRadialSpec { center: [0.0; 3], point: [0.0; 3] }),
Operation::CreateDimensionDiameter(ocs_doc_api::ops::DimensionRadialSpec { center: [0.0; 3], point: [0.0; 3] }),
Operation::CreateDimensionAngular(ocs_doc_api::ops::DimensionAngularSpec { vertex: [0.0; 3], first_point: [0.0; 3], second_point: [0.0; 3], arc_location: [0.0; 3] }),
Operation::CreateAttributeDefinition(ocs_doc_api::ops::AttributeDefinitionSpec {
tag: String::new(),
prompt: String::new(),
default_value: String::new(),
insertion_point: [0.0; 3],
height: 0.0,
rotation: 0.0,
}),
Operation::CreateTable(ocs_doc_api::ops::TableSpec {
insertion_point: [0.0; 3],
data: vec![],
}),
Operation::CreateDimensionAngular2Ln(ocs_doc_api::ops::DimensionAngularSpec {
vertex: [0.0; 3],
first_point: [0.0; 3],
second_point: [0.0; 3],
arc_location: [0.0; 3],
tag: String::new(), prompt: String::new(), default_value: String::new(), insertion_point: [0.0; 3], height: 0.0, rotation: 0.0,
}),
Operation::CreateTable(ocs_doc_api::ops::TableSpec { insertion_point: [0.0; 3], data: vec![] }),
Operation::CreateDimensionAngular2Ln(ocs_doc_api::ops::DimensionAngularSpec { vertex: [0.0; 3], first_point: [0.0; 3], second_point: [0.0; 3], arc_location: [0.0; 3] }),
];
// Derive the variant names by serializing a probe of each and reading the
// bincode variant index -> name map is not available; instead match by
// constructing each and using a tiny name extractor via Debug prefix.
_marker
.iter()
.enumerate()
.map(|(index, op)| {
let wire = bincode::serialize(op).unwrap();
assert_eq!(
u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize,
index
);
.map(|op| {
let dbg = format!("{:?}", op);
dbg.split(|c: char| c == '(' || c == ' ' || c == '{')
.next()
.unwrap_or("")
.to_string()
dbg.split(|c: char| c == '(' || c == ' ' || c == '{').next().unwrap_or("").to_string()
})
.collect()
}
@@ -191,56 +90,24 @@ fn query_variants() -> Vec<String> {
use ocs_doc_api::query::Query;
use ocs_doc_api::ObjectId;
let marker: Vec<Query> = vec![
Query::GetEntity {
id: ObjectId::from_u64(0),
},
Query::GetBounds {
id: ObjectId::from_u64(0),
},
Query::GetCentroid {
id: ObjectId::from_u64(0),
},
Query::GetVolume {
id: ObjectId::from_u64(0),
},
Query::GetIntersects {
a: ObjectId::from_u64(0),
b: ObjectId::from_u64(0),
},
Query::GetEntity { id: ObjectId::from_u64(0) },
Query::GetBounds { id: ObjectId::from_u64(0) },
Query::GetCentroid { id: ObjectId::from_u64(0) },
Query::GetVolume { id: ObjectId::from_u64(0) },
Query::GetIntersects { a: ObjectId::from_u64(0), b: ObjectId::from_u64(0) },
Query::GetGeometryRevision,
Query::GetTextContent {
id: ObjectId::from_u64(0),
},
Query::GetHatchBoundary {
id: ObjectId::from_u64(0),
},
Query::GetDimensionMeasurement {
id: ObjectId::from_u64(0),
},
Query::GetAttributes {
id: ObjectId::from_u64(0),
},
Query::GetBlockEntities {
block_name: String::new(),
},
Query::GetViewportView {
id: ObjectId::from_u64(0),
},
Query::GetTextContent { id: ObjectId::from_u64(0) },
Query::GetHatchBoundary { id: ObjectId::from_u64(0) },
Query::GetDimensionMeasurement { id: ObjectId::from_u64(0) },
Query::GetAttributes { id: ObjectId::from_u64(0) },
Query::GetBlockEntities { block_name: String::new() },
Query::GetViewportView { id: ObjectId::from_u64(0) },
];
marker
.iter()
.enumerate()
.map(|(index, q)| {
let wire = bincode::serialize(q).unwrap();
assert_eq!(
u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize,
index
);
.map(|q| {
let dbg = format!("{:?}", q);
dbg.split(|c: char| c == '(' || c == ' ' || c == '{')
.next()
.unwrap_or("")
.to_string()
dbg.split(|c: char| c == '(' || c == ' ' || c == '{').next().unwrap_or("").to_string()
})
.collect()
}
@@ -256,26 +123,17 @@ fn every_spec_op_and_query_maps_to_an_enum_variant() {
for m in fam.constructor.iter().chain(fam.method.iter()) {
if let Some(op) = &m.op {
if !op_names.contains(op) {
missing.push(format!(
"Operation::{op} (family {})",
fam.handle.as_deref().unwrap_or("?")
));
missing.push(format!("Operation::{op} (family {})", fam.handle.as_deref().unwrap_or("?")));
}
}
if let Some(q) = &m.query {
if !query_names.contains(q) {
missing.push(format!(
"Query::{q} (family {})",
fam.handle.as_deref().unwrap_or("?")
));
missing.push(format!("Query::{q} (family {})", fam.handle.as_deref().unwrap_or("?")));
}
}
}
}
assert!(
missing.is_empty(),
"spec references ops/queries with no enum variant: {missing:?}"
);
assert!(missing.is_empty(), "spec references ops/queries with no enum variant: {missing:?}");
}
/// Recorded baseline of the `Operation` variant order (append-only contract).