forked from 1b-agent/opencadstudio-upstream
General improvements on API
This commit is contained in:
1 parent
56e09cd3f5
commit
55f61a4327
30 files changed
+1820
-2565
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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(§ions)?;
|
||||
@@ -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
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user