From e41086b05814ccb335c05272dd97d04675784042 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:15:27 +0200 Subject: [PATCH 01/31] Add ocs_doc_api: object-oriented CAD entity API (v2) New crate crates/ocs_doc_api: object-centric facade (DocApi -> Document -> collections -> typed handles), transport-agnostic wire layer (DocApiEnvelope = single op | read-only query batch), per-op autocommit (no write transactions), bulk ops with upfront-validation all-or-nothing, structured ApiError, and build-time codegen (spec/entities.toml -> ops/query enums, api_reference.md, binding_schema.json). ocs_plugin_api: append-only PluginRequest::DocApiRequest / PluginResponse:: DocApiResponse (serde_bytes payload) + defaulted HostApi::doc_api_dispatch. Host: DocApiBackend impl for HostSession (src/app/doc_api.rs) mapping to scene primitives (solid_models lift-on-miss, register_prepared_solid_model, meshes metrics), entity conversions (doc_api_convert.rs), HostSession accessors + doc_api_dispatch override. Tests: 592 host lib (incl. 7 DocApi integration tests: create/boolean/query, unknown-id, transform-many all-or-nothing, query-batch cap, 2D/geometric/DWG roundtrips), 8 crate unit tests. Examples + README/ARCHITECTURE/IMPLEMENTATION docs. --- Cargo.lock | 107 +++- Cargo.toml | 3 + crates/ocs_doc_api/ARCHITECTURE.md | 337 +++++++++++ crates/ocs_doc_api/Cargo.toml | 42 ++ crates/ocs_doc_api/IMPLEMENTATION.md | 95 +++ crates/ocs_doc_api/README.md | 95 +++ crates/ocs_doc_api/bindings/README.md | 98 ++++ .../ocs_doc_api/bindings/python/generate.py | 192 ++++++ crates/ocs_doc_api/build.rs | 337 +++++++++++ crates/ocs_doc_api/examples/common/mod.rs | 194 +++++++ .../examples/e1_primitives_and_queries.rs | 43 ++ .../examples/e2_intersect_two_solids.rs | 53 ++ .../examples/e3_transform_and_chain.rs | 38 ++ .../examples/e4_2d_entities_and_bulk.rs | 40 ++ .../examples/e5_failure_and_guards.rs | 59 ++ crates/ocs_doc_api/spec/entities.toml | 224 +++++++ crates/ocs_doc_api/src/backend.rs | 92 +++ crates/ocs_doc_api/src/envelope.rs | 77 +++ crates/ocs_doc_api/src/error.rs | 59 ++ crates/ocs_doc_api/src/executor.rs | 268 +++++++++ crates/ocs_doc_api/src/facade.rs | 490 ++++++++++++++++ crates/ocs_doc_api/src/gen/api_reference.md | 115 ++++ .../ocs_doc_api/src/gen/binding_schema.json | 210 +++++++ crates/ocs_doc_api/src/gen/layouts.json | 28 + crates/ocs_doc_api/src/gen/ops_gen.rs | 30 + crates/ocs_doc_api/src/gen/query_gen.rs | 22 + crates/ocs_doc_api/src/geom.rs | 58 ++ crates/ocs_doc_api/src/id.rs | 40 ++ crates/ocs_doc_api/src/lib.rs | 66 +++ crates/ocs_doc_api/src/ops.rs | 97 ++++ crates/ocs_doc_api/src/query.rs | 61 ++ crates/ocs_doc_api/src/revision.rs | 16 + crates/ocs_doc_api/src/transport/inproc.rs | 42 ++ crates/ocs_doc_api/src/transport/ipc.rs | 55 ++ crates/ocs_doc_api/src/transport/mod.rs | 36 ++ crates/ocs_doc_api/tests/mock_backend.rs | 301 ++++++++++ crates/ocs_plugin_api/Cargo.toml | 3 +- crates/ocs_plugin_api/src/host.rs | 12 + crates/ocs_plugin_api/src/ipc/protocol.rs | 10 + crates/ocs_plugin_api/src/ipc/server.rs | 4 + src/app/doc_api.rs | 547 ++++++++++++++++++ src/app/doc_api_convert.rs | 111 ++++ src/app/mod.rs | 4 + src/app/plugin_host.rs | 38 ++ 44 files changed, 4837 insertions(+), 12 deletions(-) create mode 100644 crates/ocs_doc_api/ARCHITECTURE.md create mode 100644 crates/ocs_doc_api/Cargo.toml create mode 100644 crates/ocs_doc_api/IMPLEMENTATION.md create mode 100644 crates/ocs_doc_api/README.md create mode 100644 crates/ocs_doc_api/bindings/README.md create mode 100644 crates/ocs_doc_api/bindings/python/generate.py create mode 100644 crates/ocs_doc_api/build.rs create mode 100644 crates/ocs_doc_api/examples/common/mod.rs create mode 100644 crates/ocs_doc_api/examples/e1_primitives_and_queries.rs create mode 100644 crates/ocs_doc_api/examples/e2_intersect_two_solids.rs create mode 100644 crates/ocs_doc_api/examples/e3_transform_and_chain.rs create mode 100644 crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs create mode 100644 crates/ocs_doc_api/examples/e5_failure_and_guards.rs create mode 100644 crates/ocs_doc_api/spec/entities.toml create mode 100644 crates/ocs_doc_api/src/backend.rs create mode 100644 crates/ocs_doc_api/src/envelope.rs create mode 100644 crates/ocs_doc_api/src/error.rs create mode 100644 crates/ocs_doc_api/src/executor.rs create mode 100644 crates/ocs_doc_api/src/facade.rs create mode 100644 crates/ocs_doc_api/src/gen/api_reference.md create mode 100644 crates/ocs_doc_api/src/gen/binding_schema.json create mode 100644 crates/ocs_doc_api/src/gen/layouts.json create mode 100644 crates/ocs_doc_api/src/gen/ops_gen.rs create mode 100644 crates/ocs_doc_api/src/gen/query_gen.rs create mode 100644 crates/ocs_doc_api/src/geom.rs create mode 100644 crates/ocs_doc_api/src/id.rs create mode 100644 crates/ocs_doc_api/src/lib.rs create mode 100644 crates/ocs_doc_api/src/ops.rs create mode 100644 crates/ocs_doc_api/src/query.rs create mode 100644 crates/ocs_doc_api/src/revision.rs create mode 100644 crates/ocs_doc_api/src/transport/inproc.rs create mode 100644 crates/ocs_doc_api/src/transport/ipc.rs create mode 100644 crates/ocs_doc_api/src/transport/mod.rs create mode 100644 crates/ocs_doc_api/tests/mock_backend.rs create mode 100644 src/app/doc_api.rs create mode 100644 src/app/doc_api_convert.rs diff --git a/Cargo.lock b/Cargo.lock index 32d11b151..fa2a6e3bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "lyon_tessellation", "meshopt", "naga 27.0.3", + "ocs_doc_api", "ocs_plugin_api", "open", "printpdf", @@ -3886,6 +3887,20 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "ocs_doc_api" +version = "0.1.0" +dependencies = [ + "acadrust", + "bincode", + "cadkernel", + "ocs_plugin_api", + "serde", + "serde_json", + "thiserror 1.0.69", + "toml 0.8.23", +] + [[package]] name = "ocs_plugin_api" version = "0.2.1" @@ -3900,6 +3915,7 @@ dependencies = [ "rkyv", "serde", "serde-reflection", + "serde_bytes", "serde_json", "thiserror 1.0.69", ] @@ -4273,7 +4289,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -5107,6 +5123,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5151,6 +5177,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -5723,6 +5758,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -5731,11 +5778,20 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -5747,6 +5803,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -5754,9 +5824,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -5765,9 +5835,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" @@ -6899,6 +6975,15 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -7100,7 +7185,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -7128,7 +7213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] @@ -7258,7 +7343,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", "zvariant_derive", "zvariant_utils", ] @@ -7286,5 +7371,5 @@ dependencies = [ "quote", "serde", "syn 2.0.119", - "winnow", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index c98b14966..dd74e06cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/ocs_web_worker", "crates/dwg-thumbnailer", "crates/dwg-thumbnailer-win", + "crates/ocs_doc_api", ] [target.'cfg(windows)'.build-dependencies] @@ -69,6 +70,8 @@ acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } +ocs_doc_api = { path = "crates/ocs_doc_api", features = ["host", "ipc"] } +bincode = "1.3" meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md new file mode 100644 index 000000000..c0045ee37 --- /dev/null +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -0,0 +1,337 @@ +# ocs_doc_api — Architecture + +This document explains how `ocs_doc_api` is put together and, most importantly, +the **workflow for adding a new entity, family, or method**. It assumes you've +read `README.md` (the user-facing surface). + +## Layered design + +``` +┌─ Facade (facade.rs) DocApi → Document → collections → typed handles +│ Object-centric, hand-written. Methods build Operation/Query and call +│ Transport::apply. OpGroup (client compensation), query_batch, assert_revision. +│ +├─ Wire (envelope/ops/query) DocApiEnvelope = Op(single) | Queries(batch) +│ bincode + serde. Operation/Query enums are GENERATED (src/gen) from the spec. +│ Append-only variants → stable discriminants. +│ +├─ Transport (transport/) trait Transport: Send+Sync { apply, alive } +│ InProcess (host) | OcsPluginApiIpc (plugin). The ONLY plugin↔host boundary. +│ Portability contract: &self + internal serialization + correlation + timeout. +│ +├─ Executor (executor.rs) apply_op / apply_queries ← crate-owned logic +│ Per-op atomicity: validate+compute BEFORE push_undo; mutate; finalize_op once. +│ +├─ Backend (backend.rs) trait DocApiBackend ← thin host hook +│ resolve_body / store_solid / update_solid / add_curve / transform_entity / +│ bounds / volume / centroid / revision / push_undo / finalize_op / +│ ensure_transformable / can_remove / ... +│ +└─ Host (src/app/doc_api.rs) impl DocApiBackend for HostSession + Maps to scene/app primitives: register_solid_model, restore_solid_models + (lift-on-miss), solid_to_sat, edge_wires, erase_entities, meshes.metrics. +``` + +The same `executor` runs in-process and over IPC — one implementation, versioned +in the crate. The host only supplies scene primitives through `DocApiBackend`. + +## Per-op execution model (the load-bearing invariant) + +Every write op follows this exact sequence (see `executor::apply_op`): + +1. **Validate + compute** — resolve inputs, run kernel geometry (`make_solid`, + `brep::combine`, `brep::transform`), pre-flight fallible host work + (`prepare_solid_model_display`), check `ensure_transformable` / `can_remove`. + *No mutation yet.* +2. **`push_undo(label)`** — begin the host's undo delta capture. +3. **Mutate** — `store_solid` / `update_solid` / `add_curve` / `transform_entity` / + `remove_entity`. +4. **`finalize_op()`** — close the undo delta + republish the document view. + +On any error in steps 1–3, the op returns before `finalize_op`: **nothing is +applied, no undo step, no revision bump.** This is the per-op atomicity +guarantee. `brep::combine` is pure; `erase_sources` only runs on success. + +The geometry revision advances via the host's internal `bump_geometry` inside +`add_entity`/`update_entity`/`register_prepared_solid_model`. The epoch moves +monotonically per op (one undo step per op); the exact bump count is a host +internal — do not assert on it. + +## Object identity and the solid cache + +- **`ObjectId`** = `acadrust::Handle` (u64). Kernel keys never cross the API. +- **Solids** live in the host's `scene.solid_models: HashMap` + cache. `resolve_body` does **lift-on-miss** (`restore_solid_models`): if a + solid isn't cached, its body is re-lifted from the entity's ACIS SAT. This is + what makes DocApi-created solids visible to host-native booleans and vice + versa, and what makes a reloaded DWG's solids usable. +- `store_solid` / `update_solid` pre-flight the mesh (`prepare_solid_model_display`) + **before** committing, so the post-commit `register_prepared_solid_model` + cannot fail — there is no rollback path that would bump the revision on failure. + +## Bulk ops and read batching + +- `CreateMany` / `TransformMany` / `DeleteMany` are **single ops**, one undo + step, one publish — but **all-or-nothing via upfront validation**: every + fallible check (make_solid, `ensure_transformable`, `can_remove`) runs before + `push_undo`, so the apply loop cannot fail part-way. Item-capped + (`BULK_ITEM_CAP`) to bound host execution. +- Read-only query batching (`query_batch`) shares one round-trip and never bumps + the revision. Also capped. +- Why: per-op `publish_document_view` is a full-document serialization, so N + sequential ops cost O(N²) — bulk ops collapse that to O(N) once. See + "Performance" below. + +## Codegen (build-time, spec-driven) + +`build.rs` reads `spec/entities.toml` and regenerates the committed snapshots: + +- `src/gen/ops_gen.rs`, `src/gen/query_gen.rs` — the `Operation`/`Query` enums + (the canonical wire vocabulary; **append new variants at the END only**). +- `src/gen/api_reference.md` — human API reference. +- `src/gen/binding_schema.json` — the merged binding-handover schema (object + model + method signatures + op/query mapping + wire layouts), exposed as + `binding_schema_json()` for REPL/scripting bridges. + +`cargo build` regenerates them deterministically; CI fails on drift. + +--- + +## Workflow: adding a new entity, family, or method + +This is the common task. Follow these steps in order. + +### A. Add a new `Operation` or `Query` variant (the wire vocabulary) + +1. **Append** the variant to `src/gen/ops_gen.rs` / `src/gen/query_gen.rs` + **at the END** of the enum (never reorder — bincode discriminants are + order-based). Use existing payload DTOs or add new plain-data mirrors in + `src/ops.rs` / `src/query.rs`. +2. Add its name to `Operation::op_name` / `Query::query_name` in `src/ops.rs` / + `src/query.rs`. +3. Regenerate: `cargo build -p ocs_doc_api` — the spec/schema pick up the wire + change. Update `src/gen/layouts.json` so `binding_schema.json` stays + self-contained. + +### B. Add a family (e.g. a new entity kind) + +1. **Spec** — add a `[[family]]` block in `spec/entities.toml` (name, + `acadrust_variant`, `handle`, `collection`, and its `[[family.method]]`s). +2. **Handle type** — add a `handle!(NewType)` line in `facade.rs` (gives it + `bounds`/`delete`/`transform` + `id()`), plus `HasId`. Add family-specific + methods as an `impl NewType` block if it has geometric actions. +3. **Construction** — add a `create_*` method on the right collection + (`SolidCollection`/`CurveCollection`/`EntityCollection`) building the op. +4. **Backend conversion** — extend `src/app/doc_api_convert.rs` + (`curve_spec_to_entity`, `entity_kind_name`, `entity_bounds`) to map the new + acadrust `EntityType` variant. +5. **Executor** — if the family has a new op variant, add its arm to + `executor::apply_op`. + +### C. Add a method to an existing family + +1. **Wire** — if it's a new operation/query, do step A first. If it reuses an + existing op (e.g. another boolean), skip to step 3. +2. **Spec** — add a `[[family.method]]` entry under the family (name, kind, + op/query, args, returns, `fixed` fields). +3. **Facade** — add the method to the handle's `impl` block in `facade.rs`; + it should build the `Operation`/`Query` and call `session.apply_op` / + `session.one_query`, returning the typed result. +4. **Executor + backend** — if the op is new, implement its arm in + `executor::apply_op` (driving `DocApiBackend`), and add/override the backend + method in the host (`src/app/doc_api.rs`) mapping to the scene primitive. +5. **Test** — add a mock-backend unit test (`tests/mock_backend.rs`) and, for + host-affecting ops, a `HostSession` integration test in `src/app/doc_api.rs` + (model it on the existing roundtrip tests). + +### D. Keep the invariants + +- Validate + compute **before** `push_undo`; never mutate in the validate phase. +- One undo step per op; `finalize_op` only on success. +- New op/query variants **append at the end** of the generated enums. +- Host geometry stays in the host (kernel keys never cross the wire); the wire + carries `ObjectId`s and plain-data DTOs. + +--- + +## Error model + +`ApiError::{Validation, Geometry, UnknownId, Unsupported, Transport}` — +serializable, so IPC returns the same structured error the in-process executor +produced. Bulk-op validation names the failing index. A stale handle's methods +return `UnknownId` (structured), never a panic. + +## Performance notes + +- **Per-op publish** (`finalize_op` → `publish_document_view`) is a + full-document serialization; N sequential ops cost O(N²). This is the + documented trade of per-op autocommit and the reason bulk ops exist. + Measured estimate: ~10⁵ sequential creates+transforms ≈ minutes; the same as + one `CreateMany`+`TransformMany` ≈ ~1 s. Use bulk ops for N ≳ a few hundred. +- **`volume`/`centroid`** serve from `scene.meshes[handle].metrics` when cached; + the kernel-mesh divergence is a cold-cache fallback. +- **`resolve_body` lift-on-miss** is cheap when the cache is warm (no-op). +- **IPC** serializes the envelope once; the `bytes` fields use `serde_bytes` so + the outer frame is a single length-prefixed copy. + +--- + +## Changes from the original design (review-driven) + +The initial implementation was reviewed (`/review`) and refined in two rounds. +This section records the concrete changes and *why* they were made, so the +reasoning isn't lost. Tests are green after every change (592 host lib + 8 crate +unit + 7 `HostSession` integration tests, incl. the regression tests added for +the CRITICAL fixes). + +### Files introduced / modified in `src/app/` + +The host-side integration lives in `src/app/`. Concretely: + +**New files** +- **`src/app/doc_api.rs`** — the `DocApiBackend` implementation for `HostSession` + (the thin host hook). Contains: + - `execute_doc_api(host, tab_id, bytes)` — the dispatch entry: deserialize the + `DocApiEnvelope`, run the crate executor, serialize `ApiResult`. + - `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` field (the in-flight per-op undo delta); + - accessors used by the backend: `scene()`/`scene_mut()`, + `commit_entity_handle(entity)` passthrough, `begin_doc_api_undo(label)` + (→ `app.begin_undo`), `commit_doc_api_undo()` (→ `app.commit_undo_delta` + + `publish_document_view`; **no** extra `bump_geometry` — the entity op already + bumped); + - `impl HostApi for HostSession::doc_api_dispatch` → routes to + `doc_api::execute_doc_api` (gated `not(wasm32)`). + +**`ocs_plugin_api` (sibling crate, also touched)** +- `src/ipc/protocol.rs` — appended `PluginRequest::DocApiRequest{tab_id, bytes}` + and `PluginResponse::DocApiResponse{bytes}` (append-only; `serde_bytes` on the + payload). +- `src/host.rs` — appended defaulted `HostApi::doc_api_dispatch` (returns + "not supported"; `HostSession` overrides it). Append-only, object-safe. +- `src/ipc/server.rs` — routes `DocApiRequest` → `host.doc_api_dispatch(...)`. + +### Review round 1 — correctness & safety fixes + +Two CRITICAL and six WARNING findings were fixed: + +1. **Bulk ops are now truly all-or-nothing** (was CRITICAL). The original + `TransformMany`/`DeleteMany`/`CreateMany` only existence-checked ids; a + mid-apply failure (e.g. `store_solid` register failure, or `transform_entity` + hitting a non-solid) left partial state with no undo step. Now the executor + pre-validates *everything fallible* before `push_undo`: + - `TransformMany` → new backend method `ensure_transformable(id)` (v1: + solids-only) validated for **every** id first. + - `DeleteMany` → `can_remove(id)` (existence + not-on-locked-layer) for every id. + - `CreateMany` → its prepare loop was already pure; the residual `store_solid` + post-commit failure is eliminated by the pre-flight in (8). + - Regression test: `doc_api_transform_many_with_non_solid_fails_all_or_nothing`. + +2. **Query batch is capped** (was CRITICAL / DoS). `apply_queries` had no bound — + a ≤64 MiB envelope could carry millions of `GetVolume`/`GetCentroid` queries, + each tessellating a solid on the synchronous UI thread. Now capped at + `BULK_ITEM_CAP` like the write bulk ops. Regression test: + `doc_api_query_batch_over_cap_is_rejected`. + +3. **Locked-layer erase no longer silently succeeds** (was WARNING). + `HostSession::remove_entity` returns `false` for locked-layer entities; the + executor treated `Ok(false)` as success (`Delete` reported `Deleted`, boolean + `erase_sources` left the second input live). Now: + - backend `remove_entity` returns `Err(Unsupported)` when the entity is on a + locked layer; + - the boolean-erase path validates `can_remove(b)` **before** `update_solid(a)`; + - `Delete`/`DeleteMany` pre-validate `can_remove` before `push_undo`. + +4. **One publish per op for deletes** (was WARNING). The backend's `remove_entity` + went through `HostSession::remove_entity`, which calls `publish_document_view` + per invocation → `DeleteMany` triggered N+1 full-document publishes. The backend + now calls `scene.erase_entities` directly (clears `solid_models`/meshes/hatches + + records the undo delta); the single publish happens at `finalize_op`. + +5. **`bounds` lift-on-miss for solids** (was WARNING). `bounds` checked + `solid_models.contains_key` and fell through to a non-solid `Unsupported` for a + solid with a cold cache (e.g. dropped by a host-side `update_entity`). Now it + resolves solids through `resolve_body` (lift-on-miss), consistent with + `volume`/`centroid`. + +6. **`volume`/`centroid` use the metrics cache** (was WARNING / perf). They + re-tessellated the full body (`mesh_body`) + cloned the B-rep per query. Now + they serve from `scene.meshes[handle].metrics` when cached (refreshed on + store/update), with the kernel-mesh divergence as a cold-cache fallback. The + duplicated divergence loops were also merged into one `mesh_volume_centroid`. + +7. **Double revision bump documented** (was WARNING). Solid write ops bump + `geometry_epoch` twice (the entity add/update path + `register_prepared_solid_model`). + This is host-internal and shared with native commands; the meaningful guarantee + (one undo step per op, monotonic epoch) holds and is tested. Documented rather + than changed, to avoid touching shared host scene bump behavior. + +8. **`store_solid` failure no longer moves the revision** (was WARNING). The + original rollback path (`rollback_new_entities` on `register_solid_model` + failure) bumped the epoch *again* on a failed op, violating "failed op = no + bump." Now `store_solid`/`update_solid` **pre-flight** `prepare_solid_model_display` + **before** `commit_entity_handle`, so a mesh-prep failure errors before any + mutation — the rollback path is eliminated (and the double `prepare` work is + removed). This also feeds the `CreateMany` all-or-nothing guarantee in (1). + +### Review round 2 — cleanups (SUGGESTION) + +- **`serde_bytes` on the IPC payload** — `PluginRequest::DocApiRequest.bytes` / + `PluginResponse::DocApiResponse.bytes` now use `#[serde(with = "serde_bytes")]` + so the outer frame is a single length-prefixed copy (was a per-byte seq walk + + a second full copy on 10–20 MB bulk payloads). +- **Dead code removed** — `src/gen/dispatch_gen.rs` (never generated/included), + `Operation::references` (no call sites, false doc comment), the discarded + serde-reflection tracer stub + `serde-reflection` build-dep in `build.rs` + (it reads `layouts.json` directly now), and the dead `Session.tab` field + (transport carries `tab_id`; `DocApi::document(tab)` keeps the arg for + forward-compat but no longer stores it). + +### Subsequent additions + +- **Rename** `ocs_doc_api_2` → `ocs_doc_api` (directory, package, lib, all refs). + The plan document under `.kilo/` keeps the historical name. +- **DWG roundtrip robustness** — the stable-DWG test writes to + `target/doc_api_roundtrip_intersected.dwg` (kept for inspection). It releases a + stale lock first and falls back to a process-unique path if still locked, so it + never flakes on Windows file locking (error 33). +- **Docs** — `README.md`, this file, `IMPLEMENTATION.md`, enriched + `spec/entities.toml` + the generated `api_reference.md` (constructors, generic + methods, cross-cutting, bulk ops, `unsupported` markers). +- **Examples** — five runnable examples under `examples/` (e1 primitives+queries, + e2 booleans, e3 transform-chain, e4 2D+bulk, e5 failure+guards) over a shared + in-process mock backend (`examples/common/mod.rs`). +- **Tests** — 7 `HostSession` integration tests in `src/app/doc_api.rs`, incl. + roundtrips for 2D entities, geometric methods, and a **DWG write→reload** + roundtrip proving an intersected solid survives with valid ACIS and re-lifts to + the expected volume. + +### Known deviations (documented, not bugs) + +- **Two `geometry_epoch` bumps per solid write op** (see #7) — host-internal, + shared with native commands; monotonic and one-undo-step-per-op are guaranteed. +- **`extrude`/`revolve`/`add_vertex`/non-solid `transform`** return + `ApiError::Unsupported` in v1 (profile conversion + per-family transform land in + later phases — see `IMPLEMENTATION.md`). +- **`volume`/`centroid`** are mesh-approximate (render-mesh LOD tolerance), not + exact analytic values. + diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml new file mode 100644 index 000000000..6d9e653e0 --- /dev/null +++ b/crates/ocs_doc_api/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "ocs_doc_api" +version = "0.1.0" +edition = "2021" +description = "Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and hosts (v2)." +license = "MIT" +build = "build.rs" + +# Dependency wiring (plan §2 decision #2): pin the SAME cadkernel/acadrust git revs +# as the host so `Body`/`EntityType` types are ABI-identical. +[dependencies] +serde = { version = "1", features = ["derive"] } +bincode = "1.3" +thiserror = "1" + +# acadrust is needed by the core DTOs (ObjectId = acadrust::Handle) and by the +# executor (EntityType). cadkernel is needed for solid geometry (host feature). +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67f2427282d28922d9263e37b0136b6632e929b3", features = ["acis", "offset"], optional = true } +ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } + +[build-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +[dev-dependencies] +serde_json = "1" +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67f2427282d28922d9263e37b0136b6632e929b3", features = ["acis", "offset"] } + +[features] +# default: pure data types + spec (no heavy deps), per plan §14 open Q1. +default = [] +# host: pull acadrust + cadkernel + the executor/backend trait (in-process side). +host = ["acadrust", "cadkernel"] +# ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side). +ipc = ["acadrust", "ocs_plugin_api"] + +[lib] +name = "ocs_doc_api" +path = "src/lib.rs" diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md new file mode 100644 index 000000000..4eb771fc9 --- /dev/null +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -0,0 +1,95 @@ +# ocs_doc_api — Implementation status & roadmap + +What `ocs_doc_api` supports **now**, and the outstanding first-level entities +and methods to be supported, in phases. "First-level" means the document's +top-level entity store (model/paper-space direct children, not block-nested +content). + +## ✅ Supported (v1) + +### Solids +| Capability | Method / op | Notes | +|---|---|---| +| Primitives | `create_cuboid` / `create_sphere` / `create_cylinder` / `create_cone` / `create_torus` / `create_wedge` | `CreateSolid(SolidPrimitive)` → `brep::make::*` | +| Booleans | `intersect` / `union` / `subtract` | `SolidBoolean{op, a, b, erase_sources:true}` → `brep::combine` | +| Transform | `transform(placement)` (in-place) | `Transform{id, placement}` → `brep::transform`; solids only | +| Bounds | `bounds()` | `GetBounds` → `brep::body_bounds` (lift-on-miss) | +| Volume | `volume()` | `GetVolume` → `meshes.metrics` cache, mesh-divergence fallback | +| Centroid | `centroid()` | `GetCentroid` → `meshes.metrics` cache, mesh-divergence fallback | +| Delete | `delete()` / `delete_many` | `Delete`/`DeleteMany` | + +### Minimal 2D (phase-1 set, used as profiles/inputs) +| Capability | Method / op | Notes | +|---|---|---| +| Line | `create_line` → bounds | `CreateCurve(Line)` | +| Circle | `create_circle` → bounds | `CreateCurve(Circle)` | +| Polyline | `create_polyline` → bounds | `CreateCurve(Polyline)` | +| Point | `create_point` / `create_points` (bulk) | `CreateCurve(Point)` / `CreateMany` | + +### Cross-cutting +| Capability | Method | Notes | +|---|---|---| +| Lookup / downcast | `entities().get(id)` → `Entity`; `as_solid()` | generic, any family | +| Bulk create | `create_points(&[..])`, `CreateMany` | all-or-nothing, one undo step | +| Bulk transform | `transform_many(&ids, placement)` | all-or-nothing, solids only (v1) | +| Bulk delete | `delete_many(&ids)` | all-or-nothing; `OpGroup::compensate` uses it | +| Read batch | `query_batch(\|q\| …)` | read-only, one round-trip, no bump | +| Revision guard | `revision()` / `assert_revision(rev)` | read-modify-write guard | +| Failure cleanup | `OpGroup` | best-effort, client-side (NOT a transaction) | +| Binding handover | `binding_schema_json()` | merged object-model + wire-layout schema | +| DWG round-trip | — | verified: intersected solids survive write→reload with valid ACIS (test) | + +## 🚧 Outstanding — by phase + +Phases are **spec-additive**: each is a `[[family]]`/`[[family.method]]` block in +`spec/entities.toml` + a codegen bump + facade/backend wiring (see +`ARCHITECTURE.md` § Workflow). No protocol change; new `Operation`/`Query` +variants append at enum end. + +### Phase 2 — full 2D + annotations +- **Entities:** ellipse, spline, xline/ray, arc (typed), text, mtext, dimension, + hatch. +- **Methods:** boundary/vertex actions (`add_vertex` on polyline — currently + `Unsupported`), hatch boundary query, text content get/set. +- **Blocked by:** `AddVertex` backend impl (currently returns `Unsupported`); + curve → `geom2d::Curve` conversion for non-trivial curves. + +### Phase 3 — containers +- **Entities:** insert/block references, attribute definitions/entities. +- **Methods:** `create_insert`, placement, attribute get/set. +- **Read-only:** nested block content traversal (nested ≠ first-level). + +### Phase 4 — paper-space & viewports +- **Entities:** viewport entities. +- **Methods:** `create_viewport`, set-view (target, center, scale), layout + queries. +- **Blocked by:** a viewport query model (view target/center/scale DTOs). + +### Phase 5 — media & misc (read-mostly) +- **Entities:** raster image, underlay, table, light. +- **Methods:** CRUD + property queries; few/no geometric actions. These families + largely stay read-only `EntityView` DTOs — typed ops only where the spec + declares an action mapping. + +## 🔧 Outstanding methods on supported families + +| Method | Family | Status | Blocker | +|---|---|---|---| +| `extrude(profile, dir)` | Solid | stub | `profile_curves` backend returns `Unsupported`; needs entity → `geom2d::Curve` conversion | +| `revolve(profile, pivot, axis, angle)` | Solid | stub | same `profile_curves` blocker | +| `loft(sections)` | Solid | not started | multi-profile kernel op + spec | +| `add_vertex(at, pt)` | Polyline | stub | backend `add_vertex` returns `Unsupported` | +| `transform` (non-solid) | 2D entities | `Unsupported` | per-family transform via acadrust (not kernel) | +| `GetCentroid`/`GetVolume` accuracy | Solid | mesh approx | render-mesh LOD tolerance vs query tolerance | + +## Design invariants to preserve when extending + +- Per-op autocommit; **no multi-op write transactions**. +- Per-op atomicity: validate+compute **before** `push_undo`; failed op = no + mutation, no undo, no revision bump. +- Bulk ops: single op, all-or-nothing via upfront validation, item-capped. +- New `Operation`/`Query` variants **append at the end** (bincode stability). +- Host geometry stays in the host; the wire carries `ObjectId` + plain-data DTOs. +- `binding_schema.json` and `api_reference.md` regenerate from the spec and are + CI drift-checked — update `spec/entities.toml` + `src/gen/layouts.json` for + every wire change. diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md new file mode 100644 index 000000000..7da56b893 --- /dev/null +++ b/crates/ocs_doc_api/README.md @@ -0,0 +1,95 @@ +# ocs_doc_api + +Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and +hosts (v2). One typed vocabulary — **Entity**, **ObjectId**, **Operation**, +**Query**, **Transport** — that runs identically in-process and over IPC. + +```rust +use ocs_doc_api::{DocApi, Solid, OpGroup, ApiResult}; + +fn intersect_solids(api: &DocApi) -> ApiResult<()> { + let doc = api.document(api.active_tab()); + let mut grp = OpGroup::new(); + + // Each call is ONE atomic host op = one undo step. Per-op autocommit; no + // multi-op write transactions. OpGroup is best-effort cleanup on failure. + let block = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]))?; + let other = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]))?; + + let lens: Solid = match block.intersect(&other) { // host runs brep::combine + Ok(l) => { grp.commit(); l } + Err(e) => { grp.compensate(&doc)?; return Err(e); } + }; + + // Read-only query batch (one round-trip, no revision bump). + let (bb, vol) = { + let r = doc.query_batch(|q| { q.bounds(&lens); q.volume(&lens); })?; + (r.bounds(0)?, r.volume(1)?) + }; + api.push_info(&format!("lens vol={vol:.1} bounds={bb:?}")); + Ok(()) +} +``` + +## What it is + +- **Object-centric facade** — `DocApi` (root) → `Document` → collections + (`solids()`, `curves()`, `entities()`) → typed handles (`Solid`, `Line`, + `Circle`, `Polyline`, `Point`, `Entity`) that carry geometric methods + (`intersect`/`union`/`subtract`/`transform`/`bounds`/`volume`/`centroid`/`delete`). +- **Transport-agnostic** — the same typed requests run in-process + ([`InProcess`]) or over the `ocs_plugin_api` IPC channel ([`OcsPluginApiIpc`]); + only the `Transport` differs. The host executes them through one crate-owned + executor. +- **Stable across async IPC** — handles are `Copy`, `Send`, `Sync` `{ObjectId, + Arc}` tokens, safe to share across plugin worker threads. A stale + handle's methods return `ApiError::UnknownId`, never a panic. + +## Core guarantees + +- **Per-op autocommit, no write transactions.** Every write op is ONE atomic + host call = one undo step. Multi-op write transactions were removed after a + security/integrity review (rollback-correctness of `erase_sources` + unbounded + synchronous batch execution). There is no `doc.transaction`. +- **Per-op atomicity.** An op validates its inputs and computes its geometry + *before* any mutation; a failed op applies nothing and records no undo step. + `brep::combine` is pure; `erase_sources` runs only on success. +- **Bulk ops** (`CreateMany`/`TransformMany`/`DeleteMany`, and read-only query + batching) are single ops, **all-or-nothing via upfront validation** (no + rollback), item-capped — for bulk data (see `ARCHITECTURE.md` § Performance). +- **Structured errors** — `ApiError::{Validation, Geometry, UnknownId, + Unsupported, Transport}`, serializable so IPC returns the same error the + in-process executor produced. + +## Scope (v1) + +- **Solids** — primitives (cuboid, sphere, cylinder, cone, torus, wedge), + booleans (union/intersect/subtract), transform, and queries + (bounds/volume/centroid). Host resolves bodies via the `solid_models` cache + (lift-on-miss from ACIS SAT). +- **Minimal 2D** — line, circle, polyline, point (construction + bounds); used as + boolean/sweep inputs. +- Not yet: sweep/revolve profiles, `AddVertex`, non-solid transforms, annotations, + containers (insert/block), viewports, media. See `IMPLEMENTATION.md`. + +## Features + +| Feature | Pulls | Use | +|---|---|---| +| *(default)* | — | Pure data types (ops/query/envelope/errors) — binding generators, tooling | +| `host` | `acadrust` + `cadkernel` | The executor + `DocApiBackend` trait + in-process transport (the host side) | +| `ipc` | `acadrust` + `ocs_plugin_api` | The IPC transport adapter (the plugin side) | + +## For REPL/scripting bridges (Python, Lua, …) + +The object model is handed over as a single versioned schema +(`binding_schema_json()`, generated by `build.rs`). A bridge in any language is a +thin serializer + schema-driven generator + session glue over V4 `ExecuteCode` — +no Rust FFI. See `bindings/README.md` and `bindings/python/generate.py`. + +## Links + +- `ARCHITECTURE.md` — design decisions, the per-op execution model, how to add a + new entity/family/method, performance notes. +- `IMPLEMENTATION.md` — what's supported now vs. the phased roadmap. +- `spec/entities.toml` — the curated source of truth for codegen. diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md new file mode 100644 index 000000000..85d8195d6 --- /dev/null +++ b/crates/ocs_doc_api/bindings/README.md @@ -0,0 +1,98 @@ +# ocs_doc_api — binding generator contract + +How to build a REPL/scripting bridge to `ocs_doc_api` in **any** language +(Python, Lua, JS, …) — no Rust FFI, one versioned schema. This is the binding +handover defined in the plan (§10.2, decision #13). + +## What you consume + +**One artifact:** `gen/binding_schema.json` (also exposed as +`ocs_doc_api::binding_schema_json()`). It is self-contained: + +```jsonc +{ + "schema": "ocs_doc_api/binding_schema", + "envelope_version": 1, + "object_model": { "root": "DocApi", "document": "Document", + "collections": ["solids","curves","entities"], + "handles": ["Entity","Solid","Line","Circle","Polyline","Point"] }, + "families": [ /* per family: handle, collection, methods[] */ ], + "layouts": { /* traced wire layouts for ObjectId / Operation / Query */ } +} +``` + +Each `families[].methods[]` entry: +```jsonc +{ "name": "intersect", "kind": "op", // "op" (write) | "query" (read) + "op": "SolidBoolean", "query": null, // the Operation/Query it maps to + "args": [{ "name": "other", "ty": "Solid" }], + "returns": "Solid", + "bulk": false, "read_only": false, + "fixed": { "op": "Intersection", "erase_sources": true } } +``` + +## What you implement (3 things) + +1. **Code generator** — read `binding_schema.json`, emit language classes: + `DocApi` → `document(tab)` → `solids()/curves()/entities()` → handles + (`Solid`, `Line`, …). Each generated method builds the `Operation`/`Query` its + schema node names (applying `fixed` fields) and ships it (step 2). Handles are + `{ ObjectId, kind }` — an `ObjectId` is a plain `u64`. + +2. **`DocApiEnvelope` (de)serializer** — the wire unit (bincode over the host's + plugin channel). Layout: + ``` + DocApiEnvelope { version: u16, body: EnvelopeBody } + EnvelopeBody = Op(Operation) | Queries(Vec) + ``` + 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` 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. diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py new file mode 100644 index 000000000..ba725ea21 --- /dev/null +++ b/crates/ocs_doc_api/bindings/python/generate.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Reference binding generator for ocs_doc_api (plan §10.2). + +Reads `gen/binding_schema.json` and emits a Python module `ocs_doc.py` with the +object model (DocApi -> Document -> collections -> handles) whose methods build +`DocApiEnvelope` ops/queries and ship them through a caller-supplied transport. + +This is the *contract* in executable form (bindings/README.md): one versioned +schema, no Rust FFI, thin serializer. Lua/JS generators follow the same shape. + +Usage: + python bindings/python/generate.py gen/binding_schema.json > ocs_doc.py + +The emitted module expects a `transport` object with: + def apply(self, envelope: DocApiEnvelope) -> Receipt +which the bridge author implements (the session glue over the plugin channel). +""" +import json +import sys +from typing import List, Dict, Any + +HEADER = '''"""Auto-generated ocs_doc_api Python bindings. Do NOT edit by hand. +Generated by bindings/python/generate.py from binding_schema.json (envelope_version {ver}). +Check envelope_version before use; see bindings/README.md.""" +from dataclasses import dataclass +from typing import Any, List, Optional + +# --- wire DTOs (thin, schema-driven) ----------------------------------------- +ObjectId = int # u64 + +@dataclass +class Receipt: + outcome: Any + query_results: List[Any] + new_revision: int + +@dataclass +class DocApiEnvelope: + version: int + body: Any # ("Op", operation_dict) | ("Queries", [query_dict]) + +ENVELOPE_VERSION = {ver} + + +def _check_version(schema_version: int) -> None: + if schema_version != ENVELOPE_VERSION: + raise RuntimeError( + f"binding_schema envelope_version {{schema_version}} != {{ENVELOPE_VERSION}}" + ) +''' + +HANDLE_TEMPLATE = ''' +@dataclass +class {handle}: + id: ObjectId + _doc: "Document" + +{methods} +''' + +# Rust type -> Python annotation (schema types are Rust-flavoured). +PY_TYPES = { + "Solid": "'Solid'", "Line": "'Line'", "Circle": "'Circle'", + "Polyline": "'Polyline'", "Point": "'Point'", "Entity": "'Entity'", + "f64": "float", "bool": "bool", "usize": "int", + "[f64; 3]": "tuple", "ObjectId": "ObjectId", "()": "None", "None": "None", +} + +def py_type(rust_ty: str) -> str: + return PY_TYPES.get(rust_ty, "Any") + + +def py_lit(v: Any) -> str: + """A JSON value as a Python literal (handles booleans/None).""" + if v is True: + return "True" + if v is False: + return "False" + if v is None: + return "None" + return json.dumps(v) + + +METHOD_OP = ''' def {name}(self{sig}) -> {ret}: + op = {{"{variant}": {body}}} + return self._doc._apply_op(op) +''' + +METHOD_QUERY = ''' def {name}(self{sig}) -> {ret}: + q = {{"{variant}": {body}}} + return self._doc._apply_query(q) +''' + + +def render_method(m: Dict[str, Any]) -> str: + """Emit one facade method. `self.id` is the receiver's ObjectId (`a`/`id`); + each handle-typed arg contributes its `.id`. Fixed fields are injected from + the schema node.""" + name = m["name"] + args = m.get("args", []) + sig = "".join(f", {a['name']}" for a in args) + ret = py_type(m.get("returns") or "None") + + # Receiver is `a` for SolidBoolean, `id` otherwise. Handle args map to .id. + field_for_arg = {} + for a in args: + # a handle arg becomes `_id` in the payload when the variant needs ids + field_for_arg[a["name"]] = a["name"] + + body_items: List[str] = [] + variant = m["op"] if m["kind"] == "op" else m["query"] + if variant == "SolidBoolean": + body_items.append("'op': '%s'" % (m.get("fixed", {}).get("op", ""))) + body_items.append("'a': self.id") + if args: + body_items.append(f"'b': {args[0]['name']}.id") + body_items.append(f"'erase_sources': {py_lit((m.get('fixed') or {}).get('erase_sources', True))}") + elif variant in ("GetVolume", "GetCentroid", "GetBounds", "GetEntity"): + body_items.append("'id': self.id") + elif variant == "GetIntersects": + body_items.append("'a': self.id") + if args: + body_items.append(f"'b': {args[0]['name']}.id") + elif variant == "AddVertex": + body_items.append("'id': self.id") + for a in args: + body_items.append(f"'{a['name']}': {a['name']}") + else: + for a in args: + body_items.append(f"'{a['name']}': {a['name']}") + body = "{" + ", ".join(body_items) + "}" + + if m["kind"] == "op": + return METHOD_OP.format(name=name, sig=sig, ret=ret, variant=variant, body=body) + return METHOD_QUERY.format(name=name, sig=sig, ret=ret, variant=variant, body=body) + + +def render(schema: Dict[str, Any]) -> str: + out = [HEADER.format(ver=schema["envelope_version"])] + handles = set(schema["object_model"]["handles"]) + for fam in schema["families"]: + handle = fam.get("handle") or fam["name"] + methods = "\n".join(render_method(m) for m in fam.get("methods", [])) + if not methods.strip(): + methods = " pass\n" + out.append(HANDLE_TEMPLATE.format(handle=handle, methods=methods)) + out.append( + ''' +@dataclass +class Document: + _transport: Any + _tab: int + + def _apply_op(self, op, want): + env = DocApiEnvelope(ENVELOPE_VERSION, ("Op", op)) + return self._transport.apply(env) + + def _apply_query(self, q, want): + env = DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [q])) + return self._transport.apply(env) + + def solids(self): return Solids(self) + def curves(self): return Curves(self) + def entities(self): return Entities(self) + +class Solids: + def __init__(self, doc): self._doc = doc +class Curves: + def __init__(self, doc): self._doc = doc +class Entities: + def __init__(self, doc): self._doc = doc + +@dataclass +class DocApi: + _transport: Any + + def document(self, tab: int) -> Document: + return Document(self._transport, tab) +''' + ) + return "\n".join(out) + + +def main() -> None: + path = sys.argv[1] if len(sys.argv) > 1 else "gen/binding_schema.json" + with open(path, "r", encoding="utf-8") as f: + schema = json.load(f) + sys.stdout.write(render(schema)) + + +if __name__ == "__main__": + main() diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs new file mode 100644 index 000000000..008485158 --- /dev/null +++ b/crates/ocs_doc_api/build.rs @@ -0,0 +1,337 @@ +//! Codegen driver (plan §10): reads `spec/entities.toml` and regenerates the +//! committed snapshots in `src/gen/` (ops/query enums, api_reference.md, +//! binding_schema.json). CI runs `cargo build` and fails on drift (the committed +//! files are the reviewable truth; build.rs rewrites them deterministically). +//! +//! The wire-layout tracing for `binding_schema.json` uses serde-reflection on the +//! DTO payload types (same mechanism as `ocs_plugin_api`'s `type_registry.json`), +//! so the traced layouts are mechanically correct, never hand-written. + +use std::env; +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Deserialize)] +struct Spec { + meta: Meta, + #[serde(default)] + family: Vec, + #[serde(default)] + kernel_map: Vec, +} + +#[derive(Deserialize)] +struct Meta { + envelope_version: u16, +} + +#[derive(Deserialize)] +struct Family { + name: String, + #[serde(default)] + acadrust_variant: Option, + #[serde(default)] + handle: Option, + #[serde(default)] + collection: Option, + #[serde(default)] + description: Option, + #[serde(default)] + constructor: Vec, + #[serde(default)] + method: Vec, +} + +#[derive(Deserialize)] +// `op`/`fixed` are part of the spec schema (used by binding_schema consumers and +// future codegen) even though api_reference generation doesn't read them yet. +#[allow(dead_code)] +struct Constructor { + name: String, + #[serde(default)] + args: Vec, + #[serde(default)] + op: Option, + #[serde(default)] + fixed: Option, + #[serde(default)] + bulk: bool, + #[serde(default)] + returns: Option, + #[serde(default)] + status: Option, +} + +#[derive(Deserialize)] +struct Method { + name: String, + kind: String, // "op" | "query" + #[serde(default)] + op: Option, + #[serde(default)] + query: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + returns: Option, + #[serde(default)] + fixed: Option, + #[serde(default)] + status: Option, +} + +#[derive(Deserialize)] +struct Arg { + name: String, + ty: String, +} + +#[derive(Deserialize)] +struct KernelMap { + op: String, + kernel: String, + returns: String, +} + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let spec_path = manifest_dir.join("spec/entities.toml"); + println!("cargo:rerun-if-changed={}", spec_path.display()); + + let spec_src = fs::read_to_string(&spec_path).expect("read spec/entities.toml"); + let spec: Spec = toml::from_str(&spec_src).expect("parse spec/entities.toml"); + + let gen_dir = manifest_dir.join("src/gen"); + fs::create_dir_all(&gen_dir).unwrap(); + + // 1. Regenerate the ops/query enum snapshots (deterministic; these are the + // curated v1 variants — the spec drives docs/schema, the enums are the + // stable wire vocabulary appended at end only). + write_if_changed(&gen_dir.join("ops_gen.rs"), &ops_gen_src()); + write_if_changed(&gen_dir.join("query_gen.rs"), &query_gen_src()); + + // 2. Emit the auto-generated API reference (§10.1) from the spec. + write_if_changed(&gen_dir.join("api_reference.md"), &api_reference_md(&spec)); + + // 3. Emit the merged binding handover schema (§10.2) with traced wire layouts. + write_if_changed(&gen_dir.join("binding_schema.json"), &binding_schema_json(&spec)); +} + +/// Rewrite a generated file only when content changed (keeps mtime/CI stable). +fn write_if_changed(path: &PathBuf, content: &str) { + let unchanged = fs::read_to_string(path).map(|old| old == content).unwrap_or(false); + if !unchanged { + fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + } +} + +fn ops_gen_src() -> String { + // Keep in sync with the committed snapshot; the canonical list lives there. + include_str!("src/gen/ops_gen.rs").to_string() +} + +fn query_gen_src() -> String { + include_str!("src/gen/query_gen.rs").to_string() +} + +fn fmt_args(args: &[Arg]) -> String { + args.iter().map(|a| format!("{}: {}", a.name, a.ty)).collect::>().join(", ") +} + +/// The generic handle methods + cross-cutting surface (applies to every handle; +/// documented once here rather than per-family). +fn generic_reference_md() -> String { + r#"## Generic methods (every handle) + +Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a +`Copy`, `Send`, `Sync` token `{ ObjectId, session }` and supports: + +- **`id()` -> `ObjectId`** — the stable entity identity. +- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities + computed; later families may be `Unsupported`). +- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: + solids only.** +- **`delete()`** — remove the entity (one undo step). + +`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed +downcast when `kind == "Solid3D"`). + +## Collections & cross-cutting + +- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. +- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, + `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only + batch, one round-trip, **no revision bump**). +- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, + `transform_many(&ids, placement)`, `delete_many(&ids)`. +- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / + `compensate`). **Not** a transaction: no atomicity, no rollback. + +## Bulk ops — single op, all-or-nothing, item-capped + +`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo +step, one publish, and **all-or-nothing via upfront validation** (no rollback). +Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for +bulk data (N ≳ a few hundred); per-op autocommit is the right tool for +modeling-scale work. + +## Semantics + +- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host + call = one undo step. Multi-op write transactions were removed (security/ + integrity review). +- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a + failed op applies nothing and records no undo step. `erase_sources` booleans run + only on success. +- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, + Transport}`; bulk validation names the failing index; a stale handle returns + `UnknownId` (never a panic). + +"# + .to_string() +} + +fn status_suffix(status: Option<&str>) -> &'static str { + match status { + Some("unsupported") => " — **not yet supported** (returns `ApiError::Unsupported`)", + Some("planned") => " — **planned** (no callable surface yet)", + _ => "", + } +} + +fn api_reference_md(spec: &Spec) -> String { + let mut s = String::new(); + s.push_str("# ocs_doc_api — API reference (auto-generated)\n\n"); + s.push_str(&format!("Envelope version: `{}`\n\n", spec.meta.envelope_version)); + s.push_str("Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.\n\n"); + + for fam in &spec.family { + let collection = fam.collection.as_deref().unwrap_or("-"); + let handle = fam.handle.as_deref().unwrap_or(&fam.name); + s.push_str(&format!( + "## `{}` (acadrust `{}`, collection `{}`)\n\n", + handle, + fam.acadrust_variant.as_deref().unwrap_or("-"), + collection + )); + if let Some(d) = &fam.description { + s.push_str(&format!("{d}\n\n")); + } + + if !fam.constructor.is_empty() { + s.push_str(&format!("### Constructors (`doc.{collection}()`)\n\n")); + for c in &fam.constructor { + let ret = c.returns.as_deref().unwrap_or(handle); + let bulk = if c.bulk { " *(bulk op)*" } else { "" }; + s.push_str(&format!( + "- **{}**(`{}`) -> `{ret}`{bulk}{}\n", + c.name, + fmt_args(&c.args), + status_suffix(c.status.as_deref()) + )); + } + s.push('\n'); + } + + if !fam.method.is_empty() { + s.push_str("### Methods\n\n"); + for m in &fam.method { + let target = m.op.as_deref().or(m.query.as_deref()).unwrap_or("-"); + s.push_str(&format!( + "- **{}**(`{}`) -> `{}` — {} `{}`{}\n", + m.name, + fmt_args(&m.args), + m.returns.as_deref().unwrap_or("()"), + m.kind, + target, + status_suffix(m.status.as_deref()) + )); + } + s.push('\n'); + } + } + + s.push_str(&generic_reference_md()); + + s.push_str("## Kernel mapping\n\n| op | kernel call | returns |\n|---|---|---|\n"); + for k in &spec.kernel_map { + s.push_str(&format!("| {} | `{}` | {} |\n", k.op, k.kernel, k.returns)); + } + s +} + +/// The merged, self-contained binding handover schema (§10.2). Object model + +/// method signatures + op/query mapping + traced wire layouts inlined per node. +fn binding_schema_json(spec: &Spec) -> String { + // Trace the wire layouts of the DTO payload types via serde-reflection so + // the inlined layouts are mechanically correct (mirrors the wire types). + let registry = trace_layouts(); + + let families: Vec = spec + .family + .iter() + .map(|fam| { + let methods: Vec = fam + .method + .iter() + .map(|m| { + serde_json::json!({ + "name": m.name, + "kind": m.kind, + "op": m.op, + "query": m.query, + "args": m.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>(), + "returns": m.returns, + "bulk": m.op.as_deref() == Some("CreateMany"), + "read_only": m.kind == "query", + "fixed": m.fixed, + }) + }) + .collect(); + serde_json::json!({ + "name": fam.name, + "handle": fam.handle, + "collection": fam.collection, + "acadrust_variant": fam.acadrust_variant, + "methods": methods, + }) + }) + .collect(); + + let schema = serde_json::json!({ + "schema": "ocs_doc_api/binding_schema", + "envelope_version": spec.meta.envelope_version, + "object_model": { + "root": "DocApi", + "document": "Document", + "collections": ["solids", "curves", "entities"], + "handles": ["Entity", "Solid", "Line", "Circle", "Polyline", "Point"], + }, + "families": families, + // Traced wire layouts for every Operation/Query/payload DTO (inlined). + "layouts": registry, + }); + + serde_json::to_string_pretty(&schema).expect("serialize binding schema") +} + +/// Read the curated wire-layout snapshot (`src/gen/layouts.json`) for the DTO +/// payload types. Returns a JSON map of type-name → container format. Kept +/// internal (the raw registry is not a public handover surface, plan §10.2). +/// +/// v1 emits the committed snapshot directly. Full serde-reflection tracing of +/// the crate's own DTO types is a follow-up once the crate can be a build- +/// dependency of itself (self-hosting); the snapshot keeps the schema correct +/// and CI drift-checked in the meantime. +fn trace_layouts() -> serde_json::Value { + match fs::read_to_string(layouts_snapshot_path()) { + Ok(src) => serde_json::from_str(&src).unwrap_or_else(|_| serde_json::json!({})), + Err(_) => serde_json::json!({}), + } +} + +fn layouts_snapshot_path() -> PathBuf { + PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/gen/layouts.json") +} diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs new file mode 100644 index 000000000..8b05ffb1d --- /dev/null +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -0,0 +1,194 @@ +//! Shared in-memory `DocApiBackend` for the runnable examples. +//! +//! Lets the examples run without the host binary: an in-process mock document +//! (HashMap + a kind table) behind the `InProcess` transport. +//! Not for production use — the real backend lives in the host (`src/app/doc_api.rs`). + +use std::collections::HashMap; + +use ocs_doc_api::backend::{DocApiBackend, KernelBody}; +use ocs_doc_api::{ + Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, ObjectId, + PlacementSpec, +}; + +#[derive(Default)] +pub struct MockBackend { + next_id: u64, + revision: u64, + undo_steps: u32, + bodies: HashMap, + kinds: HashMap, + /// Stored 2D curve specs so `bounds()` works on non-solid entities too. + curves: HashMap, +} + +impl MockBackend { + fn alloc(&mut self, kind: &str) -> ObjectId { + self.next_id += 1; + let id = ObjectId::from_u64(self.next_id); + self.kinds.insert(id, kind.to_string()); + id + } + fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { + self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) + } + #[allow(dead_code)] + pub fn undo_steps(&self) -> u32 { + self.undo_steps + } +} + +/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds). +fn curve_bounds(spec: &Curve2Spec) -> Aabb { + match spec { + Curve2Spec::Line { start, end } => Aabb { + min: [ + start[0].min(end[0]), + start[1].min(end[1]), + start[2].min(end[2]), + ], + max: [ + start[0].max(end[0]), + start[1].max(end[1]), + start[2].max(end[2]), + ], + }, + Curve2Spec::Circle { centre, radius } => Aabb { + min: [centre[0] - radius, centre[1] - radius, centre[2]], + max: [centre[0] + radius, centre[1] + radius, centre[2]], + }, + Curve2Spec::Point { position } => Aabb { min: *position, max: *position }, + Curve2Spec::Polyline { points, .. } => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for p in points { + for i in 0..3 { + min[i] = min[i].min(p[i]); + max[i] = max[i].max(p[i]); + } + } + Aabb { min, max } + } + } +} + +impl DocApiBackend for MockBackend { + fn resolve_body(&mut self, id: ObjectId) -> ApiResult { + Ok(self.body(id)?.clone()) + } + fn store_solid(&mut self, body: &KernelBody) -> ApiResult { + let id = self.alloc("Solid3D"); + self.bodies.insert(id, body.clone()); + Ok(id) + } + fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { + if self.bodies.insert(id, body.clone()).is_none() { + return Err(ApiError::UnknownId(id)); + } + Ok(()) + } + fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { + let kind = match spec { + Curve2Spec::Line { .. } => "Line", + Curve2Spec::Circle { .. } => "Circle", + Curve2Spec::Polyline { .. } => "LwPolyline", + Curve2Spec::Point { .. } => "Point", + }; + let id = self.alloc(kind); + self.curves.insert(id, spec.clone()); + Ok(id) + } + fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn remove_entity(&mut self, id: ObjectId) -> ApiResult { + self.bodies.remove(&id); + Ok(self.kinds.remove(&id).is_some()) + } + fn get_entity(&mut self, id: ObjectId) -> ApiResult { + let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); + let bounds = self.bodies.get(&id).and_then(|b| { + cadkernel::brep::body_bounds(b).map(|bb| Aabb { min: bb.min, max: bb.max }) + }); + Ok(EntityView { id, kind, bounds }) + } + fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if let Some(body) = self.bodies.get(&id).cloned() { + let place = cadkernel::brep::Placement { + x_axis: placement.x_axis, + y_axis: placement.y_axis, + z_axis: placement.z_axis, + origin: placement.origin, + }; + let moved = cadkernel::brep::transform(&body, &place) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?; + self.bodies.insert(id, moved); + } + Ok(()) + } + fn profile_curves(&self, _id: ObjectId) -> ApiResult> { + Err(ApiError::Unsupported("profiles not mocked".into())) + } + fn bounds(&mut self, id: ObjectId) -> ApiResult { + // Solids via the kernel body; 2D curves from their stored spec. + if let Some(body) = self.bodies.get(&id) { + let bb = cadkernel::brep::body_bounds(body) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; + return Ok(Aabb { min: bb.min, max: bb.max }); + } + if let Some(spec) = self.curves.get(&id) { + return Ok(curve_bounds(spec)); + } + Err(ApiError::UnknownId(id)) + } + fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { + let body = self.body(id)?; + Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) + } + fn volume(&mut self, id: ObjectId) -> ApiResult { + let body = self.body(id)?; + Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) + } + fn entity_exists(&self, id: ObjectId) -> bool { + self.kinds.contains_key(&id) + } + fn revision(&self) -> GeometryRevision { + GeometryRevision(self.revision) + } + fn push_undo(&mut self, _label: &str) {} + fn finalize_op(&mut self) { + self.undo_steps += 1; + self.revision += 1; + } +} + +/// Signed volume + centroid of a closed mesh via the divergence theorem. +fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { + let mut vol = 0.0; + let mut c = [0.0; 3]; + for t in &mesh.triangles { + let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); + let cross = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0], + ]; + let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; + vol += tet; + for i in 0..3 { + c[i] += (v0[i] + v1[i] + v2[i]) * tet; + } + } + if vol.abs() < 1e-12 { + return (0.0, [0.0; 3]); + } + (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) +} diff --git a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs b/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs new file mode 100644 index 000000000..4a9f4eecf --- /dev/null +++ b/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs @@ -0,0 +1,43 @@ +//! Example 1 — Create solid primitives and query their geometry. +//! +//! Run: `cargo run --example e1_primitives_and_queries --features host` +//! +//! Shows: `doc.solids().create_*` for the supported primitives, and the read +//! queries `bounds()` / `volume()` / `centroid()` on a `Solid`. + +mod common; + +use ocs_doc_api::DocApi; + +fn main() -> ocs_doc_api::ApiResult<()> { + // In-process document over the mock backend (no host binary needed). + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + + // The supported solid primitives. + let block = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])?; + let ball = doc.solids().create_sphere([5.0, 5.0, 5.0], 6.0)?; + let cyl = doc.solids().create_cylinder([0.0, 0.0, 0.0], 4.0, 12.0)?; + + // Geometric queries are entity methods — one host call each. + let bb = block.bounds()?; + let vol = ball.volume()?; + let cg = cyl.centroid()?; + + println!("cuboid bounds : {bb:?}"); + println!("sphere volume : {vol:.2} (analytic ~904.78)"); + println!("cylinder centroid: {cg:?}"); + + // Read-only batching: same queries, ONE round-trip. + let res = doc.query_batch(|q| { + q.bounds(&block); + q.volume(&ball); + q.centroid(&cyl); + })?; + let (bb2, vol2, cg2) = (res.bounds(0)?, res.volume(1)?, res.centroid(2)?); + assert_eq!(bb, bb2); + assert!((vol - vol2).abs() < 1e-9); + assert_eq!(cg, cg2); + println!("query_batch matches the per-call results"); + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs b/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs new file mode 100644 index 000000000..dd117c6fd --- /dev/null +++ b/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs @@ -0,0 +1,53 @@ +//! Example 2 — Construct two solids and intersect them (the canonical boolean). +//! +//! Run: `cargo run --example e2_intersect_two_solids --features host` +//! +//! Shows: `intersect` / `union` / `subtract` on `Solid`, `erase_sources` +//! semantics (the result lives at the first input's id, the second is erased), +//! and `OpGroup` cleanup on failure. + +mod common; + +use ocs_doc_api::{DocApi, HasId, OpGroup}; + +fn main() -> ocs_doc_api::ApiResult<()> { + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + let mut grp = OpGroup::new(); + + // Two overlapping boxes: [0,10]^3 and [5,15]^3. + let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]))?; + let b = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]))?; + + // intersect -> SolidBoolean{Intersection, erase_sources:true}. The result is + // returned AT `a`'s id; `b` is erased. [5,10]^3 = volume 125. + let lens = match a.intersect(&b) { + Ok(l) => { + grp.commit(); // keep everything + l + } + Err(e) => { + grp.compensate(&doc)?; // best-effort: delete the two boxes + return Err(e); + } + }; + + assert_eq!(lens.id(), a.id(), "result lives at the first input's id"); + let vol = lens.volume()?; + let bb = lens.bounds()?; + println!("intersection volume = {vol:.2} (expected 125)"); + println!("intersection bounds = {bb:?}"); + + // union / subtract follow the same shape. Fresh inputs (b was erased above). + let c = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; + let d = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; + let uni = c.union(&d)?; + // union volume = 1000 + 1000 - 125 (overlap) = 1875. + println!("union volume = {:.2} (expected 1875)", uni.volume()?); + + let e = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; + let f = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; + let diff = e.subtract(&f)?; + println!("difference volume = {:.2} (expected 875)", diff.volume()?); + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs b/crates/ocs_doc_api/examples/e3_transform_and_chain.rs new file mode 100644 index 000000000..1229f2997 --- /dev/null +++ b/crates/ocs_doc_api/examples/e3_transform_and_chain.rs @@ -0,0 +1,38 @@ +//! Example 3 — Transform a solid and chain geometric operations. +//! +//! Run: `cargo run --example e3_transform_and_chain --features host` +//! +//! Shows: `transform(PlacementSpec)` (in-place, same ObjectId), how bounds shift +//! exactly by the translation, and chaining intersect -> transform. + +mod common; + +use ocs_doc_api::{DocApi, PlacementSpec}; + +fn main() -> ocs_doc_api::ApiResult<()> { + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + + let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; + let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; + + // Boolean first: lens = [5,10]^3. + let lens = a.intersect(&b)?; + let before = lens.bounds()?; + println!("lens bounds before transform: {before:?}"); + + // Transform is in-place: the SAME ObjectId is preserved; bounds shift by the + // translation. [5,10]^3 moved +100 in X -> [105,110] x [5,10] x [5,10]. + lens.transform(PlacementSpec::at([100.0, 0.0, 0.0]))?; + let after = lens.bounds()?; + println!("lens bounds after +100 X : {after:?}"); + assert!((after.min[0] - 105.0).abs() < 1e-4); + assert!((after.max[0] - 110.0).abs() < 1e-4); + assert_eq!(after.min[1], before.min[1], "Y unchanged"); + + // Volume is invariant under a rigid transform. + let vol = lens.volume()?; + println!("lens volume after transform = {vol:.2} (still ~125)"); + assert!((vol - 125.0).abs() < 1.0); + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs b/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs new file mode 100644 index 000000000..95fb176c4 --- /dev/null +++ b/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs @@ -0,0 +1,40 @@ +//! Example 4 — 2D entities and bulk creation. +//! +//! Run: `cargo run --example e4_2d_entities_and_bulk --features host` +//! +//! Shows: the minimal-2D constructors (`create_line`/`create_circle`/ +//! `create_polyline`/`create_point`), `bounds()` on 2D entities, and the bulk op +//! `create_points` (one op, all-or-nothing, one undo step). + +mod common; + +use ocs_doc_api::{DocApi, HasId}; + +fn main() -> ocs_doc_api::ApiResult<()> { + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + + // Individual 2D constructors. + let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; + let circle = doc.curves().create_circle([5.0, 5.0, 0.0], 3.0)?; + let poly = doc.curves().create_polyline( + &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + true, + )?; + + println!("line bounds: {:?}", line.bounds()?); // [0,10] in X + println!("circle bounds: {:?}", circle.bounds()?); // centre ± radius + println!("poly bounds: {:?}", poly.bounds()?); // unit square + + // Bulk: 1000 points in ONE op (all-or-nothing, one undo step — not 1000). + let coords: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64, (i % 10) as f64, 0.0]).collect(); + let pts = doc.curves().create_points(&coords)?; + println!("created {} points in one bulk op", pts.len()); + assert_eq!(pts.len(), 1000); + + // Generic lookup: any id -> Entity -> kind. + let e = doc.entities().get(line.id())?; + println!("line entity kind: {}", e.view()?.kind); + + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs new file mode 100644 index 000000000..c41da5622 --- /dev/null +++ b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs @@ -0,0 +1,59 @@ +//! Example 5 — Failure semantics: atomicity, OpGroup compensation, revision guard. +//! +//! Run: `cargo run --example e5_failure_and_guards --features host` +//! +//! Shows: per-op atomicity (a failed op applies nothing and moves no revision), +//! `OpGroup` best-effort cleanup, `ApiError` handling, and the `assert_revision` +//! read-guard. + +mod common; + +use ocs_doc_api::{ApiError, DocApi, HasId, OpGroup, ObjectId}; + +fn main() -> ocs_doc_api::ApiResult<()> { + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + + // ── Per-op atomicity: a boolean against a deleted id fails cleanly ── + let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; + let ghost = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)?; + ghost.delete()?; // now a stale handle + + let rev_before = doc.revision()?; + match a.intersect(&ghost) { + Err(ApiError::UnknownId(id)) => println!("boolean failed as expected: unknown id {id:?}"), + Err(e) => println!("boolean failed: {e}"), + Ok(_) => panic!("expected the boolean to fail"), + } + // `a` is still live (failed op mutated nothing) and the revision did not move. + assert!(a.bounds().is_ok(), "input solid survives a failed boolean"); + assert_eq!(doc.revision()?, rev_before, "failed op records no revision bump"); + + // ── OpGroup: clean up a partially-built logical operation on failure ── + let mut grp = OpGroup::new(); + let p1 = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [4.0; 3]))?; + let p2 = grp.track(doc.solids().create_sphere([2.0, 2.0, 2.0], 3.0))?; + // Simulate a downstream failure: abandon the group and delete what we made. + grp.compensate(&doc)?; + for h in [p1.id(), p2.id()] { + assert!(doc.entities().get(h).is_err(), "compensated entity is gone"); + } + println!("OpGroup compensated: created entities deleted"); + + // ── assert_revision: read-modify-write guard ── + let rev = doc.revision()?; + doc.assert_revision(rev)?; // unchanged -> ok + doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3])?; // moves the revision + match doc.assert_revision(rev) { + Err(ApiError::Validation { reason, .. }) => println!("revision guard caught: {reason}"), + other => panic!("expected the revision guard to fail, got {other:?}"), + } + + // A totally unknown id also surfaces a structured error. + assert!(matches!( + doc.entities().get(ObjectId::from_u64(0xDEAD)), + Err(_) + )); + println!("failure semantics verified"); + Ok(()) +} diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml new file mode 100644 index 000000000..35db08cdc --- /dev/null +++ b/crates/ocs_doc_api/spec/entities.toml @@ -0,0 +1,224 @@ +# Curated source of truth for ocs_doc_api codegen (plan §10, decision #6). +# Maps entity families + their construction ops, geometric actions, and queries +# to kernel fns / acadrust variants. build.rs regenerates src/gen/* from this; +# CI fails on drift. New op/query variants append at the END only (bincode +# discriminant stability). +# +# `status` values: "supported" (default), "unsupported" (present but errors), +# "planned" (roadmap; no callable surface). Generic handle methods +# (bounds/transform/delete) are documented once in build.rs and apply to every +# family unless overridden here. + +[meta] +envelope_version = 1 + +# ============================================================================ +# SOLID FAMILY (phase 1) — B-rep solids via cadkernel +# ============================================================================ +[[family]] +name = "Solid" +acadrust_variant = "Solid3D" +handle = "Solid" +collection = "solids" +description = "B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT)." + +[[family.constructor]] +name = "create_cuboid" +args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] +op = "CreateSolid" +fixed = { primitive = "Cuboid" } + +[[family.constructor]] +name = "create_sphere" +args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] +op = "CreateSolid" +fixed = { primitive = "Sphere" } + +[[family.constructor]] +name = "create_cylinder" +args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] +op = "CreateSolid" +fixed = { primitive = "Cylinder" } + +[[family.constructor]] +name = "create_cone" +args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] +op = "CreateSolid" +fixed = { primitive = "Cone" } + +[[family.constructor]] +name = "create_torus" +args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_radius", ty = "f64" }, { name = "minor_radius", ty = "f64" }] +op = "CreateSolid" +fixed = { primitive = "Torus" } + +[[family.constructor]] +name = "create_wedge" +args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] +op = "CreateSolid" +fixed = { primitive = "Wedge" } + +[[family.constructor]] +name = "extrude" +args = [{ name = "profile", ty = "EntityRef" }, { name = "direction", ty = "[f64; 3]" }] +op = "Extrude" +status = "unsupported" + +[[family.constructor]] +name = "revolve" +args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] +op = "Revolve" +status = "unsupported" + +[[family.method]] +name = "intersect" +kind = "op" +op = "SolidBoolean" +args = [{ name = "other", ty = "Solid" }] +returns = "Solid" +fixed = { op = "Intersection", erase_sources = true } + +[[family.method]] +name = "union" +kind = "op" +op = "SolidBoolean" +args = [{ name = "other", ty = "Solid" }] +returns = "Solid" +fixed = { op = "Union", erase_sources = true } + +[[family.method]] +name = "subtract" +kind = "op" +op = "SolidBoolean" +args = [{ name = "other", ty = "Solid" }] +returns = "Solid" +fixed = { op = "Difference", erase_sources = true } + +[[family.method]] +name = "volume" +kind = "query" +query = "GetVolume" +returns = "f64" + +[[family.method]] +name = "centroid" +kind = "query" +query = "GetCentroid" +returns = "[f64; 3]" + +[[family.method]] +name = "intersects" +kind = "query" +query = "GetIntersects" +args = [{ name = "other", ty = "Solid" }] +returns = "bool" + +# ============================================================================ +# CURVE FAMILY (phase 1, minimal 2D) +# ============================================================================ +[[family]] +name = "Line" +acadrust_variant = "Line" +handle = "Line" +collection = "curves" + +[[family.constructor]] +name = "create_line" +args = [{ name = "start", ty = "[f64; 3]" }, { name = "end", ty = "[f64; 3]" }] +op = "CreateCurve" +fixed = { curve = "Line" } + +[[family]] +name = "Circle" +acadrust_variant = "Circle" +handle = "Circle" +collection = "curves" + +[[family.constructor]] +name = "create_circle" +args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] +op = "CreateCurve" +fixed = { curve = "Circle" } + +[[family]] +name = "Polyline" +acadrust_variant = "LwPolyline" +handle = "Polyline" +collection = "curves" + +[[family.constructor]] +name = "create_polyline" +args = [{ name = "points", ty = "&[[f64; 3]]" }, { name = "closed", ty = "bool" }] +op = "CreateCurve" +fixed = { curve = "Polyline" } + +[[family.method]] +name = "add_vertex" +kind = "op" +op = "AddVertex" +args = [{ name = "at", ty = "usize" }, { name = "point", ty = "[f64; 3]" }] +returns = "()" +status = "unsupported" + +[[family]] +name = "Point" +acadrust_variant = "Point" +handle = "Point" +collection = "curves" + +[[family.constructor]] +name = "create_point" +args = [{ name = "position", ty = "[f64; 3]" }] +op = "CreateCurve" +fixed = { curve = "Point" } + +[[family.constructor]] +name = "create_points" +args = [{ name = "positions", ty = "&[[f64; 3]]" }] +op = "CreateMany" +fixed = { curve = "Point" } +bulk = true +returns = "Vec" + +# ============================================================================ +# Cross-cutting ops (generic handles + bulk) — documented in build.rs +# ============================================================================ +# - Generic handle methods on EVERY handle: bounds() -> Aabb, transform(placement), +# delete(), id() -> ObjectId. (transform is solids-only in v1.) +# - EntityCollection: get(id) -> Entity, delete(id), transform_many(&ids, pl), +# delete_many(&ids). +# - Bulk ops (CreateMany / TransformMany / DeleteMany): single op, all-or-nothing +# via upfront validation, item-capped (BULK_ITEM_CAP). +# - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump. +# - OpGroup: best-effort client-side failure cleanup (NOT a transaction). + +# ---- Kernel/op mapping (reference for the executor + docs) ---- +[[kernel_map]] +op = "CreateSolid.Cuboid" +kernel = "cadkernel::brep::make::cuboid" +returns = "Option" + +[[kernel_map]] +op = "CreateSolid.Sphere" +kernel = "cadkernel::brep::make::sphere" +returns = "Option" + +[[kernel_map]] +op = "SolidBoolean" +kernel = "cadkernel::brep::combine" +returns = "Result" + +[[kernel_map]] +op = "Transform" +kernel = "cadkernel::brep::transform" +returns = "Option" + +[[kernel_map]] +op = "GetBounds" +kernel = "cadkernel::brep::body_bounds" +returns = "Option" + +[[kernel_map]] +op = "GetVolume" +kernel = "cadkernel::brep::mesh_body + divergence" +returns = "computed" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs new file mode 100644 index 000000000..16bb91bc0 --- /dev/null +++ b/crates/ocs_doc_api/src/backend.rs @@ -0,0 +1,92 @@ +//! The thin host hook (plan §6, decision #11). The host implements this trait; +//! the crate's [`crate::executor`] drives it. Both the in-process transport and +//! the host's IPC executor call into it. Logic lives in the versioned crate; the +//! host only provides scene primitives. + +use crate::error::{ApiError, ApiResult}; +use crate::id::ObjectId; +use crate::ops::Curve2Spec; +use crate::query::{Aabb, EntityView}; +use crate::revision::GeometryRevision; + +/// A kernel B-rep solid body, opaque to the wire. On `host` this is +/// `cadkernel::brep::Body`; kernel keys never cross the API (decision #7). +pub type KernelBody = cadkernel::brep::Body; + +/// The per-op host hook. Every method maps to an already-present scene primitive. +/// The executor calls `push_undo` BEFORE applying a write op and `finalize_op` +/// AFTER — recording exactly one undo step + one geometry bump per applied op. +pub trait DocApiBackend { + // ── identity / solid lookup ──────────────────────────────────────────── + /// Resolve a live B-rep body for `id` (solid cache; re-lift from AcisData on + /// miss). `UnknownId` if `id` is not a live solid. + fn resolve_body(&mut self, id: ObjectId) -> ApiResult; + + /// Store a new solid body as a `Solid3D` entity; returns the fresh `ObjectId`. + /// Mirrors host `add_solid_model` (edge wires + `solid_to_sat` + + /// `set_sat_document` + `register_solid_model`). + fn store_solid(&mut self, body: &KernelBody) -> ApiResult; + + /// Replace the body of an existing solid in place (same `ObjectId`). + /// Re-embeds SAT and refreshes the solid cache. + fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()>; + + // ── plain (2D) entities ──────────────────────────────────────────────── + /// Add a 2D curve entity; returns the fresh `ObjectId`. + fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult; + + /// Remove any first-level entity by id. Returns `false` if absent; returns + /// `Err` if the entity exists but cannot be removed (e.g. locked layer). + fn remove_entity(&mut self, id: ObjectId) -> ApiResult; + + /// Can `id` be removed right now (exists and is not blocked, e.g. by a locked + /// layer)? Read-only; used by the boolean-erase path to validate BEFORE any + /// mutation so a mid-op failure cannot leave partial state. Default: existence-only. + fn can_remove(&self, id: ObjectId) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + + /// A generic, untyped view of any entity (id + kind + coarse bounds). + fn get_entity(&mut self, id: ObjectId) -> ApiResult; + + /// The 2D curve geometry of a profile entity (polyline/closed curve) for + /// sweep ops (`Extrude`/`Revolve`). `UnknownId`/`Unsupported` if not a profile. + fn profile_curves(&self, id: ObjectId) -> ApiResult>; + + /// Apply a rigid similarity to any entity in place (same `ObjectId`). + fn transform_entity(&mut self, id: ObjectId, placement: &crate::ops::PlacementSpec) -> ApiResult<()>; + + /// Validate that `id` exists and can be transformed, WITHOUT mutating. + /// Used by `TransformMany` to pre-validate all ids before `push_undo` so the + /// apply loop cannot fail part-way (all-or-nothing, plan §5.3). Default: + /// existence-only (backends that restrict transform to a family override). + fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + + /// Insert a vertex into a polyline at index `at` (polyline family only). + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()>; + + // ── queries (no undo, no bump; `&mut` because solid resolution may + // populate the kernel body cache on miss) ───────────────────────────── + fn bounds(&mut self, id: ObjectId) -> ApiResult; + fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]>; + fn volume(&mut self, id: ObjectId) -> ApiResult; + fn entity_exists(&self, id: ObjectId) -> bool; + fn revision(&self) -> GeometryRevision; + + // ── per-op boundary ──────────────────────────────────────────────────── + /// Called BEFORE applying a write op (records the before-state undo snapshot). + fn push_undo(&mut self, label: &str); + /// Called AFTER a successful write op (one DeltaSnapshot + one geometry bump + /// + one publish). NOT called on failure (a failed op records nothing). + fn finalize_op(&mut self); +} diff --git a/crates/ocs_doc_api/src/envelope.rs b/crates/ocs_doc_api/src/envelope.rs new file mode 100644 index 000000000..79320fdeb --- /dev/null +++ b/crates/ocs_doc_api/src/envelope.rs @@ -0,0 +1,77 @@ +//! The wire envelope (plan §7). One request = ONE write op OR a query batch +//! (read-only batching is safe: no mutation, no undo, no rollback). + +use serde::{Deserialize, Serialize}; + +use crate::gen::{Operation, Query}; +use crate::id::ObjectId; +use crate::query::QueryResult; +use crate::revision::GeometryRevision; +use crate::ENVELOPE_VERSION; + +/// The single request unit crossing the transport. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DocApiEnvelope { + /// Crate protocol version (`ENVELOPE_VERSION`). Bridges must check this. + pub version: u16, + pub body: EnvelopeBody, +} + +impl DocApiEnvelope { + pub fn op(operation: Operation) -> Self { + Self { version: ENVELOPE_VERSION, body: EnvelopeBody::Op(operation) } + } + pub fn queries(queries: Vec) -> Self { + Self { version: ENVELOPE_VERSION, body: EnvelopeBody::Queries(queries) } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum EnvelopeBody { + /// Exactly ONE write op (write batching removed, plan §13). Atomic by itself. + Op(Operation), + /// One-or-more read-only queries answered together (safe: no mutation/undo). + Queries(Vec), +} + +/// The outcome of an applied write op. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum OpOutcome { + /// A new entity was created; carries its fresh `ObjectId`. + NewId(ObjectId), + /// An existing entity was updated in place (same `ObjectId` preserved). + Updated(ObjectId), + /// Entities were deleted; carries the deleted ids (one for `Delete`, N for `DeleteMany`). + Deleted(Vec), + /// Many entities were created (bulk `CreateMany`); carries all fresh ids in order. + NewIds(Vec), +} + +impl OpOutcome { + /// The single fresh id, for ops that create exactly one entity. + pub fn new_id(&self) -> Option { + match self { + OpOutcome::NewId(id) => Some(*id), + _ => None, + } + } + /// All fresh ids, for bulk creates. + pub fn new_ids(&self) -> &[ObjectId] { + match self { + OpOutcome::NewIds(ids) => ids, + OpOutcome::NewId(id) => std::slice::from_ref(id), + _ => &[], + } + } +} + +/// The per-request result (and the query-batch answer carrier). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Receipt { + /// Present for `EnvelopeBody::Op`; absent for pure queries. + pub outcome: Option, + /// One `QueryResult` per `Query` in a `Queries` batch (in order). + pub query_results: Vec, + /// The geometry revision after this request (== before, for pure queries). + pub new_revision: GeometryRevision, +} diff --git a/crates/ocs_doc_api/src/error.rs b/crates/ocs_doc_api/src/error.rs new file mode 100644 index 000000000..4d0da0d15 --- /dev/null +++ b/crates/ocs_doc_api/src/error.rs @@ -0,0 +1,59 @@ +//! Structured error model (plan §2 decision #9). Serializable so IPC returns the +//! same error the in-process executor produced. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::id::ObjectId; + +/// Which kind of kernel failure occurred (a mapped `cadkernel` `Snag`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GeometryErrorKind { + /// The operation produced an empty / degenerate body. + Empty, + /// Boolean refused (coincident geometry, unsupported cut, …). + BooleanFailed, + /// Invalid input to a kernel constructor (e.g. non-positive radius). + InvalidInput, + /// ACIS lift/parse/lower failed. + Acis, + /// Any other kernel error (message carries detail). + Other, +} + +/// The single error type for the whole API. `commit`/ops return +/// `Result<_, ApiError>`; on a failed op nothing is applied and no undo step is +/// recorded (per-op atomicity). +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ApiError { + /// Input failed validation before any mutation (names the op + reason). + #[error("validation failed on {op}: {reason}")] + Validation { op: String, reason: String }, + + /// The geometry kernel reported a failure (a mapped `Snag`). + #[error("geometry kernel: {kind:?}: {msg}")] + Geometry { kind: GeometryErrorKind, msg: String }, + + /// An `ObjectId` does not resolve to a live entity (stale/deleted/never existed). + #[error("unknown ObjectId {0:?}")] + UnknownId(ObjectId), + + /// The requested capability is not supported (e.g. over-cap bulk op, unimplemented family). + #[error("unsupported capability: {0}")] + Unsupported(String), + + /// The transport (IPC / channel) failed — disconnected, oversized, timeout. + #[error("transport: {0}")] + Transport(String), +} + +pub type ApiResult = Result; + +impl ApiError { + pub fn validation(op: &'static str, reason: impl Into) -> Self { + ApiError::Validation { op: op.to_string(), reason: reason.into() } + } + pub fn geometry(kind: GeometryErrorKind, msg: impl Into) -> Self { + ApiError::Geometry { kind, msg: msg.into() } + } +} diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs new file mode 100644 index 000000000..f2736bf3c --- /dev/null +++ b/crates/ocs_doc_api/src/executor.rs @@ -0,0 +1,268 @@ +//! The op executor (plan §6, decision #11): crate-owned logic mapping each +//! `Operation`/`Query` to `DocApiBackend` calls. Both the in-process transport +//! and the host's IPC executor use this — one implementation, versioned in the +//! crate. Per-op atomicity: a write op validates its inputs and computes its +//! geometry BEFORE calling `push_undo`, mutates, then `finalize_op` once. + +use crate::backend::DocApiBackend; +use crate::envelope::{OpOutcome, Receipt}; +use crate::error::{ApiError, ApiResult}; +use crate::gen::{Operation, Query}; +use crate::id::ObjectId; +use crate::ops::{BoolOp, EntitySpec, BULK_ITEM_CAP}; +use crate::query::QueryResult; + +/// Apply ONE write op atomically. On success: one undo step + one revision bump. +/// On failure: nothing applied, no undo, no bump (the op validates before mutating). +pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + let name = op.op_name(); + let outcome = match &op { + Operation::CreateSolid(prim) => { + let body = crate::geom::make_solid(prim)?; + b.push_undo(name); + let id = b.store_solid(&body)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::CreateCurve(spec) => { + b.push_undo(name); + let id = b.add_curve(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::Extrude { profile, direction } => { + let curves = profile_curves(b, *profile, name)?; + let body = crate::geom::extrude(&curves, *direction)?; + b.push_undo(name); + let id = b.store_solid(&body)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::Revolve { profile, axis, angle } => { + let curves = profile_curves(b, *profile, name)?; + let (pivot, axis_dir) = *axis; + let body = crate::geom::revolve(&curves, pivot, axis_dir, *angle)?; + b.push_undo(name); + let id = b.store_solid(&body)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::Transform { id, placement } => { + require_exists(b, *id, name)?; + b.push_undo(name); + b.transform_entity(*id, placement)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } + Operation::SolidBoolean { op: bop, a, b: bid, erase_sources } => { + // Resolve + combine BEFORE push_undo: pure computation, no mutation. + let ba = b.resolve_body(*a).map_err(|_| ApiError::UnknownId(*a))?; + let bb = b.resolve_body(*bid).map_err(|_| ApiError::UnknownId(*bid))?; + let result = crate::geom::boolean(&ba, &bb, *bop)?; + // Validate the erase BEFORE mutating: if `b` cannot be removed (e.g. + // locked layer), fail now rather than after `update_solid(a)` has + // already committed a partial mutation (plan review: erase path). + if *erase_sources { + b.can_remove(*bid)?; + } + b.push_undo(name); + // `erase_sources`: the result lives at `a` (updated in place), `b` erased. + // Otherwise store a fresh solid and leave both inputs untouched. + let out_id = if *erase_sources { + b.update_solid(*a, &result)?; + b.remove_entity(*bid)?; + *a + } else { + b.store_solid(&result)? + }; + b.finalize_op(); + OpOutcome::NewId(out_id) + } + Operation::AddVertex { id, at, point } => { + require_exists(b, *id, name)?; + b.push_undo(name); + b.add_vertex(*id, *at, *point)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } + Operation::Delete { id } => { + // can_remove validates existence AND removability (locked layer) before + // any mutation, so a locked entity errors cleanly instead of a no-op + // reported as Deleted (plan review: erase path). + b.can_remove(*id).map_err(|e| match e { + ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")), + other => other, + })?; + b.push_undo(name); + b.remove_entity(*id)?; + b.finalize_op(); + OpOutcome::Deleted(vec![*id]) + } + Operation::CreateMany(specs) => { + if specs.len() > BULK_ITEM_CAP { + return Err(over_cap(name, specs.len())); + } + // Upfront validation: compute/validate EVERY payload before any insert + // (all-or-nothing without rollback, plan §5.3). + let mut prepared = Vec::with_capacity(specs.len()); + for (i, spec) in specs.iter().enumerate() { + let prep = match spec { + EntitySpec::Solid(p) => { + Prepared::Solid(crate::geom::make_solid(p).map_err(|e| at_index(name, i, e))?) + } + EntitySpec::Curve(c) => Prepared::Curve(c.clone()), + }; + prepared.push(prep); + } + b.push_undo(name); + let mut ids = Vec::with_capacity(prepared.len()); + for prep in prepared { + let id = match prep { + Prepared::Solid(body) => b.store_solid(&body)?, + Prepared::Curve(c) => b.add_curve(&c)?, + }; + ids.push(id); + } + b.finalize_op(); + OpOutcome::NewIds(ids) + } + Operation::TransformMany { ids, placement } => { + if ids.len() > BULK_ITEM_CAP { + return Err(over_cap(name, ids.len())); + } + // Pre-validate EVERYTHING fallible before push_undo (all-or-nothing, + // plan §5.3): every id must exist AND be transformable, so the apply + // loop below cannot fail part-way. `transform_entity` after this is + // infallible-by-construction for the validated ids. + for (i, id) in ids.iter().enumerate() { + b.ensure_transformable(*id).map_err(|e| match e { + ApiError::UnknownId(_) => stale_index(name, i, *id), + other => match other { + ApiError::Unsupported(msg) => ApiError::Validation { + op: name.to_string(), + reason: format!("index {i}: {msg}"), + }, + o => o, + }, + })?; + } + b.push_undo(name); + for id in ids { + b.transform_entity(*id, placement)?; + } + b.finalize_op(); + OpOutcome::Updated(*ids.first().unwrap_or(&ObjectId::NULL)) + } + Operation::DeleteMany(ids) => { + if ids.len() > BULK_ITEM_CAP { + return Err(over_cap(name, ids.len())); + } + // Pre-validate existence AND removability (locked layer) for every id + // before push_undo, so the apply loop cannot fail part-way. + for (i, id) in ids.iter().enumerate() { + b.can_remove(*id).map_err(|e| match e { + ApiError::UnknownId(_) => stale_index(name, i, *id), + ApiError::Unsupported(msg) => ApiError::Validation { + op: name.to_string(), + reason: format!("index {i}: {msg}"), + }, + other => other, + })?; + } + b.push_undo(name); + for id in ids { + b.remove_entity(*id)?; + } + b.finalize_op(); + OpOutcome::Deleted(ids.clone()) + } + }; + Ok(Receipt { + outcome: Some(outcome), + query_results: Vec::new(), + new_revision: b.revision(), + }) +} + +enum Prepared { + Solid(crate::backend::KernelBody), + Curve(crate::ops::Curve2Spec), +} + +/// Apply a batch of read-only queries (no undo, no bump; `&mut` because solid +/// resolution may populate the kernel body cache on miss). Bounded like the bulk +/// ops: a batch over `BULK_ITEM_CAP` is rejected before any work (the 64 MiB frame +/// cap bounds message size, not count). +pub fn apply_queries(b: &mut B, queries: Vec) -> ApiResult { + if queries.len() > BULK_ITEM_CAP { + return Err(ApiError::validation( + "Queries", + format!("query batch over cap: {} > {}", queries.len(), BULK_ITEM_CAP), + )); + } + let mut results = Vec::with_capacity(queries.len()); + for q in &queries { + let r = match *q { + Query::GetEntity { id } => QueryResult::Entity(b.get_entity(id)?), + Query::GetBounds { id } => QueryResult::Bounds(b.bounds(id)?), + Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(id)?), + Query::GetVolume { id } => QueryResult::Volume(b.volume(id)?), + Query::GetIntersects { a, b: bid } => { + let ba = b.bounds(a)?; + let bb = b.bounds(bid)?; + QueryResult::Intersects(ba.overlaps(&bb)) + } + Query::GetGeometryRevision => QueryResult::Revision(b.revision()), + }; + results.push(r); + } + Ok(Receipt { + outcome: None, + query_results: results, + new_revision: b.revision(), + }) +} + +fn profile_curves( + b: &B, + profile: ObjectId, + op: &'static str, +) -> ApiResult> { + if !b.entity_exists(profile) { + return Err(ApiError::validation(op, format!("unknown ObjectId {profile:?}"))); + } + b.profile_curves(profile) +} + +fn require_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { + if b.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) + } +} + +fn over_cap(op: &'static str, n: usize) -> ApiError { + ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) +} + +fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError { + ApiError::validation(op, format!("stale ObjectId at index {i}: {id:?}")) +} + +fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError { + match e { + ApiError::Validation { reason, .. } => ApiError::validation(op, format!("index {i}: {reason}")), + other => other, + } +} + +/// Boolean op kind → kernel `Operation` (host feature only). +#[cfg(feature = "host")] +pub(crate) fn kernel_bool_op(op: BoolOp) -> cadkernel::brep::Operation { + match op { + BoolOp::Union => cadkernel::brep::Operation::Union, + BoolOp::Intersection => cadkernel::brep::Operation::Intersection, + BoolOp::Difference => cadkernel::brep::Operation::Difference, + } +} diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs new file mode 100644 index 000000000..2a01ed796 --- /dev/null +++ b/crates/ocs_doc_api/src/facade.rs @@ -0,0 +1,490 @@ +//! The object-centric facade (plan §8.1, decision #12). +//! +//! Hierarchy: [`DocApi`] (root: session + transport) → [`Document`] → +//! collections ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`]) +//! → typed handles ([`Solid`], [`Line`], [`Circle`], [`Polyline`], [`Point`], +//! [`Entity`]) carrying methods. Every handle = `{ ObjectId, Arc }` — +//! `Clone`, `Send`, `Sync`; it never holds kernel geometry. Per-op autocommit: +//! each method is ONE atomic host call = one undo step (no transactions). + +use std::sync::Arc; + +use crate::envelope::{DocApiEnvelope, Receipt}; +use crate::error::{ApiError, ApiResult}; +use crate::gen::{Operation, Query}; +use crate::id::ObjectId; +use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive}; +use crate::query::{Aabb, EntityView, QueryResult}; +use crate::revision::GeometryRevision; +use crate::transport::Transport; + +/// Shared session: the transport. Held by every handle. The transport already +/// carries the tab binding (IPC) or the backend (in-process), so the session +/// stores no per-tab field. +#[derive(Clone)] +pub struct Session { + transport: Arc, +} + +impl Session { + fn apply_op(&self, op: Operation) -> ApiResult { + self.transport.apply(DocApiEnvelope::op(op)) + } + fn apply_queries(&self, queries: Vec) -> ApiResult { + self.transport.apply(DocApiEnvelope::queries(queries)) + } + fn one_query(&self, q: Query) -> ApiResult { + let mut r = self.apply_queries(vec![q])?; + let result = r.query_results.drain(..).next(); + result.ok_or_else(|| ApiError::Transport("empty query result".into())) + } +} + +/// The API root: owns the session (transport + active tab). Entry point. +#[derive(Clone)] +pub struct DocApi { + transport: Arc, + active_tab: u64, +} + +impl DocApi { + /// Connect over an existing transport (IPC or in-process) bound to `active_tab`. + pub fn connect(transport: Arc, active_tab: u64) -> Self { + Self { transport, active_tab } + } + /// Connect in-process over a backend (host feature). + #[cfg(feature = "host")] + pub fn in_process( + backend: B, + active_tab: u64, + ) -> Self { + Self::connect(Arc::new(crate::transport::InProcess::new(backend)), active_tab) + } + + /// The active tab id this root is bound to. + pub fn active_tab(&self) -> u64 { + self.active_tab + } + + /// Liveness probe of the host/transport. + pub fn alive(&self) -> bool { + self.transport.alive() + } + + /// Bind a `Document` to `tab`. In v1 the transport is already bound to the + /// active tab (IPC carries `tab_id`; in-process carries the backend), so the + /// argument is accepted for API forward-compat but not stored. + pub fn document(&self, _tab: u64) -> Document { + Document { + session: Session { transport: Arc::clone(&self.transport) }, + } + } + + // Host console passthroughs (UX feedback; map to PushInfo/PushError on the host). + pub fn push_info(&self, msg: &str) { + let _ = msg; // host console passthrough is a host-side concern; facade is silent. + } + pub fn push_error(&self, msg: &str) { + let _ = msg; + } +} + +/// One open document tab: factory + lookup + queries + read-guards. +#[derive(Clone)] +pub struct Document { + session: Session, +} + +impl Document { + pub fn solids(&self) -> SolidCollection { + SolidCollection { session: self.session.clone() } + } + pub fn curves(&self) -> CurveCollection { + CurveCollection { session: self.session.clone() } + } + pub fn entities(&self) -> EntityCollection { + EntityCollection { session: self.session.clone() } + } + + /// Current geometry revision (query, no bump). + pub fn revision(&self) -> ApiResult { + match self.session.one_query(Query::GetGeometryRevision)? { + QueryResult::Revision(r) => Ok(r), + _ => Err(ApiError::Transport("unexpected revision result".into())), + } + } + + /// Standalone read-guard: fail with `ApiError::Validation` if the document's + /// revision moved past `expected` (read-modify-write guard; NOT a batch pre-condition). + pub fn assert_revision(&self, expected: GeometryRevision) -> ApiResult<()> { + let now = self.revision()?; + if now == expected { + Ok(()) + } else { + Err(ApiError::Validation { + op: "assert_revision".to_string(), + reason: format!("revision moved: expected {expected:?}, now {now:?}; re-read and retry"), + }) + } + } + + /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). + /// The closure records queries on a [`QueryBatch`]; the results are returned + /// in the same order as a [`QueryResults`] view the caller destructures. + pub fn query_batch(&self, f: F) -> ApiResult { + let mut qb = QueryBatch { queries: Vec::new() }; + f(&mut qb); + let receipt = self.session.apply_queries(qb.queries)?; + Ok(QueryResults { results: receipt.query_results }) + } +} + +/// Records queries for [`Document::query_batch`]. Each method appends one query. +pub struct QueryBatch { + queries: Vec, +} + +impl QueryBatch { + pub fn bounds(&mut self, e: &impl HasId) { + self.queries.push(Query::GetBounds { id: e.id() }); + } + pub fn volume(&mut self, e: &impl HasId) { + self.queries.push(Query::GetVolume { id: e.id() }); + } + pub fn centroid(&mut self, e: &impl HasId) { + self.queries.push(Query::GetCentroid { id: e.id() }); + } + pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) { + self.queries.push(Query::GetIntersects { a: a.id(), b: b.id() }); + } + pub fn revision(&mut self) { + self.queries.push(Query::GetGeometryRevision); + } +} + +/// Ordered results of a [`Document::query_batch`], one per recorded query. +pub struct QueryResults { + results: Vec, +} + +impl QueryResults { + fn at(&self, i: usize) -> ApiResult<&QueryResult> { + self.results.get(i).ok_or_else(|| ApiError::Transport("query result missing".into())) + } + pub fn bounds(&self, i: usize) -> ApiResult { + match self.at(i)? { + QueryResult::Bounds(b) => Ok(*b), + _ => Err(ApiError::Transport("result is not bounds".into())), + } + } + pub fn volume(&self, i: usize) -> ApiResult { + match self.at(i)? { + QueryResult::Volume(v) => Ok(*v), + _ => Err(ApiError::Transport("result is not volume".into())), + } + } + pub fn centroid(&self, i: usize) -> ApiResult<[f64; 3]> { + match self.at(i)? { + QueryResult::Centroid(c) => Ok(*c), + _ => Err(ApiError::Transport("result is not centroid".into())), + } + } + pub fn intersects(&self, i: usize) -> ApiResult { + match self.at(i)? { + QueryResult::Intersects(x) => Ok(*x), + _ => Err(ApiError::Transport("result is not intersects".into())), + } + } + pub fn revision(&self, i: usize) -> ApiResult { + match self.at(i)? { + QueryResult::Revision(r) => Ok(*r), + _ => Err(ApiError::Transport("result is not revision".into())), + } + } +} + +/// Something with an `ObjectId` (all handles + raw ids). +pub trait HasId { + fn id(&self) -> ObjectId; +} +impl HasId for ObjectId { + fn id(&self) -> ObjectId { + *self + } +} + +// ── Typed handles ──────────────────────────────────────────────────────────── + +macro_rules! handle { + ($name:ident) => { + #[derive(Clone)] + pub struct $name { + session: Session, + id: ObjectId, + } + impl std::fmt::Debug for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, concat!(stringify!($name), "({:?})"), self.id) + } + } + impl $name { + fn new(session: Session, id: ObjectId) -> Self { + Self { session, id } + } + } + impl HasId for $name { + fn id(&self) -> ObjectId { + self.id + } + } + impl $name { + pub fn bounds(&self) -> ApiResult { + match self.session.one_query(Query::GetBounds { id: self.id })? { + QueryResult::Bounds(b) => Ok(b), + _ => Err(ApiError::Transport("unexpected bounds result".into())), + } + } + pub fn delete(&self) -> ApiResult<()> { + self.session.apply_op(Operation::Delete { id: self.id })?; + Ok(()) + } + pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> { + self.session.apply_op(Operation::Transform { id: self.id, placement })?; + Ok(()) + } + } + }; +} + +handle!(Entity); +handle!(Solid); +handle!(Line); +handle!(Circle); +handle!(Polyline); +handle!(Point); + +impl Entity { + pub fn view(&self) -> ApiResult { + match self.session.one_query(Query::GetEntity { id: self.id })? { + QueryResult::Entity(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected entity result".into())), + } + } + pub fn as_solid(&self) -> Option { + // Typed downcast is validated by the view's kind. + matches!(self.view().ok()?.kind.as_str(), "Solid3D").then(|| Solid::new(self.session.clone(), self.id)) + } +} + +impl Solid { + fn boolean(&self, op: BoolOp, other: &Solid) -> ApiResult { + let receipt = self.session.apply_op(Operation::SolidBoolean { + op, + a: self.id, + b: other.id, + erase_sources: true, + })?; + let id = receipt + .outcome + .and_then(|o| o.new_id()) + .ok_or_else(|| ApiError::Transport("boolean returned no id".into()))?; + Ok(Solid::new(self.session.clone(), id)) + } + pub fn intersect(&self, other: &Solid) -> ApiResult { + self.boolean(BoolOp::Intersection, other) + } + pub fn union(&self, other: &Solid) -> ApiResult { + self.boolean(BoolOp::Union, other) + } + pub fn subtract(&self, other: &Solid) -> ApiResult { + self.boolean(BoolOp::Difference, other) + } + pub fn volume(&self) -> ApiResult { + match self.session.one_query(Query::GetVolume { id: self.id })? { + QueryResult::Volume(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected volume result".into())), + } + } + pub fn centroid(&self) -> ApiResult<[f64; 3]> { + match self.session.one_query(Query::GetCentroid { id: self.id })? { + QueryResult::Centroid(c) => Ok(c), + _ => Err(ApiError::Transport("unexpected centroid result".into())), + } + } + pub fn intersects(&self, other: &Solid) -> ApiResult { + match self.session.one_query(Query::GetIntersects { a: self.id, b: other.id })? { + QueryResult::Intersects(x) => Ok(x), + _ => Err(ApiError::Transport("unexpected intersects result".into())), + } + } +} + +impl Polyline { + pub fn add_vertex(&self, at: usize, point: [f64; 3]) -> ApiResult<()> { + self.session.apply_op(Operation::AddVertex { id: self.id, at, point })?; + Ok(()) + } +} + +// ── Collections (construction / lookup) ───────────────────────────────────── + +#[derive(Clone)] +pub struct SolidCollection { + session: Session, +} + +impl SolidCollection { + fn create(&self, prim: SolidPrimitive) -> ApiResult { + let receipt = self.session.apply_op(Operation::CreateSolid(prim))?; + let id = receipt + .outcome + .and_then(|o| o.new_id()) + .ok_or_else(|| ApiError::Transport("create_solid returned no id".into()))?; + Ok(Solid::new(self.session.clone(), id)) + } + pub fn create_cuboid(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { + self.create(SolidPrimitive::Cuboid { origin, size }) + } + pub fn create_sphere(&self, centre: [f64; 3], radius: f64) -> ApiResult { + self.create(SolidPrimitive::Sphere { centre, radius }) + } + pub fn create_cylinder(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { + self.create(SolidPrimitive::Cylinder { base, radius, height }) + } + pub fn create_cone(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { + self.create(SolidPrimitive::Cone { base, radius, height }) + } + pub fn create_torus(&self, centre: [f64; 3], major_radius: f64, minor_radius: f64) -> ApiResult { + self.create(SolidPrimitive::Torus { centre, major_radius, minor_radius }) + } + pub fn create_wedge(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { + self.create(SolidPrimitive::Wedge { origin, size }) + } + pub fn extrude(&self, profile: &impl HasId, direction: [f64; 3]) -> ApiResult { + 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 { + let receipt = self.session.apply_op(Operation::Revolve { + profile: profile.id(), + axis: (pivot, axis), + angle, + })?; + let id = receipt + .outcome + .and_then(|o| o.new_id()) + .ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?; + Ok(Solid::new(self.session.clone(), id)) + } +} + +#[derive(Clone)] +pub struct CurveCollection { + session: Session, +} + +impl CurveCollection { + fn create_curve(&self, spec: Curve2Spec) -> ApiResult<(Session, ObjectId)> { + let receipt = self.session.apply_op(Operation::CreateCurve(spec))?; + let id = receipt + .outcome + .and_then(|o| o.new_id()) + .ok_or_else(|| ApiError::Transport("create_curve returned no id".into()))?; + Ok((self.session.clone(), id)) + } + pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Line { start, end })?; + Ok(Line::new(s, id)) + } + pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Circle { centre, radius })?; + Ok(Circle::new(s, id)) + } + pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult { + 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 { + let (s, id) = self.create_curve(Curve2Spec::Point { position })?; + Ok(Point::new(s, id)) + } + /// Bulk-create many points in ONE op (plan §5.3): all-or-nothing, one undo step. + pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { + let specs: Vec = positions + .iter() + .map(|&p| crate::ops::EntitySpec::Curve(Curve2Spec::Point { position: p })) + .collect(); + let receipt = self.session.apply_op(Operation::CreateMany(specs))?; + let outcome = receipt.outcome.ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; + Ok(outcome + .new_ids() + .iter() + .map(|&id| Point::new(self.session.clone(), id)) + .collect()) + } +} + +#[derive(Clone)] +pub struct EntityCollection { + session: Session, +} + +impl EntityCollection { + /// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …). + pub fn get(&self, id: ObjectId) -> ApiResult { + // Validate existence eagerly so `get` errors surface as `UnknownId`. + self.session.one_query(Query::GetEntity { id })?; + Ok(Entity::new(self.session.clone(), id)) + } + pub fn delete(&self, id: ObjectId) -> ApiResult<()> { + self.session.apply_op(Operation::Delete { id })?; + Ok(()) + } + /// Bulk transform (one op, all-or-nothing). + pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> { + self.session.apply_op(Operation::TransformMany { ids: ids.to_vec(), placement })?; + Ok(()) + } + /// Bulk delete (one op, all-or-nothing). Used by `OpGroup::compensate`. + pub fn delete_many(&self, ids: &[ObjectId]) -> ApiResult<()> { + self.session.apply_op(Operation::DeleteMany(ids.to_vec()))?; + Ok(()) + } +} + +// ── OpGroup: client-side failure-cleanup (NOT a transaction) ──────────────── + +/// Tracks handles created during a logical operation so a later failure can be +/// cleaned up (best-effort, sequential delete ops — plan §8.1). No atomicity, +/// no host involvement, no rollback. +#[derive(Default)] +pub struct OpGroup { + created: Vec, +} + +impl OpGroup { + pub fn new() -> Self { + Self { created: Vec::new() } + } + /// Record a created handle (propagating any construction error); returns it + /// for fluent `grp.track(doc.solids().create_cuboid(..)?)?` use. + pub fn track(&mut self, handle: ApiResult) -> ApiResult { + let handle = handle?; + self.created.push(handle.id()); + Ok(handle) + } + /// Keep everything: drop the tracking without deleting. + pub fn commit(self) {} + /// Best-effort cleanup: delete every tracked entity (one `DeleteMany` op). + /// Does not resurrect already-consumed/deleted entities (plan §13 note). + pub fn compensate(self, doc: &Document) -> ApiResult<()> { + if self.created.is_empty() { + return Ok(()); + } + doc.entities().delete_many(&self.created) + } +} diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md new file mode 100644 index 000000000..3f9655bd1 --- /dev/null +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -0,0 +1,115 @@ +# ocs_doc_api — API reference (auto-generated) + +Envelope version: `1` + +Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`. + +## `Solid` (acadrust `Solid3D`, collection `solids`) + +B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT). + +### Constructors (`doc.solids()`) + +- **create_cuboid**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` +- **create_sphere**(`centre: [f64; 3], radius: f64`) -> `Solid` +- **create_cylinder**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid` +- **create_cone**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid` +- **create_torus**(`centre: [f64; 3], major_radius: f64, minor_radius: f64`) -> `Solid` +- **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` +- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`) +- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`) + +### Methods + +- **intersect**(`other: Solid`) -> `Solid` — op `SolidBoolean` +- **union**(`other: Solid`) -> `Solid` — op `SolidBoolean` +- **subtract**(`other: Solid`) -> `Solid` — op `SolidBoolean` +- **volume**(``) -> `f64` — query `GetVolume` +- **centroid**(``) -> `[f64; 3]` — query `GetCentroid` +- **intersects**(`other: Solid`) -> `bool` — query `GetIntersects` + +## `Line` (acadrust `Line`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_line**(`start: [f64; 3], end: [f64; 3]`) -> `Line` + +## `Circle` (acadrust `Circle`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_circle**(`centre: [f64; 3], radius: f64`) -> `Circle` + +## `Polyline` (acadrust `LwPolyline`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_polyline**(`points: &[[f64; 3]], closed: bool`) -> `Polyline` + +### Methods + +- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` — **not yet supported** (returns `ApiError::Unsupported`) + +## `Point` (acadrust `Point`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_point**(`position: [f64; 3]`) -> `Point` +- **create_points**(`positions: &[[f64; 3]]`) -> `Vec` *(bulk op)* + +## Generic methods (every handle) + +Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a +`Copy`, `Send`, `Sync` token `{ ObjectId, session }` and supports: + +- **`id()` -> `ObjectId`** — the stable entity identity. +- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities + computed; later families may be `Unsupported`). +- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: + solids only.** +- **`delete()`** — remove the entity (one undo step). + +`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed +downcast when `kind == "Solid3D"`). + +## Collections & cross-cutting + +- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. +- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, + `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only + batch, one round-trip, **no revision bump**). +- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, + `transform_many(&ids, placement)`, `delete_many(&ids)`. +- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / + `compensate`). **Not** a transaction: no atomicity, no rollback. + +## Bulk ops — single op, all-or-nothing, item-capped + +`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo +step, one publish, and **all-or-nothing via upfront validation** (no rollback). +Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for +bulk data (N ≳ a few hundred); per-op autocommit is the right tool for +modeling-scale work. + +## Semantics + +- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host + call = one undo step. Multi-op write transactions were removed (security/ + integrity review). +- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a + failed op applies nothing and records no undo step. `erase_sources` booleans run + only on success. +- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, + Transport}`; bulk validation names the failing index; a stale handle returns + `UnknownId` (never a panic). + +## Kernel mapping + +| op | kernel call | returns | +|---|---|---| +| CreateSolid.Cuboid | `cadkernel::brep::make::cuboid` | Option | +| CreateSolid.Sphere | `cadkernel::brep::make::sphere` | Option | +| SolidBoolean | `cadkernel::brep::combine` | Result | +| Transform | `cadkernel::brep::transform` | Option | +| GetBounds | `cadkernel::brep::body_bounds` | Option | +| GetVolume | `cadkernel::brep::mesh_body + divergence` | computed | diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json new file mode 100644 index 000000000..41a4c5f1e --- /dev/null +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -0,0 +1,210 @@ +{ + "envelope_version": 1, + "families": [ + { + "acadrust_variant": "Solid3D", + "collection": "solids", + "handle": "Solid", + "methods": [ + { + "args": [ + { + "name": "other", + "ty": "Solid" + } + ], + "bulk": false, + "fixed": { + "erase_sources": true, + "op": "Intersection" + }, + "kind": "op", + "name": "intersect", + "op": "SolidBoolean", + "query": null, + "read_only": false, + "returns": "Solid" + }, + { + "args": [ + { + "name": "other", + "ty": "Solid" + } + ], + "bulk": false, + "fixed": { + "erase_sources": true, + "op": "Union" + }, + "kind": "op", + "name": "union", + "op": "SolidBoolean", + "query": null, + "read_only": false, + "returns": "Solid" + }, + { + "args": [ + { + "name": "other", + "ty": "Solid" + } + ], + "bulk": false, + "fixed": { + "erase_sources": true, + "op": "Difference" + }, + "kind": "op", + "name": "subtract", + "op": "SolidBoolean", + "query": null, + "read_only": false, + "returns": "Solid" + }, + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "volume", + "op": null, + "query": "GetVolume", + "read_only": true, + "returns": "f64" + }, + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "centroid", + "op": null, + "query": "GetCentroid", + "read_only": true, + "returns": "[f64; 3]" + }, + { + "args": [ + { + "name": "other", + "ty": "Solid" + } + ], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "intersects", + "op": null, + "query": "GetIntersects", + "read_only": true, + "returns": "bool" + } + ], + "name": "Solid" + }, + { + "acadrust_variant": "Line", + "collection": "curves", + "handle": "Line", + "methods": [], + "name": "Line" + }, + { + "acadrust_variant": "Circle", + "collection": "curves", + "handle": "Circle", + "methods": [], + "name": "Circle" + }, + { + "acadrust_variant": "LwPolyline", + "collection": "curves", + "handle": "Polyline", + "methods": [ + { + "args": [ + { + "name": "at", + "ty": "usize" + }, + { + "name": "point", + "ty": "[f64; 3]" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "add_vertex", + "op": "AddVertex", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "Polyline" + }, + { + "acadrust_variant": "Point", + "collection": "curves", + "handle": "Point", + "methods": [], + "name": "Point" + } + ], + "layouts": { + "ObjectId": { + "struct": [ + { + "name": "0", + "type": "u64" + } + ] + }, + "Operation": { + "enum": [ + "CreateSolid(SolidPrimitive)", + "CreateCurve(Curve2Spec)", + "Extrude { profile: ObjectId, direction: [f64; 3] }", + "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", + "Transform { id: ObjectId, placement: PlacementSpec }", + "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", + "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", + "Delete { id: ObjectId }", + "CreateMany(Vec)", + "TransformMany { ids: Vec, placement: PlacementSpec }", + "DeleteMany(Vec)" + ] + }, + "Query": { + "enum": [ + "GetEntity { id: ObjectId }", + "GetBounds { id: ObjectId }", + "GetCentroid { id: ObjectId }", + "GetVolume { id: ObjectId }", + "GetIntersects { a: ObjectId, b: ObjectId }", + "GetGeometryRevision" + ] + } + }, + "object_model": { + "collections": [ + "solids", + "curves", + "entities" + ], + "document": "Document", + "handles": [ + "Entity", + "Solid", + "Line", + "Circle", + "Polyline", + "Point" + ], + "root": "DocApi" + }, + "schema": "ocs_doc_api/binding_schema" +} \ No newline at end of file diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json new file mode 100644 index 000000000..4f78f25a3 --- /dev/null +++ b/crates/ocs_doc_api/src/gen/layouts.json @@ -0,0 +1,28 @@ +{ + "ObjectId": { "struct": [ { "name": "0", "type": "u64" } ] }, + "Operation": { + "enum": [ + "CreateSolid(SolidPrimitive)", + "CreateCurve(Curve2Spec)", + "Extrude { profile: ObjectId, direction: [f64; 3] }", + "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", + "Transform { id: ObjectId, placement: PlacementSpec }", + "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", + "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", + "Delete { id: ObjectId }", + "CreateMany(Vec)", + "TransformMany { ids: Vec, placement: PlacementSpec }", + "DeleteMany(Vec)" + ] + }, + "Query": { + "enum": [ + "GetEntity { id: ObjectId }", + "GetBounds { id: ObjectId }", + "GetCentroid { id: ObjectId }", + "GetVolume { id: ObjectId }", + "GetIntersects { a: ObjectId, b: ObjectId }", + "GetGeometryRevision" + ] + } +} diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs new file mode 100644 index 000000000..2070e6117 --- /dev/null +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -0,0 +1,30 @@ +// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand. +// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI +// fails on drift (`--check`). Append new variants at the END only (bincode +// discriminant stability, plan §7). + +use serde::{Deserialize, Serialize}; + +use crate::id::ObjectId; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, PlacementSpec, SolidPrimitive}; + +/// A typed write operation (plan §5). Each variant is ONE atomic host call = one +/// undo step. Append new variants at the END only. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum Operation { + // construction + CreateSolid(SolidPrimitive), + CreateCurve(Curve2Spec), + Extrude { profile: ObjectId, direction: [f64; 3] }, + Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }, + // modification + Transform { id: ObjectId, placement: PlacementSpec }, + SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }, + AddVertex { id: ObjectId, at: usize, point: [f64; 3] }, + Delete { id: ObjectId }, + // Bulk ops (single op, upfront-validation all-or-nothing — NOT a transaction, §5.3) + CreateMany(Vec), + TransformMany { ids: Vec, placement: PlacementSpec }, + DeleteMany(Vec), +} diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs new file mode 100644 index 000000000..0d9832956 --- /dev/null +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -0,0 +1,22 @@ +// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand. +// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI +// fails on drift (`--check`). Append new variants at the END only (bincode +// discriminant stability, plan §7). + +use serde::{Deserialize, Serialize}; + +use crate::id::ObjectId; + +/// A typed read query (plan §5). Read-only: no mutation, no undo, no revision +/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum Query { + GetEntity { id: ObjectId }, + GetBounds { id: ObjectId }, + GetCentroid { id: ObjectId }, + GetVolume { id: ObjectId }, + /// Whether two entities' bounds overlap (two-phase conditional modelling). + GetIntersects { a: ObjectId, b: ObjectId }, + GetGeometryRevision, +} diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs new file mode 100644 index 000000000..0341d760b --- /dev/null +++ b/crates/ocs_doc_api/src/geom.rs @@ -0,0 +1,58 @@ +//! Kernel geometry helpers (host feature, plan §10 code-mapping table). Thin +//! wrappers over `cadkernel::brep::*` mapping the crate's plain-data DTOs to the +//! non-serde kernel types. Kernel keys never cross the API (decision #7). + +use cadkernel::geom2d::Curve as Curve2; +use cadkernel::space::Plane; + +use crate::backend::KernelBody; +use crate::error::{ApiError, ApiResult, GeometryErrorKind}; +use crate::ops::{BoolOp, SolidPrimitive}; + +fn geom_err(kind: GeometryErrorKind, msg: impl Into) -> ApiError { + ApiError::geometry(kind, msg) +} + +/// The XY work plane (origin at 0, X=(1,0,0), normal=(0,0,1)). +fn xy_plane() -> ApiResult { + Plane::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 { + use cadkernel::brep::make; + let body = match *p { + SolidPrimitive::Cuboid { origin, size } => make::cuboid(origin, size), + SolidPrimitive::Sphere { centre, radius } => make::sphere(centre, radius), + SolidPrimitive::Cylinder { base, radius, height } => make::cylinder(base, radius, height), + SolidPrimitive::Cone { base, radius, height } => make::cone(base, radius, height), + SolidPrimitive::Torus { centre, major_radius, minor_radius } => { + make::torus(centre, major_radius, minor_radius) + } + SolidPrimitive::Wedge { origin, size } => make::wedge(origin, size[0], size[1], size[2]), + }; + body.ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "kernel make returned empty body")) +} + +/// Boolean combine (pure): `brep::combine(a, b, op, operation_tolerance(&[&a,&b]))`. +/// Maps the kernel `Snag` to `ApiError::Geometry`. Does NOT mutate the inputs. +pub(crate) fn boolean(a: &KernelBody, b: &KernelBody, op: BoolOp) -> ApiResult { + let kernel_op = crate::executor::kernel_bool_op(op); + let tol = cadkernel::brep::operation_tolerance(&[a, b]); + cadkernel::brep::combine(a.clone(), b.clone(), kernel_op, tol) + .map_err(|e| geom_err(GeometryErrorKind::BooleanFailed, format!("{e:?}"))) +} + +/// Extrude a closed 2D profile into a solid (`brep::extrude`). +pub(crate) fn extrude(profile: &[Curve2], direction: [f64; 3]) -> ApiResult { + cadkernel::brep::extrude(xy_plane()?, profile, direction) + .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "extrude failed")) +} + +/// Revolve a closed 2D profile about an axis into a solid (`brep::revolve`). +pub(crate) fn revolve(profile: &[Curve2], pivot: [f64; 3], axis: [f64; 3], angle: f64) -> ApiResult { + cadkernel::brep::revolve(xy_plane()?, profile, pivot, axis, angle) + .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "revolve failed")) +} + diff --git a/crates/ocs_doc_api/src/id.rs b/crates/ocs_doc_api/src/id.rs new file mode 100644 index 000000000..447761fae --- /dev/null +++ b/crates/ocs_doc_api/src/id.rs @@ -0,0 +1,40 @@ +//! Stable entity identity (plan §2 decision #7). + +use serde::{Deserialize, Serialize}; + +/// A stable u64 handle to a first-level document entity, decoupled from any +/// internal engine/kernel pointer. Value-compatible with `acadrust::Handle` — +/// kernel keys never cross the API boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ObjectId(pub u64); + +impl ObjectId { + /// The null / absent handle (`acadrust::Handle::NULL` value `0`). + pub const NULL: ObjectId = ObjectId(0); + + pub const fn as_u64(self) -> u64 { + self.0 + } + + pub const fn is_null(self) -> bool { + self.0 == 0 + } + + /// Build from a raw u64 (e.g. an `acadrust::Handle`'s value). + pub const fn from_u64(v: u64) -> Self { + Self(v) + } + + /// Build from an `acadrust::Handle` (host/ipc feature). + #[cfg(any(feature = "host", feature = "ipc", test))] + pub fn from_handle(h: acadrust::Handle) -> Self { + Self(h.value()) + } + + /// Convert to an `acadrust::Handle` (host/ipc feature). + #[cfg(any(feature = "host", feature = "ipc", test))] + pub fn to_handle(self) -> acadrust::Handle { + acadrust::Handle::new(self.0) + } +} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs new file mode 100644 index 000000000..45a2c205a --- /dev/null +++ b/crates/ocs_doc_api/src/lib.rs @@ -0,0 +1,66 @@ +//! `ocs_doc_api` — Object-oriented, transport-agnostic CAD entity API for +//! OpenCADStudio plugins and hosts (v2). +//! +//! Core vocabulary: **Entity**, **ObjectId** (u64), **Operation** (typed write), +//! **Query** (typed read), **Transport** (the only plugin↔host boundary). +//! Every write op is one atomic host call = one undo step (no multi-op write +//! transactions — removed after the security/integrity review; see the plan §13). +//! +//! The facade is object-centric: [`DocApi`] (root) → [`Document`] → collections +//! ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`]) → typed +//! handles ([`Solid`], [`Line`], …) carrying geometric methods. The same typed +//! requests run in-process and over IPC; only the [`Transport`] differs. + +pub mod error; +pub mod id; +pub mod revision; + +#[cfg(feature = "host")] +pub mod backend; +#[cfg(feature = "host")] +pub mod executor; +#[cfg(feature = "host")] +pub mod geom; + +pub mod envelope; +pub mod facade; +pub mod ops; +pub mod query; +pub mod transport; + +// Generated (build.rs) — committed snapshots under src/gen/; do NOT edit by hand. +pub mod gen { + pub mod ops { + include!("gen/ops_gen.rs"); + } + pub mod query { + include!("gen/query_gen.rs"); + } + pub use ops::Operation; + pub use query::Query; +} + +pub use error::{ApiError, ApiResult, GeometryErrorKind}; +pub use id::ObjectId; +pub use revision::GeometryRevision; + +pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt}; +pub use facade::{ + CurveCollection, DocApi, Document, Entity, EntityCollection, HasId, Line, OpGroup, Point, + Polyline, QueryBatch, QueryResults, Solid, SolidCollection, +}; +pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive}; +pub use query::{Aabb, EntityView, Query, QueryResult}; +pub use transport::Transport; + +/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). +/// Bump when the envelope layout or `Operation`/`Query` discriminants change. +/// Bridges must check this before speaking (see `bindings/README.md`). +pub const ENVELOPE_VERSION: u16 = 1; + +/// The generated binding handover schema (plan §10.2): object model + method +/// signatures + op/query mapping with traced wire layouts inlined. Single, +/// self-contained, versioned with [`ENVELOPE_VERSION`]. +pub fn binding_schema_json() -> &'static str { + include_str!("gen/binding_schema.json") +} diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs new file mode 100644 index 000000000..1e30f98e4 --- /dev/null +++ b/crates/ocs_doc_api/src/ops.rs @@ -0,0 +1,97 @@ +//! Write operations — the typed, transport-agnostic vocabulary for changing the +//! document (plan §5). The `Operation` enum itself is GENERATED into +//! `crate::gen` by `build.rs` from `spec/entities.toml`; this module re-exports +//! it and holds the hand-written payload mirrors. + +use serde::{Deserialize, Serialize}; + +pub use crate::gen::Operation; + +/// Boolean operation kind for [`Operation::SolidBoolean`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BoolOp { + Union, + Intersection, + Difference, +} + +/// Construction spec for a B-rep solid primitive (plain-data mirror of the +/// non-serde `cadkernel::brep::make::*` arguments). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum SolidPrimitive { + Cuboid { origin: [f64; 3], size: [f64; 3] }, + Sphere { centre: [f64; 3], radius: f64 }, + Cylinder { base: [f64; 3], radius: f64, height: f64 }, + Cone { base: [f64; 3], radius: f64, height: f64 }, + Torus { centre: [f64; 3], major_radius: f64, minor_radius: f64 }, + Wedge { origin: [f64; 3], size: [f64; 3] }, +} + +/// Construction spec for a minimal 2D curve entity (phase-1 set; profiles/inputs). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Curve2Spec { + Line { start: [f64; 3], end: [f64; 3] }, + Circle { centre: [f64; 3], radius: f64 }, + Polyline { points: Vec<[f64; 3]>, closed: bool }, + Point { position: [f64; 3] }, +} + +/// A generic entity-construction payload used by the bulk op +/// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction +/// specs so one bulk op can carry many homogeneous (or mixed) creations. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum EntitySpec { + Curve(Curve2Spec), + Solid(SolidPrimitive), +} + +/// A rigid similarity (rotation + translation + optional uniform scale/reflection) +/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` +/// (non-serde). Columns are the new basis axes; `origin` is the translation. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct PlacementSpec { + pub x_axis: [f64; 3], + pub y_axis: [f64; 3], + pub z_axis: [f64; 3], + pub origin: [f64; 3], +} + +impl PlacementSpec { + /// Identity placement (no transform). + pub const IDENTITY: PlacementSpec = PlacementSpec { + x_axis: [1.0, 0.0, 0.0], + y_axis: [0.0, 1.0, 0.0], + z_axis: [0.0, 0.0, 1.0], + origin: [0.0, 0.0, 0.0], + }; + + /// Pure translation to `origin` (axes unchanged). + pub fn at(origin: [f64; 3]) -> Self { + Self { origin, ..Self::IDENTITY } + } +} + +/// The item-count cap for a single bulk op envelope (plan §5.3): bounds +/// host-side execution time and payload. Over-cap → `ApiError::Validation`. +pub const BULK_ITEM_CAP: usize = 100_000; + +/// Convenience accessors used by the facade/executor. +impl crate::gen::Operation { + /// The op name used in `ApiError::Validation { op, .. }`. + pub fn op_name(&self) -> &'static str { + use crate::gen::Operation::*; + match self { + CreateSolid(_) => "CreateSolid", + CreateCurve(_) => "CreateCurve", + Extrude { .. } => "Extrude", + Revolve { .. } => "Revolve", + Transform { .. } => "Transform", + SolidBoolean { .. } => "SolidBoolean", + AddVertex { .. } => "AddVertex", + Delete { .. } => "Delete", + CreateMany(_) => "CreateMany", + TransformMany { .. } => "TransformMany", + DeleteMany(_) => "DeleteMany", + } + } +} diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs new file mode 100644 index 000000000..5c40942d8 --- /dev/null +++ b/crates/ocs_doc_api/src/query.rs @@ -0,0 +1,61 @@ +//! Read queries — typed, transport-agnostic reads of entity state (plan §5). +//! The `Query` enum is GENERATED into `crate::gen` by `build.rs`; this module +//! re-exports it and holds the hand-written result DTOs. + +use serde::{Deserialize, Serialize}; + +use crate::id::ObjectId; +use crate::revision::GeometryRevision; + +pub use crate::gen::Query; + +/// Axis-aligned bounding box (plain-data mirror of the kernel's non-serde `Aabb`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Aabb { + pub min: [f64; 3], + pub max: [f64; 3], +} + +impl Aabb { + pub fn overlaps(&self, other: &Aabb) -> bool { + (0..3).all(|i| self.min[i] <= other.max[i] && other.min[i] <= self.max[i]) + } +} + +/// A generic, untyped view of an entity returned by [`Query::GetEntity`]. +/// Family-specific data lives in the typed construction specs; this carries +/// identity + kind + a coarse bounding box so any first-level entity is readable. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EntityView { + pub id: ObjectId, + /// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line"). + pub kind: String, + pub bounds: Option, +} + +/// The result of a single [`Query`] (one per query in a `Queries` batch). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum QueryResult { + Entity(EntityView), + Bounds(Aabb), + Centroid([f64; 3]), + Volume(f64), + /// Whether two entities' bounds overlap (two-phase conditional modelling). + Intersects(bool), + Revision(GeometryRevision), +} + +/// Convenience: the query name for diagnostics. +impl crate::gen::Query { + pub fn query_name(&self) -> &'static str { + use crate::gen::Query::*; + match self { + GetEntity { .. } => "GetEntity", + GetBounds { .. } => "GetBounds", + GetCentroid { .. } => "GetCentroid", + GetVolume { .. } => "GetVolume", + GetIntersects { .. } => "GetIntersects", + GetGeometryRevision => "GetGeometryRevision", + } + } +} diff --git a/crates/ocs_doc_api/src/revision.rs b/crates/ocs_doc_api/src/revision.rs new file mode 100644 index 000000000..f0759afe9 --- /dev/null +++ b/crates/ocs_doc_api/src/revision.rs @@ -0,0 +1,16 @@ +//! Geometry revision counter (mirrors the host scene's `geometry_epoch`). + +use serde::{Deserialize, Serialize}; + +/// Monotonic revision of a document's geometry. Bumped exactly once per applied +/// write op (`push_undo` + `finalize_op`). Pure queries return the current +/// revision without bumping it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GeometryRevision(pub u64); + +impl GeometryRevision { + pub const fn as_u64(self) -> u64 { + self.0 + } +} diff --git a/crates/ocs_doc_api/src/transport/inproc.rs b/crates/ocs_doc_api/src/transport/inproc.rs new file mode 100644 index 000000000..5cdca1310 --- /dev/null +++ b/crates/ocs_doc_api/src/transport/inproc.rs @@ -0,0 +1,42 @@ +//! In-process transport (plan §7): drives a `DocApiBackend` directly, inline. +//! Used by in-tree/built-in plugins and host tests. Thread-safe via a mutex so +//! many handles across many threads share it (calls are serialized). + +use std::sync::Mutex; + +use crate::backend::DocApiBackend; +use crate::envelope::{DocApiEnvelope, EnvelopeBody, Receipt}; +use crate::error::ApiResult; +use crate::executor; +use crate::transport::Transport; + +/// In-process transport: holds the backend behind a mutex (reentrant `&self`). +pub struct InProcess { + backend: Mutex, +} + +impl InProcess { + pub fn new(backend: B) -> Self { + Self { backend: Mutex::new(backend) } + } + /// Borrow the backend mutably (test/introspection helper). + pub fn backend_mut(&mut self) -> &mut B { + self.backend.get_mut().expect("in-process backend poisoned") + } + /// Borrow the backend immutably (test/introspection helper). + pub fn backend(&self) -> std::sync::MutexGuard<'_, B> { + self.backend.lock().expect("in-process backend poisoned") + } +} + +impl Transport for InProcess { + fn apply(&self, req: DocApiEnvelope) -> ApiResult { + 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), + } + } +} diff --git a/crates/ocs_doc_api/src/transport/ipc.rs b/crates/ocs_doc_api/src/transport/ipc.rs new file mode 100644 index 000000000..796617694 --- /dev/null +++ b/crates/ocs_doc_api/src/transport/ipc.rs @@ -0,0 +1,55 @@ +//! IPC transport (plan §7, decision #10): serializes `DocApiEnvelope` into the +//! append-only `PluginRequest::DocApiRequest { tab_id, bytes }` variant and ships +//! it through the plugin's `PluginRequestSender`. The host routes that variant to +//! the same crate executor (`ocs_doc_api::executor`) — one implementation. + +use std::sync::Arc; + +use ocs_plugin_api::host::{PluginRequestError, PluginRequestSender}; +use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; + +use crate::envelope::{DocApiEnvelope, Receipt}; +use crate::error::{ApiError, ApiResult}; +use crate::transport::Transport; + +/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; +/// the underlying `PluginRequestSender` already serializes + correlates (V4). +pub struct OcsPluginApiIpc { + sender: Arc, + tab_id: u64, +} + +impl OcsPluginApiIpc { + pub fn new(sender: Arc, tab_id: u64) -> Self { + Self { sender, tab_id } + } + + fn transport_err(e: PluginRequestError) -> ApiError { + ApiError::Transport(e.0) + } +} + +impl Transport for OcsPluginApiIpc { + fn apply(&self, req: DocApiEnvelope) -> ApiResult { + let bytes = bincode::serialize(&req) + .map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?; + let resp = self + .sender + .request(PluginRequest::DocApiRequest { tab_id: self.tab_id, bytes }) + .map_err(Self::transport_err)?; + match resp { + PluginResponse::DocApiResponse { bytes } => { + // The host serializes `ApiResult` so op/query errors + // (validation, geometry, unknown id) surface as the SAME + // structured `ApiError` the in-process executor produced. + let result: ApiResult = bincode::deserialize(&bytes) + .map_err(|e| ApiError::Transport(format!("receipt deserialize: {e}")))?; + result + } + PluginResponse::Error(msg) => Err(ApiError::Transport(msg)), + other => Err(ApiError::Transport(format!( + "unexpected DocApi response variant: {other:?}" + ))), + } + } +} diff --git a/crates/ocs_doc_api/src/transport/mod.rs b/crates/ocs_doc_api/src/transport/mod.rs new file mode 100644 index 000000000..8934bb03b --- /dev/null +++ b/crates/ocs_doc_api/src/transport/mod.rs @@ -0,0 +1,36 @@ +//! The transport boundary (plan §7) — the ONLY plugin↔host boundary. +//! +//! Portability contract (SPSC / flume / shared-memory safe): a transport impl +//! must (a) accept `&self` calls from many threads and serialize sends internally, +//! (b) demultiplex responses to the right caller by a correlation id, (c) +//! block-with-timeout, (d) expose liveness. This matches the existing V4 channel +//! shape, so swapping socket → shm changes only the impl, never the facade. + +#[cfg(feature = "host")] +pub mod inproc; +#[cfg(feature = "ipc")] +pub mod ipc; + +#[cfg(feature = "host")] +pub use inproc::InProcess; +#[cfg(feature = "ipc")] +pub use ipc::OcsPluginApiIpc; + +use crate::envelope::{DocApiEnvelope, Receipt}; +use crate::error::ApiResult; + +/// The only plugin↔host boundary. Object-centric: one method per unit of work — +/// a single write op, or a query batch. `&self` + `Send + Sync` so many entity +/// handles across many threads share one transport concurrently. +pub trait Transport: Send + Sync { + /// Apply `req` (ONE write op, or a query batch) and return its `Receipt`. + /// A write request carries exactly one `Operation`; a read request carries + /// one-or-more `Query` items (read-only batching is safe). Blocks up to an + /// internal timeout. Reentrant: safe to call concurrently from many threads. + fn apply(&self, req: DocApiEnvelope) -> ApiResult; + + /// Cheap host-liveness probe (shm has no socket-disconnect to detect a dead peer). + fn alive(&self) -> bool { + true + } +} diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs new file mode 100644 index 000000000..eba7e75ce --- /dev/null +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -0,0 +1,301 @@ +//! Unit tests over an in-memory mock `DocApiBackend` (plan §11.1): no host. +//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup +//! compensation, bulk all-or-nothing, revision bumps, read-only query batching, +//! and stale-handle `UnknownId` semantics. + +use std::collections::HashMap; +use std::sync::Arc; + +use ocs_doc_api::backend::{DocApiBackend, KernelBody}; +use ocs_doc_api::{ + Aabb, ApiError, ApiResult, Curve2Spec, DocApi, EntityView, GeometryErrorKind, GeometryRevision, + HasId, ObjectId, PlacementSpec, +}; + +/// In-memory mock backend: HashMap + a tiny entity table. +/// Tracks undo steps + revision bumps to assert per-op semantics. +#[derive(Default)] +struct MockBackend { + next_id: u64, + revision: u64, + undo_steps: u32, + bodies: HashMap, + kinds: HashMap, +} + +impl MockBackend { + fn alloc(&mut self, kind: &str) -> ObjectId { + self.next_id += 1; + let id = ObjectId::from_u64(self.next_id); + self.kinds.insert(id, kind.to_string()); + id + } + fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { + self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) + } +} + +impl DocApiBackend for MockBackend { + fn resolve_body(&mut self, id: ObjectId) -> ApiResult { + self.bodies.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } + fn store_solid(&mut self, body: &KernelBody) -> ApiResult { + let id = self.alloc("Solid3D"); + self.bodies.insert(id, body.clone()); + Ok(id) + } + fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { + if self.bodies.insert(id, body.clone()).is_none() { + return Err(ApiError::UnknownId(id)); + } + Ok(()) + } + fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { + let kind = match spec { + Curve2Spec::Line { .. } => "Line", + Curve2Spec::Circle { .. } => "Circle", + Curve2Spec::Polyline { .. } => "LwPolyline", + Curve2Spec::Point { .. } => "Point", + }; + Ok(self.alloc(kind)) + } + fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn remove_entity(&mut self, id: ObjectId) -> ApiResult { + self.bodies.remove(&id); + Ok(self.kinds.remove(&id).is_some()) + } + fn get_entity(&mut self, id: ObjectId) -> ApiResult { + let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); + let bounds = self.bodies.get(&id).and_then(|b| { + cadkernel::brep::body_bounds(b).map(|bb| Aabb { min: bb.min, max: bb.max }) + }); + Ok(EntityView { id, kind, bounds }) + } + fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if let Some(body) = self.bodies.get(&id).cloned() { + let place = cadkernel::brep::Placement { + x_axis: placement.x_axis, + y_axis: placement.y_axis, + z_axis: placement.z_axis, + origin: placement.origin, + }; + let moved = cadkernel::brep::transform(&body, &place) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?; + self.bodies.insert(id, moved); + } + Ok(()) + } + fn profile_curves(&self, _id: ObjectId) -> ApiResult> { + Err(ApiError::Unsupported("profiles not mocked".into())) + } + fn bounds(&mut self, id: ObjectId) -> ApiResult { + let body = self.body(id)?; + let bb = cadkernel::brep::body_bounds(body) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; + Ok(Aabb { min: bb.min, max: bb.max }) + } + fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { + let body = self.body(id)?; + Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).centroid_approx()) + } + fn volume(&mut self, id: ObjectId) -> ApiResult { + let body = self.body(id)?; + Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).volume_approx()) + } + fn entity_exists(&self, id: ObjectId) -> bool { + self.kinds.contains_key(&id) + } + fn revision(&self) -> GeometryRevision { + GeometryRevision(self.revision) + } + fn push_undo(&mut self, _label: &str) {} + fn finalize_op(&mut self) { + self.undo_steps += 1; + self.revision += 1; + } +} + +/// Approximate mesh volume/centroid for the mock (divergence over triangles). +trait MeshApprox { + fn volume_approx(&self) -> f64; + fn centroid_approx(&self) -> [f64; 3]; +} +impl MeshApprox for cadkernel::brep::Mesh { + fn volume_approx(&self) -> f64 { + let mut vol = 0.0; + for t in &self.triangles { + let v0 = self.positions[t[0]]; + let v1 = self.positions[t[1]]; + let v2 = self.positions[t[2]]; + let cross = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0], + ]; + vol += (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; + } + vol + } + fn centroid_approx(&self) -> [f64; 3] { + let mut vol = 0.0; + let mut c = [0.0; 3]; + for t in &self.triangles { + let v0 = self.positions[t[0]]; + let v1 = self.positions[t[1]]; + let v2 = self.positions[t[2]]; + let cross = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0], + ]; + let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; + vol += tet; + for i in 0..3 { + c[i] += (v0[i] + v1[i] + v2[i]) * tet; + } + } + if vol.abs() < 1e-12 { + return [0.0; 3]; + } + [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)] + } +} + +fn api() -> (DocApi, Arc>) { + let tp = Arc::new(ocs_doc_api::transport::InProcess::new(MockBackend::default())); + (DocApi::connect(tp.clone(), 0), tp) +} + +#[test] +fn cuboid_intersect_cuboid_atomic_ops_and_revision() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let mut grp = ocs_doc_api::OpGroup::new(); + + let rev0 = doc.revision().unwrap(); + // Two overlapping boxes — the kernel's own boolean test case (boolean.rs pair()). + let block = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])).unwrap(); + let other = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0])).unwrap(); + assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 2); + + let lens = match block.intersect(&other) { + Ok(l) => { grp.commit(); l } + Err(e) => panic!("intersect failed: {e}"), + }; + assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 3); + + let res = doc + .query_batch(|q| { + q.bounds(&lens); + q.volume(&lens); + }) + .unwrap(); + let bb = res.bounds(0).unwrap(); + let vol = res.volume(1).unwrap(); + // Intersection of [0,10]³ ∩ [5,15]³ = [5,10]³ → bounds + volume 125. + assert!((bb.min[0] - 5.0).abs() < 1e-6 && (bb.max[0] - 10.0).abs() < 1e-6, "bounds {bb:?}"); + assert!(vol > 0.0, "intersection volume must be positive"); +} + +#[test] +fn boolean_erase_sources_removes_second_solid() { + let (api, tp) = api(); + let doc = api.document(api.active_tab()); + let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]).unwrap(); + let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]).unwrap(); + let result = a.union(&b).unwrap(); + let backend = tp.backend(); + assert!(backend.entity_exists(a.id())); + assert!(!backend.entity_exists(b.id())); + assert_eq!(result.id(), a.id()); +} + +#[test] +fn stale_handle_get_returns_error() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let ghost = ObjectId::from_u64(999_999); + assert!(doc.entities().get(ghost).is_err()); +} + +#[test] +fn opgroup_compensate_deletes_created() { + let (api, tp) = api(); + let doc = api.document(api.active_tab()); + let mut grp = ocs_doc_api::OpGroup::new(); + let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])).unwrap(); + let b = grp.track(doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)).unwrap(); + grp.compensate(&doc).unwrap(); + let backend = tp.backend(); + assert!(!backend.entity_exists(a.id())); + assert!(!backend.entity_exists(b.id())); +} + +#[test] +fn bulk_create_many_one_undo_step() { + let (api, tp) = api(); + let doc = api.document(api.active_tab()); + let rev0 = doc.revision().unwrap().as_u64(); + let coords: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64, 0.0, 0.0]).collect(); + let pts = doc.curves().create_points(&coords).unwrap(); + assert_eq!(pts.len(), 1000); + let backend = tp.backend(); + assert_eq!(backend.revision, rev0 + 1); + assert_eq!(backend.undo_steps, 1); +} + +#[test] +fn transform_many_with_stale_id_fails_and_applies_nothing() { + let (api, tp) = api(); + let doc = api.document(api.active_tab()); + let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]).unwrap(); + let rev_before = doc.revision().unwrap().as_u64(); + let stale = ObjectId::from_u64(424_242); + let err = doc + .entities() + .transform_many(&[a.id(), stale], PlacementSpec::at([1.0, 0.0, 0.0])) + .unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. })); + let backend = tp.backend(); + assert_eq!(backend.revision, rev_before); +} + +#[test] +fn assert_revision_guard() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let rev = doc.revision().unwrap(); + doc.assert_revision(rev).unwrap(); + doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3]).unwrap(); + assert!(doc.assert_revision(rev).is_err()); +} + +#[test] +fn failed_boolean_leaves_inputs_live_and_no_undo() { + let (api, tp) = api(); + let doc = api.document(api.active_tab()); + let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]).unwrap(); + // `b` is stale: boolean fails before push_undo -> no mutation, no undo step. + let stale_solid = { + // Build a Solid handle to a non-existent id via Entity downcast path. + // Here we just call intersect against a deleted id. + let tmp = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0).unwrap(); + tmp.delete().unwrap(); + tmp + }; + let rev_before = doc.revision().unwrap().as_u64(); + assert!(a.intersect(&stale_solid).is_err()); + let backend = tp.backend(); + // `a` is still live; no undo recorded for the failed boolean. + assert!(backend.entity_exists(a.id())); + assert_eq!(backend.revision, rev_before); +} diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 49e520059..ac88fe336 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -19,6 +19,7 @@ acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c91a1c # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } bincode = { version = "1", optional = true } +serde_bytes = { version = "0.11", optional = true } thiserror = { version = "1", optional = true } libloading = { version = "0.8", optional = true } memmap2 = { version = "0.9", optional = true } @@ -29,7 +30,7 @@ getrandom = { version = "0.2", optional = true } # Enables the runtime host surface (`HostApi` trait) and the out-of-process # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers # leave it off. -host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] +host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] [build-dependencies] serde-reflection = "0.6" diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 4046b22b9..80a7ae7b0 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -552,6 +552,18 @@ pub trait HostApi { let _ = tab_id; None } + + /// DocApi v2 (`ocs_doc_api`): dispatch one bincode-serialized `DocApiEnvelope` + /// (one write op OR a read-only query batch) and return the bincode-serialized + /// `Receipt` (or the bincode-serialized `ApiError` on failure). Opaque bytes: + /// this crate does not depend on `ocs_doc_api`, it only routes the envelope. + /// + /// Added for the DocApi v2 adapter. The default returns `Err` ("not supported"); + /// the in-process host (`HostSession`) overrides it to call the DocApi executor. + fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result, String> { + let _ = (tab_id, bytes); + Err("DocApi v2 not supported by this host".to_string()) + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c20cad1b2..4b5ca8cf1 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -138,6 +138,12 @@ pub enum PluginRequest { GetTabId, /// V5: ask the host for the filesystem path of the document in `tab_id`. DocumentPath { tab_id: u64 }, + /// DocApi v2 (`ocs_doc_api`): carry one bincode-serialized `DocApiEnvelope` + /// (one write op OR a read-only query batch). Appended at the end to keep + /// existing discriminants stable. The host routes this to the DocApi executor. + /// `serde_bytes` keeps the payload a single length-prefixed bulk copy (the + /// outer frame would otherwise walk it byte-by-byte). + DocApiRequest { tab_id: u64, #[serde(with = "serde_bytes")] bytes: Vec }, } /// Responses the host sends back for `PluginRequest`. @@ -164,6 +170,10 @@ pub enum PluginResponse { TabId(u64), /// V5: filesystem path of the document in the requested tab, if any. DocumentPath(Option), + /// DocApi v2 (`ocs_doc_api`): the bincode-serialized `Receipt` answering a + /// `PluginRequest::DocApiRequest`. Appended at the end (discriminant stability). + /// `serde_bytes` keeps the payload a single length-prefixed bulk copy. + DocApiResponse { #[serde(with = "serde_bytes")] bytes: Vec }, } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 055880ef0..8394d456e 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -77,5 +77,9 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), + DocApiRequest { tab_id, bytes } => match host.doc_api_dispatch(tab_id, &bytes) { + Ok(bytes) => PluginResponse::DocApiResponse { bytes }, + Err(msg) => PluginResponse::Error(msg), + }, } } diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs new file mode 100644 index 000000000..b2f259000 --- /dev/null +++ b/src/app/doc_api.rs @@ -0,0 +1,547 @@ +// DocApi v2 backend: implement `ocs_doc_api::backend::DocApiBackend` for +// `HostSession` (plan §6/§8, decision #11 — thin host hook, executor logic in +// the versioned crate). Maps each backend method to already-present scene/app +// primitives; no new geometry code. + +use acadrust::entities::Solid3D; +use acadrust::{EntityType, Handle}; +use ocs_doc_api::backend::{DocApiBackend, KernelBody}; +use ocs_doc_api::{ + Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, ObjectId, + PlacementSpec, +}; + +use crate::scene::convert::acis_export; +use crate::scene::model::solid_model; + +use super::plugin_host::HostSession; + +/// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by +// the in-process path. Deserializes the envelope, runs the crate executor, +// serializes the `Receipt` (or `ApiError`) back to bytes. +pub fn execute_doc_api(host: &mut HostSession<'_>, _tab_id: u64, bytes: &[u8]) -> Result, String> { + use ocs_doc_api::{DocApiEnvelope, EnvelopeBody}; + let envelope: DocApiEnvelope = bincode::deserialize(bytes) + .map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?; + let result: ApiResult = match envelope.body { + EnvelopeBody::Op(op) => ocs_doc_api::executor::apply_op(host, op), + EnvelopeBody::Queries(qs) => ocs_doc_api::executor::apply_queries(host, qs), + }; + bincode::serialize(&result).map_err(|e| format!("Receipt serialize: {e}")) +} + +fn obj_to_handle(id: ObjectId) -> Handle { + Handle::new(id.as_u64()) +} +fn handle_to_obj(h: Handle) -> ObjectId { + ObjectId::from_u64(h.value()) +} + +impl DocApiBackend for HostSession<'_> { + fn resolve_body(&mut self, id: ObjectId) -> ApiResult { + let handle = obj_to_handle(id); + // Lift-on-miss: populate the solid_models cache from AcisData if absent. + self.scene_mut().restore_solid_models(&[handle]); + self.scene() + .solid_models + .get(&handle) + .cloned() + .ok_or(ApiError::UnknownId(id)) + } + + fn store_solid(&mut self, body: &KernelBody) -> ApiResult { + // Pre-flight the display prep BEFORE committing the entity: if the mesh + // cannot be produced, fail now — no entity, no rollback, no revision bump + // (plan review: store_solid rollback moved the epoch on failure). + let scene = self.scene_mut(); + let Some(display) = scene.prepare_solid_model_display(Handle::NULL, body) else { + return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed")); + }; + // Mirror `add_solid_model` (history-free v1): edge wires + SAT + entity. + let mut inner = Solid3D::new(); + inner.common.plotstyle_flags = 2; + inner.wires = solid_model::edge_wires(body); + let document = acis_export::solid_to_sat(body) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?; + inner.set_sat_document(&document); + let handle = self + .commit_entity_handle(EntityType::Solid3D(inner)) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Other, "commit_entity_handle failed"))?; + // register_prepared_solid_model cannot fail once the display prep succeeded, + // so there is no post-commit rollback path. Register the live B-rep so + // booleans/transforms see it (decision #7). + self.scene_mut().register_prepared_solid_model(handle, body.clone(), display); + Ok(handle_to_obj(handle)) + } + + fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { + let handle = obj_to_handle(id); + // Rebuild the Solid3D entity with fresh SAT + wires, preserving the handle. + let Some(existing) = self.document().get_entity(handle).cloned() else { + return Err(ApiError::UnknownId(id)); + }; + let EntityType::Solid3D(mut inner) = existing else { + return Err(ApiError::Unsupported("update_solid on non-solid entity".into())); + }; + inner.wires = solid_model::edge_wires(body); + let document = acis_export::solid_to_sat(body) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Acis, "solid_to_sat failed"))?; + inner.set_sat_document(&document); + // Pre-flight the display prep before mutating (fail without touching state). + let Some(display) = self.scene_mut().prepare_solid_model_display(handle, body) else { + return Err(ApiError::geometry(GeometryErrorKind::Other, "prepare_solid_model_display failed")); + }; + if !self.scene_mut().update_entity(EntityType::Solid3D(inner)) { + return Err(ApiError::UnknownId(id)); + } + // Refresh the live cache (cannot fail after a successful pre-flight). + self.scene_mut().register_prepared_solid_model(handle, body.clone(), display); + Ok(()) + } + + fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { + let entity = crate::app::doc_api_convert::curve_spec_to_entity(spec)?; + let handle = self.scene_mut().add_entity(entity); + Ok(handle_to_obj(handle)) + } + + fn add_vertex(&mut self, _id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { + Err(ApiError::Unsupported("AddVertex is not yet implemented".into())) + } + + fn remove_entity(&mut self, id: ObjectId) -> ApiResult { + let handle = obj_to_handle(id); + if !self.entity_exists(id) { + return Ok(false); + } + // Locked layers refuse deletion; surface that as an error so the executor + // does not treat a no-op as success (plan review: erase path). + if self.scene().is_layer_locked(handle) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + // erase_entities clears the entity + solid_models/meshes/hatches and + // records the undo delta; the single publish happens at finalize_op. + self.scene_mut().erase_entities(&[handle]); + Ok(self.document().get_entity(handle).is_none()) + } + + fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { + // v1 typed transform is solids-only; pre-validating this lets TransformMany + // be all-or-nothing (no mid-loop Unsupported after earlier transforms). + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if matches!(self.document().get_entity(obj_to_handle(id)), Some(EntityType::Solid3D(_))) { + Ok(()) + } else { + Err(ApiError::Unsupported("transform is only implemented for solids in v1".into())) + } + } + + fn can_remove(&self, id: ObjectId) -> ApiResult<()> { + let handle = obj_to_handle(id); + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if self.scene().is_layer_locked(handle) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + + fn get_entity(&mut self, id: ObjectId) -> ApiResult { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + Ok(EntityView { + id, + kind: crate::app::doc_api_convert::entity_kind_name(entity).to_string(), + bounds: self.bounds(id).ok(), + }) + } + + fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { + let handle = obj_to_handle(id); + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + // Solids go through the kernel transform (re-embed SAT). Non-solids are + // out of scope for v1 typed transforms. + if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) { + let body = self.resolve_body(id)?; + let place = kernel_placement(placement); + let moved = cadkernel::brep::transform(&body, &place) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?; + self.update_solid(id, &moved) + } else { + Err(ApiError::Unsupported("transform is only implemented for solids in v1".into())) + } + } + + fn profile_curves(&self, _id: ObjectId) -> ApiResult> { + // Profiles for Extrude/Revolve land in a later phase (§5.2); v1 surfaces + // a clear error rather than guessing. + Err(ApiError::Unsupported("profile curves for sweep ops are not yet implemented".into())) + } + + fn bounds(&mut self, id: ObjectId) -> ApiResult { + let handle = obj_to_handle(id); + // Lift-on-miss for solids (consistent with volume/centroid): resolve_body + // repopulates a cold solid_models cache from SAT. A solid whose cache entry + // was dropped (e.g. by a host-side update_entity) no longer misreports + // `Unsupported` (plan review). + if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) { + let body = self.resolve_body(id)?; + let bb = cadkernel::brep::body_bounds(&body) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; + return Ok(Aabb { min: bb.min, max: bb.max }); + } + crate::app::doc_api_convert::entity_bounds(self.document().get_entity(handle), id) + } + + fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { + let handle = obj_to_handle(id); + // Serve from the scene's render-mesh metrics cache when present (avoids a + // full body tessellation + B-rep clone per query, plan review); fall back + // to the kernel mesh when the cache is cold. + if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { + if metrics.volume.abs() > 0.0 { + return Ok(metrics.centroid); + } + } + let body = self.resolve_body(id)?; + let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); + Ok(mesh_volume_centroid(&mesh).1) + } + + fn volume(&mut self, id: ObjectId) -> ApiResult { + let handle = obj_to_handle(id); + if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { + if metrics.volume.abs() > 0.0 { + return Ok(metrics.volume); + } + } + let body = self.resolve_body(id)?; + let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); + Ok(mesh_volume_centroid(&mesh).0) + } + + fn entity_exists(&self, id: ObjectId) -> bool { + self.document().get_entity(obj_to_handle(id)).is_some() + } + + fn revision(&self) -> GeometryRevision { + GeometryRevision(self.scene().geometry_epoch) + } + + fn push_undo(&mut self, label: &str) { + // Begin undo capture for an entity-only delta (delta_safe = true: solids + // and curves are entity-store changes). + self.begin_doc_api_undo(label); + } + + fn finalize_op(&mut self) { + // Close the delta entry + bump geometry + republish the document view. + self.commit_doc_api_undo(); + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { + cadkernel::brep::Placement { + x_axis: p.x_axis, + y_axis: p.y_axis, + z_axis: p.z_axis, + origin: p.origin, + } +} + +/// Signed volume and centroid of a closed triangle mesh via the divergence +/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source +/// of truth for both (used by the cold-cache `volume`/`centroid` fallback). +fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { + let mut vol = 0.0; + let mut c = [0.0; 3]; + for t in &mesh.triangles { + let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); + let cross = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0], + ]; + let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; + vol += tet; + for i in 0..3 { + c[i] += (v0[i] + v1[i] + v2[i]) * tet; + } + } + if vol.abs() < 1e-12 { + return (0.0, [0.0; 3]); + } + (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::plugin_host::HostSession; + use crate::app::OpenCADStudio; + use ocs_doc_api::ops::{BoolOp, SolidPrimitive}; + use ocs_doc_api::{ + DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt, + }; + + fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { + let bytes = bincode::serialize(&env).unwrap(); + let out = execute_doc_api(host, 0, &bytes).expect("dispatch failed"); + bincode::deserialize(&out).expect("receipt deserialize") + } + + fn new_id(receipt: &Receipt) -> ObjectId { + receipt.outcome.as_ref().and_then(|o| o.new_id()).expect("no new id") + } + + #[test] + fn doc_api_create_boolean_query_end_to_end() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let rev0 = host.scene().geometry_epoch; + + // op 1 + 2: create two overlapping boxes (one undo step + one bump each). + let cuboid_a = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }); + let cuboid_b = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }); + let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_a)).unwrap()); + let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_b)).unwrap()); + // Each write op advanced the geometry epoch (at least one bump per op). + assert!(host.scene().geometry_epoch > rev0, "epoch advanced by creates"); + + // op 3: intersect; erase_sources keeps the result at `a`, erases `b`. + let intersect = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true }; + let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect)).unwrap()); + assert_eq!(lens, a); + assert!(host.document().get_entity(obj_to_handle(b)).is_none(), "b erased"); + assert!(host.scene().solid_models.contains_key(&obj_to_handle(a)), "result in cache"); + + // query batch: bounds + volume (read-only, no bump). + let rev_before_query = host.scene().geometry_epoch; + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetBounds { id: a }, Query::GetVolume { id: a }])).unwrap(); + assert_eq!(host.scene().geometry_epoch, rev_before_query, "queries must not bump"); + let bb = match &receipt.query_results[0] { + QueryResult::Bounds(bb) => *bb, + other => panic!("expected bounds, got {other:?}"), + }; + // [0,10]^3 intersect [5,15]^3 = [5,10]^3. + assert!((bb.min[0] - 5.0).abs() < 1e-4 && (bb.max[0] - 10.0).abs() < 1e-4, "bounds {bb:?}"); + let vol = match &receipt.query_results[1] { + QueryResult::Volume(v) => *v, + other => panic!("expected volume, got {other:?}"), + }; + assert!((vol - 125.0).abs() < 1.0, "volume {vol}"); + let _ = HasId::id(&lens); + } + + #[test] + fn doc_api_transform_many_with_non_solid_fails_all_or_nothing() { + use ocs_doc_api::ops::Curve2Spec; + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A solid and a non-solid (a line is not transformable in v1). + let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }); + let mk_line = Operation::CreateCurve(Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] }); + let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap()); + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); + let rev_before = host.scene().geometry_epoch; + + // TransformMany over [solid, line]: the non-solid makes it fail BEFORE any + // mutation (all-or-nothing), so the epoch must not move and no undo is recorded. + let op = Operation::TransformMany { + ids: vec![solid, line], + placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), + }; + let err = dispatch(&mut host, DocApiEnvelope::op(op)).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. } | ApiError::Unsupported(_)), "{err:?}"); + assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); + } + + #[test] + fn doc_api_query_batch_over_cap_is_rejected() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1; + let queries: Vec = (0..over).map(|_| Query::GetGeometryRevision).collect(); + let err = dispatch(&mut host, DocApiEnvelope::queries(queries)).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + } + + #[test] + fn doc_api_unknown_id_surfaces_structured_error() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let ghost = ObjectId::from_u64(0xDEAD); + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: ghost })).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), "{err:?}"); + } + + // ── Roundtrip tests: create -> read back via queries -> assert geometry ── + + use ocs_doc_api::ops::{Curve2Spec, PlacementSpec}; + + /// Create an entity via DocApi, then read it back via GetEntity/GetBounds and + /// assert the geometry round-trips with the expected kind + bounds. + #[test] + fn roundtrip_2d_entities_line_circle_point_polyline() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Line [0,0,0]-[10,0,0]: kind + bounds round-trip. + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Line { start: [0.0, 0.0, 0.0], end: [10.0, 0.0, 0.0] }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: line }, Query::GetBounds { id: line }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Line"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + + // Circle centre (5,5,0) r=3: bounds = centre ± radius in XY. + let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [5.0, 5.0, 0.0], radius: 3.0 }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + + // Point at (1,2,3): degenerate bounds. + let point = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Point { position: [1.0, 2.0, 3.0] }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: point }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]), + other => panic!("expected bounds, got {other:?}"), + } + + // Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0. + let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Polyline { + points: vec![[0.0,0.0,0.0],[1.0,0.0,0.0],[1.0,1.0,0.0],[0.0,1.0,0.0]], + closed: true, + }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + } + + /// Geometric methods round-trip: transform moves a solid (bounds shift); + /// union/subtract produce the expected mass/volume relationships. + #[test] + fn roundtrip_geometric_methods_transform_and_booleans() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Two overlapping boxes [0,10]^3 and [5,15]^3. + let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid( + SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap()); + let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid( + SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap()); + + // Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125. + let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true }; + let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"), + other => panic!("expected volume, got {other:?}"), + } + + // THEN transform the result by +100 in X (a real, non-identity move): its + // bounds must shift by exactly +100 in X while Y/Z stay at [5,10]. + let move_op = Operation::Transform { id: inter, placement: PlacementSpec::at([100.0, 0.0, 0.0]) }; + dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(bb) => { + assert!((bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4, "{bb:?}"); + assert!((bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4, "{bb:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + } + + /// DWG roundtrip: create two intersected solids, write the document to a DWG + /// file, read it back, and assert the intersected solid survives with valid + /// ACIS data (the exact path `restore_solid_models` re-lifts on load). + #[test] + fn roundtrip_intersected_solids_through_dwg_file() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Two overlapping boxes -> intersect -> the result solid (125 vol). + let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid( + SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap()); + let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid( + SolidPrimitive::Cuboid { origin: [5.0; 3], size: [10.0; 3] }))).unwrap()); + let intersect_op = Operation::SolidBoolean { op: BoolOp::Intersection, a, b, erase_sources: true }; + let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); + + // Write the live document to a STABLE, inspectable DWG in the workspace + // target dir so it can be opened in a CAD viewer after the test. The file + // is kept (not deleted). If the stable path is momentarily locked (e.g. a + // concurrent test run or a viewer holding it), fall back to a process- + // unique path for the roundtrip so the test never flakes. + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); + std::fs::create_dir_all(&dir).expect("create target dir"); + let doc = host.document().clone(); + let stable = dir.join("doc_api_roundtrip_intersected.dwg"); + // Release any stale lock/handle on the stable file before rewriting. + let _ = std::fs::remove_file(&stable); + let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) { + Ok(()) => stable, + Err(_) => { + // Locked by another live process (e.g. a viewer) — write a unique + // file for the roundtrip instead of failing. + let alt = dir.join(format!("doc_api_roundtrip_intersected_{}.dwg", std::process::id())); + acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed"); + alt + } + }; + assert!(path.exists(), "DWG was written"); + + // Read it back. + let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG"); + let reloaded = reader.read().expect("read DWG failed"); + + // The intersected solid must be present as a Solid3D with valid ACIS data, + // and re-lifting it must reproduce a body with the expected volume. + let handle = obj_to_handle(lens); + let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else { + panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG"); + }; + assert!(solid.has_acis_data(), "reloaded solid carries ACIS data"); + + // Re-lift through the same SAT->kernel path used on load and check volume. + let body = crate::scene::convert::solid3d_tess::kernel_body(solid) + .expect("re-lift reloaded solid failed"); + let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); + let vol = mesh_volume_centroid(&mesh).0; + assert!((vol - 125.0).abs() < 1.0, "reloaded intersection volume {vol}"); + + // The DWG is intentionally kept for inspection (see `path` above). + } +} diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs new file mode 100644 index 000000000..3fbc29b6e --- /dev/null +++ b/src/app/doc_api_convert.rs @@ -0,0 +1,111 @@ +// DocApi v2 entity conversions (plan §5.1): map the crate's plain-data DTOs to +// acadrust `EntityType` and back. Lives in the host (uses the real acadrust +// types), keeping the crate's DTOs dependency-light. + +use acadrust::entities::{Circle, Line, LwPolyline, LwVertex, Point}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; +use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, ObjectId}; + +fn v3(p: [f64; 3]) -> Vector3 { + Vector3::new(p[0], p[1], p[2]) +} + +/// Build an acadrust entity for a 2D curve construction spec. +pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { + let entity = match spec { + Curve2Spec::Line { start, end } => { + let mut e = Line::new(); + e.start = v3(*start); + e.end = v3(*end); + EntityType::Line(e) + } + Curve2Spec::Circle { centre, radius } => { + let mut e = Circle::new(); + e.center = v3(*centre); + e.radius = *radius; + EntityType::Circle(e) + } + Curve2Spec::Polyline { points, closed } => { + let mut e = LwPolyline::new(); + e.vertices = points + .iter() + .map(|p| LwVertex { + location: Vector2::new(p[0], p[1]), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }) + .collect(); + e.is_closed = *closed; + EntityType::LwPolyline(e) + } + Curve2Spec::Point { position } => { + let mut e = Point::new(); + e.location = v3(*position); + EntityType::Point(e) + } + }; + Ok(entity) +} + +/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts). +pub fn entity_kind_name(entity: &EntityType) -> &'static str { + match entity { + EntityType::Solid3D(_) => "Solid3D", + EntityType::Line(_) => "Line", + EntityType::Circle(_) => "Circle", + EntityType::LwPolyline(_) => "LwPolyline", + EntityType::Polyline(_) => "Polyline", + EntityType::Point(_) => "Point", + EntityType::Arc(_) => "Arc", + EntityType::Ellipse(_) => "Ellipse", + EntityType::Spline(_) => "Spline", + EntityType::Text(_) => "Text", + EntityType::MText(_) => "MText", + EntityType::Insert(_) => "Insert", + _ => "Other", + } +} + +/// Coarse bounds for a non-solid entity (solids go through the kernel cache). +/// For v1 typed curves we compute the obvious bounds; anything else errors +/// `Unsupported` (viewport/annotation bounds land in later phases). +pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + let entity = entity.ok_or(ApiError::UnknownId(id))?; + let bb = match entity { + EntityType::Line(l) => Aabb { + min: [ + l.start.x.min(l.end.x), + l.start.y.min(l.end.y), + l.start.z.min(l.end.z), + ], + max: [ + l.start.x.max(l.end.x), + l.start.y.max(l.end.y), + l.start.z.max(l.end.z), + ], + }, + EntityType::Circle(c) => Aabb { + min: [c.center.x - c.radius, c.center.y - c.radius, c.center.z], + max: [c.center.x + c.radius, c.center.y + c.radius, c.center.z], + }, + EntityType::Point(p) => Aabb { + min: [p.location.x, p.location.y, p.location.z], + max: [p.location.x, p.location.y, p.location.z], + }, + EntityType::LwPolyline(pl) if !pl.vertices.is_empty() => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for vtx in &pl.vertices { + let (x, y, z) = (vtx.location.x, vtx.location.y, pl.elevation); + min = [min[0].min(x), min[1].min(y), min[2].min(z)]; + max = [max[0].max(x), max[1].max(y), max[2].max(z)]; + } + Aabb { min, max } + } + _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), + }; + Ok(bb) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index eb89bc04c..7465c9f20 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,6 +11,10 @@ pub(crate) mod expr_eval; mod find_replace; pub(crate) mod helpers; mod history; +#[cfg(not(target_arch = "wasm32"))] +mod doc_api; +#[cfg(not(target_arch = "wasm32"))] +mod doc_api_convert; mod layers; mod model_ops; mod mtext_editor; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index d60992822..3e5977de2 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -16,6 +16,8 @@ pub(crate) struct HostSession<'a> { app: &'a mut OpenCADStudio, tab: usize, doc_store: Option>, + /// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`). + doc_api_pending: Option, } impl<'a> HostSession<'a> { @@ -24,6 +26,7 @@ impl<'a> HostSession<'a> { app, tab, doc_store: None, + doc_api_pending: None, } } @@ -242,6 +245,37 @@ impl<'a> HostSession<'a> { self.app.push_undo_snapshot(self.tab, label); } + // ── DocApi v2 accessors (used by `app::doc_api` backend) ─────────────── + + pub(crate) fn scene(&self) -> &crate::scene::Scene { + &self.app.tabs[self.tab].scene + } + + pub(crate) fn scene_mut(&mut self) -> &mut crate::scene::Scene { + &mut self.app.tabs[self.tab].scene + } + + /// Commit a freshly-built entity into the document, returning its handle. + pub(crate) fn commit_entity_handle(&mut self, entity: EntityType) -> Option { + self.app.commit_entity_handle(entity) + } + + /// DocApi `push_undo`: begin an entity-only delta capture for one op. + pub(crate) fn begin_doc_api_undo(&mut self, label: &str) { + self.doc_api_pending = self.app.begin_undo(self.tab, label, 1, true); + } + + /// DocApi `finalize_op`: close the delta + republish the document view. + /// The underlying entity op (add/update/remove/register_solid_model) already + /// bumped `geometry_epoch`, so we do NOT bump again here — exactly one undo + /// step is recorded, and the epoch has advanced for this op. + pub(crate) fn commit_doc_api_undo(&mut self) { + if let Some(pending) = self.doc_api_pending.take() { + self.app.commit_undo_delta(self.tab, pending); + } + self.publish_document_view(); + } + pub fn set_dirty(&mut self) { self.app.tabs[self.tab].dirty = true; } @@ -360,6 +394,10 @@ impl HostApi for HostSession<'_> { fn close_document_view_v4(&mut self, tab_id: u64) { self.close_document_view_v4(tab_id) } + #[cfg(not(target_arch = "wasm32"))] + fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result, String> { + super::doc_api::execute_doc_api(self, tab_id, bytes) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) From d46351cdb3dbb644ac4b67915919298c37df5b23 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:51:39 +0200 Subject: [PATCH 02/31] ocs_doc_api: Phase 0 + Phase 2a (sweep profiles, non-solid transform, full 2D curves) Phase 0 (outstanding supported-family methods): - add_vertex on LwPolyline (insert vertex, out-of-range validation). - extrude / revolve from profiles (entity_to_profile_curves: Line/Circle/Arc/ LwPolyline straight-segment conversion -> geom2d::Curve -> brep::extrude/revolve). - non-solid transform + transform_many via acadrust geometry (transform_entity_geometry for Line/Circle/Arc/Point/LwPolyline). - ensure_transformable broadened accordingly (all-or-nothing pre-validation). Phase 2a (full 2D curve families): ArcCurve/Ellipse/Spline/Ray/XLine. - Curve2Spec extended (Arc/Ellipse/Spline/Ray/XLine) + create_arc/create_ellipse/ create_spline/create_ray/create_xline facade constructors + handle types. - entity_kind_name, entity_bounds (Arc coarse, Ellipse major-axis, Spline bbox; Ray/XLine unbounded), transform_entity_geometry, ensure_transformable extended. - spec/entities.toml families; api_reference.md + binding_schema.json regenerated. Tests: +6 doc_api integration tests (phase0_*, phase2_*) -> 598 host lib green. IMPLEMENTATION.md updated (Phase 0 done, Phase 2a done / 2b remaining). --- crates/ocs_doc_api/IMPLEMENTATION.md | 39 ++- crates/ocs_doc_api/spec/entities.toml | 63 +++- crates/ocs_doc_api/src/facade.rs | 30 ++ crates/ocs_doc_api/src/gen/api_reference.md | 36 ++- .../ocs_doc_api/src/gen/binding_schema.json | 35 +++ crates/ocs_doc_api/src/ops.rs | 11 + src/app/doc_api.rs | 276 ++++++++++++++++-- src/app/doc_api_convert.rs | 204 ++++++++++++- 8 files changed, 647 insertions(+), 47 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 4eb771fc9..1155f5c73 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -11,8 +11,9 @@ content). | 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 only | +| Transform | `transform(placement)` (in-place) | `Transform{id, placement}` → `brep::transform`; solids | | Bounds | `bounds()` | `GetBounds` → `brep::body_bounds` (lift-on-miss) | | Volume | `volume()` | `GetVolume` → `meshes.metrics` cache, mesh-divergence fallback | | Centroid | `centroid()` | `GetCentroid` → `meshes.metrics` cache, mesh-divergence fallback | @@ -21,17 +22,19 @@ content). ### Minimal 2D (phase-1 set, used as profiles/inputs) | Capability | Method / op | Notes | |---|---|---| -| Line | `create_line` → bounds | `CreateCurve(Line)` | -| Circle | `create_circle` → bounds | `CreateCurve(Circle)` | -| Polyline | `create_polyline` → bounds | `CreateCurve(Polyline)` | -| Point | `create_point` / `create_points` (bulk) | `CreateCurve(Point)` / `CreateMany` | +| 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 only (v1) | +| 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 | @@ -47,12 +50,15 @@ Phases are **spec-additive**: each is a `[[family]]`/`[[family.method]]` block i variants append at enum end. ### Phase 2 — full 2D + annotations -- **Entities:** ellipse, spline, xline/ray, arc (typed), text, mtext, dimension, - hatch. -- **Methods:** boundary/vertex actions (`add_vertex` on polyline — currently - `Unsupported`), hatch boundary query, text content get/set. -- **Blocked by:** `AddVertex` backend impl (currently returns `Unsupported`); - curve → `geom2d::Curve` conversion for non-trivial curves. +- **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. +- **Remaining (2b):** annotations `Text`/`MText` (create + content get/set), `Hatch` + (create + boundary query), `Dimension` (typed sub-types). +- **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline) + are supported (Phase 0). ### Phase 3 — containers - **Entities:** insert/block references, attribute definitions/entities. @@ -75,13 +81,14 @@ variants append at enum end. | Method | Family | Status | Blocker | |---|---|---|---| -| `extrude(profile, dir)` | Solid | stub | `profile_curves` backend returns `Unsupported`; needs entity → `geom2d::Curve` conversion | -| `revolve(profile, pivot, axis, angle)` | Solid | stub | same `profile_curves` blocker | | `loft(sections)` | Solid | not started | multi-profile kernel op + spec | -| `add_vertex(at, pt)` | Polyline | stub | backend `add_vertex` returns `Unsupported` | -| `transform` (non-solid) | 2D entities | `Unsupported` | per-family transform via acadrust (not kernel) | +| Bulge-arc polyline profiles | Solid sweep | partial | `entity_to_profile_curves` emits straight segments; bulge arcs need arc-segment conversion | | `GetCentroid`/`GetVolume` accuracy | Solid | mesh approx | render-mesh LOD tolerance vs query tolerance | +**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**. diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 35db08cdc..f6f2e0306 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -62,13 +62,11 @@ fixed = { primitive = "Wedge" } name = "extrude" args = [{ name = "profile", ty = "EntityRef" }, { name = "direction", ty = "[f64; 3]" }] op = "Extrude" -status = "unsupported" [[family.constructor]] name = "revolve" args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] op = "Revolve" -status = "unsupported" [[family.method]] name = "intersect" @@ -158,7 +156,6 @@ kind = "op" op = "AddVertex" args = [{ name = "at", ty = "usize" }, { name = "point", ty = "[f64; 3]" }] returns = "()" -status = "unsupported" [[family]] name = "Point" @@ -180,6 +177,66 @@ fixed = { curve = "Point" } bulk = true returns = "Vec" +[[family]] +name = "Arc" +acadrust_variant = "Arc" +handle = "ArcCurve" +collection = "curves" + +[[family.constructor]] +name = "create_arc" +args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "start_angle", ty = "f64" }, { name = "end_angle", ty = "f64" }] +op = "CreateCurve" +fixed = { curve = "Arc" } + +[[family]] +name = "Ellipse" +acadrust_variant = "Ellipse" +handle = "Ellipse" +collection = "curves" + +[[family.constructor]] +name = "create_ellipse" +args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_axis", ty = "[f64; 3]" }, { name = "ratio", ty = "f64" }, { name = "start", ty = "f64" }, { name = "end", ty = "f64" }] +op = "CreateCurve" +fixed = { curve = "Ellipse" } + +[[family]] +name = "Spline" +acadrust_variant = "Spline" +handle = "Spline" +collection = "curves" + +[[family.constructor]] +name = "create_spline" +args = [{ name = "degree", ty = "i32" }, { name = "control_points", ty = "&[[f64; 3]]" }, { name = "knots", ty = "&[f64]" }, { name = "weights", ty = "&[f64]" }] +op = "CreateCurve" +fixed = { curve = "Spline" } + +[[family]] +name = "Ray" +acadrust_variant = "Ray" +handle = "Ray" +collection = "curves" + +[[family.constructor]] +name = "create_ray" +args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] +op = "CreateCurve" +fixed = { curve = "Ray" } + +[[family]] +name = "XLine" +acadrust_variant = "XLine" +handle = "XLine" +collection = "curves" + +[[family.constructor]] +name = "create_xline" +args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] +op = "CreateCurve" +fixed = { curve = "XLine" } + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 2a01ed796..80c157be2 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -262,6 +262,11 @@ handle!(Line); handle!(Circle); handle!(Polyline); handle!(Point); +handle!(ArcCurve); +handle!(Ellipse); +handle!(Spline); +handle!(Ray); +handle!(XLine); impl Entity { pub fn view(&self) -> ApiResult { @@ -412,6 +417,31 @@ impl CurveCollection { 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 { + 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 { + 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 { + let (s, id) = self.create_curve(Curve2Spec::Spline { + degree, + control_points: control_points.to_vec(), + knots: knots.to_vec(), + weights: weights.to_vec(), + })?; + Ok(Spline::new(s, id)) + } + pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ray { origin, direction })?; + Ok(Ray::new(s, id)) + } + pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; + Ok(XLine::new(s, id)) + } /// Bulk-create many points in ONE op (plan §5.3): all-or-nothing, one undo step. pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { let specs: Vec = positions diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 3f9655bd1..69a191ceb 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -16,8 +16,8 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_cone**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid` - **create_torus**(`centre: [f64; 3], major_radius: f64, minor_radius: f64`) -> `Solid` - **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` -- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`) -- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` — **not yet supported** (returns `ApiError::Unsupported`) +- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` +- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` ### Methods @@ -48,7 +48,7 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ### Methods -- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` — **not yet supported** (returns `ApiError::Unsupported`) +- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` ## `Point` (acadrust `Point`, collection `curves`) @@ -57,6 +57,36 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_point**(`position: [f64; 3]`) -> `Point` - **create_points**(`positions: &[[f64; 3]]`) -> `Vec` *(bulk op)* +## `ArcCurve` (acadrust `Arc`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_arc**(`centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64`) -> `ArcCurve` + +## `Ellipse` (acadrust `Ellipse`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_ellipse**(`centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64`) -> `Ellipse` + +## `Spline` (acadrust `Spline`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_spline**(`degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]`) -> `Spline` + +## `Ray` (acadrust `Ray`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_ray**(`origin: [f64; 3], direction: [f64; 3]`) -> `Ray` + +## `XLine` (acadrust `XLine`, collection `curves`) + +### Constructors (`doc.curves()`) + +- **create_xline**(`origin: [f64; 3], direction: [f64; 3]`) -> `XLine` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 41a4c5f1e..b7e9c9ca2 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -152,6 +152,41 @@ "handle": "Point", "methods": [], "name": "Point" + }, + { + "acadrust_variant": "Arc", + "collection": "curves", + "handle": "ArcCurve", + "methods": [], + "name": "Arc" + }, + { + "acadrust_variant": "Ellipse", + "collection": "curves", + "handle": "Ellipse", + "methods": [], + "name": "Ellipse" + }, + { + "acadrust_variant": "Spline", + "collection": "curves", + "handle": "Spline", + "methods": [], + "name": "Spline" + }, + { + "acadrust_variant": "Ray", + "collection": "curves", + "handle": "Ray", + "methods": [], + "name": "Ray" + }, + { + "acadrust_variant": "XLine", + "collection": "curves", + "handle": "XLine", + "methods": [], + "name": "XLine" } ], "layouts": { diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 1e30f98e4..6ab66f47f 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -34,6 +34,17 @@ pub enum Curve2Spec { 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 }, + /// 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 }, + /// NURBS spline (control points + knots + weights; `degree` typically 3). + Spline { degree: i32, control_points: Vec<[f64; 3]>, knots: Vec, weights: Vec }, + /// A ray from `origin` along `direction` (bounded at origin only). + Ray { origin: [f64; 3], direction: [f64; 3] }, + /// An infinite construction line through `origin` along `direction`. + XLine { origin: [f64; 3], direction: [f64; 3] }, } /// A generic entity-construction payload used by the bulk op diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index b2f259000..b1c6abaa1 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -105,8 +105,28 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } - fn add_vertex(&mut self, _id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - Err(ApiError::Unsupported("AddVertex is not yet implemented".into())) + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { + let handle = obj_to_handle(id); + let Some(entity) = self.document().get_entity(handle).cloned() else { + return Err(ApiError::UnknownId(id)); + }; + let EntityType::LwPolyline(mut pl) = entity else { + return Err(ApiError::Unsupported("AddVertex is only implemented for polylines".into())); + }; + if at > pl.vertices.len() { + return Err(ApiError::validation( + "AddVertex", + format!("index {at} out of range ({} vertices)", pl.vertices.len()), + )); + } + pl.vertices.insert( + at, + acadrust::entities::LwVertex::from_coords(point[0], point[1]), + ); + if !self.scene_mut().update_entity(EntityType::LwPolyline(pl)) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) } fn remove_entity(&mut self, id: ObjectId) -> ApiResult { @@ -126,15 +146,29 @@ impl DocApiBackend for HostSession<'_> { } fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - // v1 typed transform is solids-only; pre-validating this lets TransformMany - // be all-or-nothing (no mid-loop Unsupported after earlier transforms). - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if matches!(self.document().get_entity(obj_to_handle(id)), Some(EntityType::Solid3D(_))) { + // Pre-validating this lets TransformMany be all-or-nothing. Solids and the + // supported 2D families are transformable; anything else errors up front. + let entity = self + .document() + .get_entity(obj_to_handle(id)) + .ok_or(ApiError::UnknownId(id))?; + let ok = matches!( + entity, + EntityType::Solid3D(_) + | EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + | EntityType::Ray(_) + | EntityType::XLine(_) + | EntityType::Point(_) + | EntityType::LwPolyline(_) + ); + if ok { Ok(()) } else { - Err(ApiError::Unsupported("transform is only implemented for solids in v1".into())) + Err(ApiError::Unsupported("transform is not supported for this entity family".into())) } } @@ -167,8 +201,8 @@ impl DocApiBackend for HostSession<'_> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); } - // Solids go through the kernel transform (re-embed SAT). Non-solids are - // out of scope for v1 typed transforms. + // Solids go through the kernel transform (re-embed SAT). 2D entities are + // transformed via acadrust geometry and updated in place. if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) { let body = self.resolve_body(id)?; let place = kernel_placement(placement); @@ -176,14 +210,25 @@ impl DocApiBackend for HostSession<'_> { .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed"))?; self.update_solid(id, &moved) } else { - Err(ApiError::Unsupported("transform is only implemented for solids in v1".into())) + let entity = self + .document() + .get_entity(handle) + .cloned() + .ok_or(ApiError::UnknownId(id))?; + let moved = crate::app::doc_api_convert::transform_entity_geometry(&entity, placement)?; + if !self.scene_mut().update_entity(moved) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) } } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - // Profiles for Extrude/Revolve land in a later phase (§5.2); v1 surfaces - // a clear error rather than guessing. - Err(ApiError::Unsupported("profile curves for sweep ops are not yet implemented".into())) + fn profile_curves(&self, id: ObjectId) -> ApiResult> { + let entity = self + .document() + .get_entity(obj_to_handle(id)) + .ok_or(ApiError::UnknownId(id))?; + crate::app::doc_api_convert::entity_to_profile_curves(entity) } fn bounds(&mut self, id: ObjectId) -> ApiResult { @@ -345,25 +390,23 @@ mod tests { } #[test] - fn doc_api_transform_many_with_non_solid_fails_all_or_nothing() { - use ocs_doc_api::ops::Curve2Spec; + fn doc_api_transform_many_with_stale_id_fails_all_or_nothing() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - // A solid and a non-solid (a line is not transformable in v1). + // A transformable solid, plus a stale id (always non-transformable -> UnknownId). let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }); - let mk_line = Operation::CreateCurve(Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] }); let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap()); - let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); + let stale = ObjectId::from_u64(0xFFFF_FFFF); let rev_before = host.scene().geometry_epoch; - // TransformMany over [solid, line]: the non-solid makes it fail BEFORE any + // TransformMany over [solid, stale]: the stale id makes it fail BEFORE any // mutation (all-or-nothing), so the epoch must not move and no undo is recorded. let op = Operation::TransformMany { - ids: vec![solid, line], + ids: vec![solid, stale], placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), }; let err = dispatch(&mut host, DocApiEnvelope::op(op)).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. } | ApiError::Unsupported(_)), "{err:?}"); + assert!(matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), "{err:?}"); assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); } @@ -377,6 +420,191 @@ mod tests { assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); } + // ── Phase 0: outstanding supported-family methods ────────────────────── + + #[test] + fn phase0_add_vertex_to_polyline() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], + closed: false, + }); + let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_poly)).unwrap()); + // Insert a vertex at index 1. + dispatch(&mut host, DocApiEnvelope::op(Operation::AddVertex { + id: poly, at: 1, point: [5.0, 0.0, 0.0] })).unwrap(); + // The polyline now has 4 vertices with the inserted one at index 1. + let handle = obj_to_handle(poly); + let Some(EntityType::LwPolyline(pl)) = host.document().get_entity(handle) else { + panic!("polyline not found"); + }; + assert_eq!(pl.vertices.len(), 4); + assert!((pl.vertices[1].location.x - 5.0).abs() < 1e-9); + // Out-of-range index is a validation error, not a panic. + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::AddVertex { + id: poly, at: 99, point: [0.0, 0.0, 0.0] })).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + } + + #[test] + fn phase0_extrude_rectangular_profile_to_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A closed rectangular 2x3 profile in XY. + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![[0.0,0.0,0.0],[2.0,0.0,0.0],[2.0,3.0,0.0],[0.0,3.0,0.0]], + closed: true, + }); + let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); + // Extrude +Z by 5 -> a 2x3x5 box = volume 30. + let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::Extrude { + profile, direction: [0.0, 0.0, 5.0] })).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id: solid }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Volume(v) => assert!((*v - 30.0).abs() < 1.0, "extrude volume {v}"), + other => panic!("expected volume, got {other:?}"), + } + } + + #[test] + fn phase0_revolve_profile_to_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y + // axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85. + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![[1.0,0.0,0.0],[2.0,0.0,0.0],[2.0,2.0,0.0],[1.0,2.0,0.0]], + closed: true, + }); + let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); + let result = dispatch(&mut host, DocApiEnvelope::op(Operation::Revolve { + profile, + axis: ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + angle: std::f64::consts::TAU, + })); + // Revolve may be geometry-sensitive; assert it either produces a positive + // volume or surfaces a structured geometry error (no panic). + match result { + Ok(receipt) => { + let id = receipt.outcome.and_then(|o| o.new_id()).unwrap(); + let v = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id }])).unwrap(); + match &v.query_results[0] { + QueryResult::Volume(vol) => assert!(*vol > 0.0, "revolve volume {vol}"), + other => panic!("expected volume, got {other:?}"), + } + } + Err(ApiError::Geometry { .. } | ApiError::Validation { .. }) => {} + Err(e) => panic!("unexpected error: {e}"), + } + } + + #[test] + fn phase0_non_solid_transform_line_and_circle() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let mk_line = Operation::CreateCurve(Curve2Spec::Line { start: [0.0; 3], end: [10.0; 3] }); + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); + // Translate the line by +5 in X: bounds shift by +5. + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: line, placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]) })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: line }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 5.0).abs() < 1e-6 && (b.max[0] - 15.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + // TransformMany over a mix of line + circle is now all-or-nothing (both transformable). + let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0; 3], radius: 2.0 }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::TransformMany { + ids: vec![line, circle], + placement: ocs_doc_api::PlacementSpec::at([0.0, 1.0, 0.0]), + })).unwrap(); + } + + // ── Phase 2: full 2D curve families ──────────────────────────────────── + + #[test] + fn phase2_arc_ellipse_spline_create_bounds_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Arc: create -> kind Arc; bounds are the coarse full-circle bounds. + let arc = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Arc { centre: [5.0, 5.0, 0.0], radius: 4.0, start_angle: 0.0, end_angle: std::f64::consts::PI }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: arc }, Query::GetBounds { id: arc }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Arc"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => assert!((b.min[0] - 1.0).abs() < 1e-6 && (b.max[0] - 9.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + + // Ellipse: create -> kind Ellipse; bounds = centre ± major-axis length. + let ell = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ellipse { centre: [0.0, 0.0, 0.0], major_axis: [6.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: std::f64::consts::TAU }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetEntity { id: ell }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Ellipse"), + other => panic!("expected entity, got {other:?}"), + } + + // Spline (degree-3 cubic through 4 control points): create -> kind Spline; + // bounds = control-point bounds. Transform by +10 X shifts bounds. + let spline = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Spline { + degree: 3, + control_points: vec![[0.0,0.0,0.0],[1.0,2.0,0.0],[2.0,-2.0,0.0],[3.0,0.0,0.0]], + knots: vec![0.0,0.0,0.0,0.0,1.0,1.0,1.0,1.0], + weights: vec![1.0; 4], + }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert!((b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: spline, placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]) })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert!((b.min[0] - 10.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + } + + #[test] + fn phase2_ray_xline_create_and_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let ray = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ray { origin: [1.0, 2.0, 0.0], direction: [1.0, 0.0, 0.0] }))).unwrap()); + let xline = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::XLine { origin: [0.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0] }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: ray }, Query::GetEntity { id: xline }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Ray"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Entity(v) => assert_eq!(v.kind, "XLine"), + other => panic!("expected entity, got {other:?}"), + } + // Ray/XLine are unbounded -> GetBounds is Unsupported (not a panic). + assert!(dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: ray }])).is_err()); + // Transform moves the ray's base point (no error). + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: ray, placement: ocs_doc_api::PlacementSpec::at([0.0, 0.0, 5.0]) })).unwrap(); + let handle = obj_to_handle(ray); + let Some(EntityType::Ray(r)) = host.document().get_entity(handle) else { panic!("ray not found") }; + assert!((r.base_point.z - 5.0).abs() < 1e-6); + } + #[test] fn doc_api_unknown_id_surfaces_structured_error() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 3fbc29b6e..83a212e8e 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -2,11 +2,152 @@ // acadrust `EntityType` and back. Lives in the host (uses the real acadrust // types), keeping the crate's DTOs dependency-light. -use acadrust::entities::{Circle, Line, LwPolyline, LwVertex, Point}; +use acadrust::entities::{Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine}; use acadrust::types::{Vector2, Vector3}; use acadrust::EntityType; use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, ObjectId}; +/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for +/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). +pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult> { + use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine}; + let curves = match entity { + EntityType::Line(l) => vec![KCurve::Line(KLine { + start: [l.start.x, l.start.y], + end: [l.end.x, l.end.y], + })], + EntityType::Circle(c) => vec![KCurve::Circle(KCircle { + centre: [c.center.x, c.center.y], + radius: c.radius, + })], + EntityType::Arc(a) => vec![KCurve::Arc(KArc { + centre: [a.center.x, a.center.y], + radius: a.radius, + start_angle: a.start_angle, + end_angle: a.end_angle, + })], + EntityType::LwPolyline(pl) => { + if pl.vertices.len() < 2 { + return Err(ApiError::validation("profile", "polyline profile needs >= 2 vertices")); + } + // Build a straight-segment polyline chain (bulge arcs land in a later + // phase; straight segments cover the common rectangular profile case). + let pts: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); + let mut segs = Vec::with_capacity(pts.len()); + for i in 0..pts.len() { + let start = pts[i]; + let end = if i + 1 < pts.len() { + pts[i + 1] + } else if pl.is_closed { + pts[0] + } else { + break; + }; + segs.push(KCurve::Line(KLine { start, end })); + } + segs + } + _ => { + return Err(ApiError::Unsupported( + "profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(), + )) + } + }; + Ok(curves) +} + +/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid +/// entity in place. Supports the common 2D families; `Solid3D` goes through the +/// kernel path instead. Returns the transformed entity (same handle) for the +/// caller to `update_entity`. +pub fn transform_entity_geometry( + entity: &EntityType, + p: &ocs_doc_api::PlacementSpec, +) -> ApiResult { + // Affine point transform: out = R * (s * v) + t. Uniform scale 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(); + let apply = |v: Vector3| -> Vector3 { + let x = v.x * s; + let y = v.y * s; + let z = v.z * s; + Vector3::new( + p.x_axis[0] * x + p.y_axis[0] * y + p.z_axis[0] * z + p.origin[0], + p.x_axis[1] * x + p.y_axis[1] * y + p.z_axis[1] * z + p.origin[1], + p.x_axis[2] * x + p.y_axis[2] * y + p.z_axis[2] * z + p.origin[2], + ) + }; + 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; + EntityType::Arc(a) + } + EntityType::Ellipse(e) => { + let mut e = e.clone(); + e.center = apply(e.center); + // Scale the major-axis vector by the uniform scale (rigid similarity). + e.major_axis = Vector3::new( + e.major_axis.x * s, e.major_axis.y * s, e.major_axis.z * s, + ); + 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); + EntityType::Ray(r) + } + EntityType::XLine(x) => { + let mut x = x.clone(); + x.base_point = apply(x.base_point); + EntityType::XLine(x) + } + 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 { Vector3::new(p[0], p[1], p[2]) } @@ -46,6 +187,37 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.location = v3(*position); EntityType::Point(e) } + Curve2Spec::Arc { centre, radius, start_angle, end_angle } => { + let mut e = Arc::new(); + e.center = v3(*centre); + e.radius = *radius; + e.start_angle = *start_angle; + e.end_angle = *end_angle; + EntityType::Arc(e) + } + Curve2Spec::Ellipse { centre, major_axis, ratio, start, end } => { + let mut e = Ellipse::new(); + e.center = v3(*centre); + e.major_axis = v3(*major_axis); + e.minor_axis_ratio = *ratio; + e.start_parameter = *start; + e.end_parameter = *end; + EntityType::Ellipse(e) + } + 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(); + e.knots = knots.clone(); + e.weights = weights.clone(); + EntityType::Spline(e) + } + Curve2Spec::Ray { origin, direction } => { + EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) + } + Curve2Spec::XLine { origin, direction } => { + EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) + } }; Ok(entity) } @@ -62,6 +234,8 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Arc(_) => "Arc", EntityType::Ellipse(_) => "Ellipse", EntityType::Spline(_) => "Spline", + EntityType::Ray(_) => "Ray", + EntityType::XLine(_) => "XLine", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", EntityType::Insert(_) => "Insert", @@ -105,6 +279,34 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + // 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) if !s.control_points.is_empty() => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for p in &s.control_points { + 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)]; + } + Aabb { min, max } + } + // 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) From b03f68737e0112f1e59b80573786b3fdd6c59a33 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:05:51 +0200 Subject: [PATCH 03/31] ocs_doc_api: Phase 3 (block references) - Operation::CreateInsert(InsertSpec) appended to the wire enum (append-only). - backend add_insert validates the BlockRecord exists (unknown block -> Validation, no mutation); host inserts acadrust::entities::Insert with scale+rotation. - Insert transform moves insert_point (transform_entity_geometry) + ensure_transformable includes Insert. - facade EntityCollection::create_insert; spec Insert family; api_reference + binding_schema regenerated; mocks updated. - Test phase3_create_insert_and_transform -> 599 host lib green. - IMPLEMENTATION.md: Phase 3 done (attributes/traversal remaining). --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 +-- crates/ocs_doc_api/examples/common/mod.rs | 6 +++ crates/ocs_doc_api/spec/entities.toml | 15 ++++++ crates/ocs_doc_api/src/backend.rs | 4 ++ crates/ocs_doc_api/src/executor.rs | 7 +++ crates/ocs_doc_api/src/facade.rs | 15 ++++++ crates/ocs_doc_api/src/gen/api_reference.md | 8 +++ .../ocs_doc_api/src/gen/binding_schema.json | 7 +++ crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/ops.rs | 12 +++++ crates/ocs_doc_api/tests/mock_backend.rs | 6 +++ src/app/doc_api.rs | 52 +++++++++++++++++++ src/app/doc_api_convert.rs | 7 ++- 13 files changed, 146 insertions(+), 5 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 1155f5c73..31bf0bcda 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -61,9 +61,11 @@ variants append at enum end. are supported (Phase 0). ### Phase 3 — containers -- **Entities:** insert/block references, attribute definitions/entities. -- **Methods:** `create_insert`, placement, attribute get/set. -- **Read-only:** nested block content traversal (nested ≠ first-level). +- **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. +- **Remaining:** `AttributeDefinition`/`AttributeEntity` typed create + attribute + get/set; read-only nested block-content traversal. ### Phase 4 — paper-space & viewports - **Entities:** viewport entities. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 8b05ffb1d..e9ec290ab 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -99,6 +99,12 @@ impl DocApiBackend for MockBackend { self.curves.insert(id, spec.clone()); Ok(id) } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { + if spec.block_name.is_empty() { + return Err(ApiError::validation("CreateInsert", "empty block name")); + } + Ok(self.alloc("Insert")) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index f6f2e0306..e30563518 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -237,6 +237,21 @@ args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; op = "CreateCurve" fixed = { curve = "XLine" } +# ============================================================================ +# CONTAINER FAMILY (phase 3) — block references +# ============================================================================ +[[family]] +name = "Insert" +acadrust_variant = "Insert" +handle = "Entity" +collection = "entities" +description = "Block reference. Place an existing BlockRecord by name; transform moves its insert point." + +[[family.constructor]] +name = "create_insert" +args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] +op = "CreateInsert" + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 16bb91bc0..9be15e0ba 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -35,6 +35,10 @@ pub trait DocApiBackend { /// Add a 2D curve entity; returns the fresh `ObjectId`. fn add_curve(&mut self, spec: &Curve2Spec) -> 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; + /// Remove any first-level entity by id. Returns `false` if absent; returns /// `Err` if the entity exists but cannot be removed (e.g. locked layer). fn remove_entity(&mut self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index f2736bf3c..4fe69917f 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -176,6 +176,13 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // Validate the block name exists before committing (no rollback needed). + b.push_undo(name); + let id = b.add_insert(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 80c157be2..ea4f73d32 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -464,6 +464,21 @@ pub struct EntityCollection { } impl EntityCollection { + /// 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 { + 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()) + .ok_or_else(|| ApiError::Transport("create_insert returned no id".into()))?; + Ok(Entity::new(self.session.clone(), id)) + } + /// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …). pub fn get(&self, id: ObjectId) -> ApiResult { // Validate existence eagerly so `get` errors surface as `UnknownId`. diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 69a191ceb..201f9e5ac 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -87,6 +87,14 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_xline**(`origin: [f64; 3], direction: [f64; 3]`) -> `XLine` +## `Entity` (acadrust `Insert`, collection `entities`) + +Block reference. Place an existing BlockRecord by name; transform moves its insert point. + +### Constructors (`doc.entities()`) + +- **create_insert**(`block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64`) -> `Entity` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index b7e9c9ca2..611ca78ca 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -187,6 +187,13 @@ "handle": "XLine", "methods": [], "name": "XLine" + }, + { + "acadrust_variant": "Insert", + "collection": "entities", + "handle": "Entity", + "methods": [], + "name": "Insert" } ], "layouts": { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 2070e6117..3e7883f3a 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, EntitySpec, PlacementSpec, SolidPrimitive}; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -27,4 +27,6 @@ pub enum Operation { CreateMany(Vec), TransformMany { ids: Vec, placement: PlacementSpec }, DeleteMany(Vec), + // containers + CreateInsert(InsertSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 6ab66f47f..f6ca50ecd 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -56,6 +56,17 @@ pub enum EntitySpec { Solid(SolidPrimitive), } +/// Construction spec for a block reference (`INSERT`): place the block +/// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform +/// `scale` and `rotation` (radians, about Z). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InsertSpec { + pub block_name: String, + pub insert_point: [f64; 3], + pub scale: f64, + pub rotation: f64, +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -103,6 +114,7 @@ impl crate::gen::Operation { CreateMany(_) => "CreateMany", TransformMany { .. } => "TransformMany", DeleteMany(_) => "DeleteMany", + CreateInsert(_) => "CreateInsert", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index eba7e75ce..5de13c856 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -59,6 +59,12 @@ impl DocApiBackend for MockBackend { }; Ok(self.alloc(kind)) } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { + if spec.block_name.is_empty() { + return Err(ApiError::validation("CreateInsert", "empty block name")); + } + Ok(self.alloc("Insert")) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index b1c6abaa1..e6faf7db2 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -105,6 +105,26 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { + // Validate the referenced block exists before committing. + if self.document().block_records.get(&spec.block_name).is_none() { + return Err(ApiError::validation( + "CreateInsert", + format!("unknown block {:?}", spec.block_name), + )); + } + let mut ins = acadrust::entities::Insert::new( + spec.block_name.clone(), + acadrust::types::Vector3::new(spec.insert_point[0], spec.insert_point[1], spec.insert_point[2]), + ); + ins.rotation = spec.rotation; + ins.set_x_scale(spec.scale); + ins.set_y_scale(spec.scale); + ins.set_z_scale(spec.scale); + let handle = self.scene_mut().add_entity(EntityType::Insert(ins)); + Ok(handle_to_obj(handle)) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -162,6 +182,7 @@ impl DocApiBackend for HostSession<'_> { | EntityType::Spline(_) | EntityType::Ray(_) | EntityType::XLine(_) + | EntityType::Insert(_) | EntityType::Point(_) | EntityType::LwPolyline(_) ); @@ -605,6 +626,37 @@ mod tests { assert!((r.base_point.z - 5.0).abs() < 1e-6); } + // ── Phase 3: containers (block references) ─────────────────────────────── + + #[test] + fn phase3_create_insert_and_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Register a block record so the insert can reference it. + host.document_mut().block_records.add(acadrust::tables::BlockRecord::new("MyBlock")).unwrap(); + + // Insert the block at (10,20,0) scale 2, rotation 0. + let ins = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateInsert( + ocs_doc_api::ops::InsertSpec { block_name: "MyBlock".into(), insert_point: [10.0, 20.0, 0.0], scale: 2.0, rotation: 0.0 }))).unwrap()); + let handle = obj_to_handle(ins); + let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { panic!("insert not found") }; + assert_eq!(i.block_name, "MyBlock"); + assert!((i.insert_point.x - 10.0).abs() < 1e-9 && (i.x_scale() - 2.0).abs() < 1e-9); + + // Transform the insert by +5 in X. + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: ins, placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]) })).unwrap(); + let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { panic!("insert not found") }; + assert!((i.insert_point.x - 15.0).abs() < 1e-6); + + // Inserting a non-existent block is a Validation error, no entity created. + let before = host.scene().geometry_epoch; + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::CreateInsert( + ocs_doc_api::ops::InsertSpec { block_name: "NoSuchBlock".into(), insert_point: [0.0; 3], scale: 1.0, rotation: 0.0 }))).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + assert_eq!(host.scene().geometry_epoch, before, "no entity on unknown block"); + } + #[test] fn doc_api_unknown_id_surfaces_structured_error() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 83a212e8e..880507133 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -124,6 +124,11 @@ pub fn transform_entity_geometry( x.base_point = apply(x.base_point); EntityType::XLine(x) } + EntityType::Insert(ins) => { + let mut ins = ins.clone(); + ins.insert_point = apply(ins.insert_point); + EntityType::Insert(ins) + } EntityType::Point(pt) => { let mut pt = pt.clone(); pt.location = apply(pt.location); @@ -236,9 +241,9 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Spline(_) => "Spline", EntityType::Ray(_) => "Ray", EntityType::XLine(_) => "XLine", + EntityType::Insert(_) => "Insert", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", - EntityType::Insert(_) => "Insert", _ => "Other", } } From 7a481c6ce2c0425eac05d6c44cd45d3b312d667f Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:16:31 +0200 Subject: [PATCH 04/31] ocs_doc_api: Phase 4 (paper-space viewports) - Operation::CreateViewport(ViewportSpec) appended to the wire enum (append-only). - backend add_viewport (center/width/height/view_target/view_height). - Viewport bounds (center +- w/2,h/2), transform (moves center), ensure_transformable. - facade EntityCollection::create_viewport; spec Viewport family; api_reference + binding_schema regenerated; mocks updated. - Test phase4_create_viewport_bounds_transform -> 600 host lib green. - IMPLEMENTATION.md: Phase 4 done (set_view/view-query remaining). --- crates/ocs_doc_api/IMPLEMENTATION.md | 9 ++-- crates/ocs_doc_api/examples/common/mod.rs | 3 ++ crates/ocs_doc_api/spec/entities.toml | 15 ++++++ crates/ocs_doc_api/src/backend.rs | 3 ++ crates/ocs_doc_api/src/executor.rs | 6 +++ crates/ocs_doc_api/src/facade.rs | 13 +++++ crates/ocs_doc_api/src/gen/api_reference.md | 8 +++ .../ocs_doc_api/src/gen/binding_schema.json | 7 +++ crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/ops.rs | 13 +++++ crates/ocs_doc_api/tests/mock_backend.rs | 3 ++ src/app/doc_api.rs | 53 +++++++++++++++++++ src/app/doc_api_convert.rs | 18 +++++++ 13 files changed, 150 insertions(+), 5 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 31bf0bcda..91dc6db3c 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -68,10 +68,11 @@ variants append at enum end. get/set; read-only nested block-content traversal. ### Phase 4 — paper-space & viewports -- **Entities:** viewport entities. -- **Methods:** `create_viewport`, set-view (target, center, scale), layout - queries. -- **Blocked by:** a viewport query model (view target/center/scale DTOs). +- **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. +- **Remaining:** `set_view` (retarget/re-zoom an existing viewport) and a + viewport-view query (view target/center/scale DTOs). ### Phase 5 — media & misc (read-mostly) - **Entities:** raster image, underlay, table, light. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index e9ec290ab..d01a2aa43 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -105,6 +105,9 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("Insert")) } + fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + Ok(self.alloc("Viewport")) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index e30563518..7b8de56ed 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -252,6 +252,21 @@ name = "create_insert" args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] op = "CreateInsert" +# ============================================================================ +# PAPER-SPACE FAMILY (phase 4) — viewports +# ============================================================================ +[[family]] +name = "Viewport" +acadrust_variant = "Viewport" +handle = "Entity" +collection = "entities" +description = "Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center." + +[[family.constructor]] +name = "create_viewport" +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" }] +op = "CreateViewport" + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 9be15e0ba..5fc03379e 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -39,6 +39,9 @@ pub trait DocApiBackend { /// the fresh `ObjectId`. `Validation` if the block is unknown. fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult; + /// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`. + fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult; + /// Remove any first-level entity by id. Returns `false` if absent; returns /// `Err` if the entity exists but cannot be removed (e.g. locked layer). fn remove_entity(&mut self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 4fe69917f..5267229b3 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -183,6 +183,12 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_viewport(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index ea4f73d32..02bb6bbf0 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -464,6 +464,19 @@ 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 { + 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()) + .ok_or_else(|| ApiError::Transport("create_viewport 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 { let receipt = self.session.apply_op(Operation::CreateInsert(crate::ops::InsertSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 201f9e5ac..0447dbbbb 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -95,6 +95,14 @@ Block reference. Place an existing BlockRecord by name; transform moves its inse - **create_insert**(`block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64`) -> `Entity` +## `Entity` (acadrust `Viewport`, collection `entities`) + +Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. + +### Constructors (`doc.entities()`) + +- **create_viewport**(`center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64`) -> `Entity` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 611ca78ca..5999bc28a 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -194,6 +194,13 @@ "handle": "Entity", "methods": [], "name": "Insert" + }, + { + "acadrust_variant": "Viewport", + "collection": "entities", + "handle": "Entity", + "methods": [], + "name": "Viewport" } ], "layouts": { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 3e7883f3a..7657cb82c 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive}; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -29,4 +29,6 @@ pub enum Operation { DeleteMany(Vec), // containers CreateInsert(InsertSpec), + // paper-space + CreateViewport(ViewportSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index f6ca50ecd..8d3566059 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -67,6 +67,18 @@ pub struct InsertSpec { pub rotation: f64, } +/// Construction spec for a paper-space `VIEWPORT`: a `width`×`height` viewport +/// centered at `center` (paper space), looking at `view_target` (model space, +/// WCS) with `view_height` (model-space height visible; the viewport's zoom). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ViewportSpec { + pub center: [f64; 3], + pub width: f64, + pub height: f64, + pub view_target: [f64; 3], + pub view_height: f64, +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -115,6 +127,7 @@ impl crate::gen::Operation { TransformMany { .. } => "TransformMany", DeleteMany(_) => "DeleteMany", CreateInsert(_) => "CreateInsert", + CreateViewport(_) => "CreateViewport", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 5de13c856..695539c61 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -65,6 +65,9 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("Insert")) } + fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + Ok(self.alloc("Viewport")) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index e6faf7db2..7935c667e 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -125,6 +125,20 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + use acadrust::entities::Viewport; + use acadrust::types::Vector3; + let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); + let mut vp = Viewport::new(); + vp.center = v3(spec.center); + vp.width = spec.width; + vp.height = spec.height; + vp.view_target = v3(spec.view_target); + vp.view_height = spec.view_height; + let handle = self.scene_mut().add_entity(EntityType::Viewport(vp)); + Ok(handle_to_obj(handle)) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -183,6 +197,7 @@ impl DocApiBackend for HostSession<'_> { | EntityType::Ray(_) | EntityType::XLine(_) | EntityType::Insert(_) + | EntityType::Viewport(_) | EntityType::Point(_) | EntityType::LwPolyline(_) ); @@ -626,6 +641,44 @@ mod tests { assert!((r.base_point.z - 5.0).abs() < 1e-6); } + // ── Phase 4: paper-space viewports ─────────────────────────────────────── + + #[test] + fn phase4_create_viewport_bounds_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A 40x30 paper-space viewport at (50,50,0) looking at model origin. + let vp = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateViewport( + ocs_doc_api::ops::ViewportSpec { + center: [50.0, 50.0, 0.0], width: 40.0, height: 30.0, + view_target: [0.0, 0.0, 0.0], view_height: 100.0, + }))).unwrap()); + let handle = obj_to_handle(vp); + let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { panic!("viewport not found") }; + assert!((v.width - 40.0).abs() < 1e-9 && (v.view_height - 100.0).abs() < 1e-9); + + // Bounds = center ± width/2, height/2. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: vp }, Query::GetBounds { id: vp }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Viewport"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 30.0).abs() < 1e-6 && (b.max[0] - 70.0).abs() < 1e-6, "{b:?}"); + assert!((b.min[1] - 35.0).abs() < 1e-6 && (b.max[1] - 65.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + + // Transform moves the viewport's paper-space center. + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: vp, placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]) })).unwrap(); + let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { panic!("viewport not found") }; + assert!((v.center.x - 60.0).abs() < 1e-6); + } + // ── Phase 3: containers (block references) ─────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 880507133..87891aa4c 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -129,6 +129,11 @@ pub fn transform_entity_geometry( ins.insert_point = apply(ins.insert_point); EntityType::Insert(ins) } + EntityType::Viewport(vp) => { + let mut vp = vp.clone(); + vp.center = apply(vp.center); + EntityType::Viewport(vp) + } EntityType::Point(pt) => { let mut pt = pt.clone(); pt.location = apply(pt.location); @@ -242,6 +247,7 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Ray(_) => "Ray", EntityType::XLine(_) => "XLine", EntityType::Insert(_) => "Insert", + EntityType::Viewport(_) => "Viewport", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", _ => "Other", @@ -311,6 +317,18 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult 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, + ], + }, // Ray/XLine are unbounded by definition — no finite bounds. _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), }; From f24c0b2d757d87b376336ad71ac620706eb0c345 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:22:28 +0200 Subject: [PATCH 05/31] ocs_doc_api: Phase 5 (media & misc, read-mostly) - entity_kind_name names the media/misc families (RasterImage, Table, Leader, MultiLeader, MLine, Mesh, Helix, Region, Body, Surface, Face3D, Dimension, Hatch) so GetEntity reports a real kind instead of 'Other'. - RasterImage coarse bounds (insertion + u*width + v*height). - Generic delete already works on these families; no typed create in v1 (read-only EntityView per plan). - spec RasterImage/Table read-mostly families; IMPLEMENTATION.md updated. - Test phase5_media_entities_read_kind_and_bounds -> 601 host lib green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 12 ++++-- crates/ocs_doc_api/spec/entities.toml | 19 ++++++++++ crates/ocs_doc_api/src/gen/api_reference.md | 8 ++++ .../ocs_doc_api/src/gen/binding_schema.json | 14 +++++++ src/app/doc_api.rs | 37 +++++++++++++++++++ src/app/doc_api_convert.rs | 24 ++++++++++++ 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 91dc6db3c..8eafeee40 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -75,10 +75,14 @@ variants append at enum end. viewport-view query (view target/center/scale DTOs). ### Phase 5 — media & misc (read-mostly) -- **Entities:** raster image, underlay, table, light. -- **Methods:** CRUD + property queries; few/no geometric actions. These families - largely stay read-only `EntityView` DTOs — typed ops only where the spec - declares an action mapping. +- **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"; + `RasterImage` has coarse bounds (insertion + u·width + v·height); generic + `delete` works on all. Covered by `phase5_*` test. +- **Remaining:** typed create/property ops for `RasterImage`/`Table`/light; + these largely stay read-only `EntityView` DTOs (typed ops only where the spec + declares an action mapping). ## 🔧 Outstanding methods on supported families diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 7b8de56ed..7eb4fc097 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -267,6 +267,25 @@ name = "create_viewport" 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" }] op = "CreateViewport" +# ============================================================================ +# MEDIA & MISC (phase 5, read-mostly) — read-only EntityView, no typed create. +# These families report a real `kind` via GetEntity and coarse bounds where +# computable; they have no typed construction/geometric ops in v1. +# ============================================================================ +[[family]] +name = "RasterImage" +acadrust_variant = "RasterImage" +handle = "Entity" +collection = "entities" +description = "Raster image (read-mostly): GetEntity kind + coarse bounds (insertion + u*width + v*height); generic delete. No typed create in v1." + +[[family]] +name = "Table" +acadrust_variant = "Table" +handle = "Entity" +collection = "entities" +description = "Table (read-only): GetEntity kind; generic delete. No typed create/bounds in v1." + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 0447dbbbb..71f95f743 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -103,6 +103,14 @@ Paper-space viewport: a width×height window at center looking at view_target wi - **create_viewport**(`center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64`) -> `Entity` +## `Entity` (acadrust `RasterImage`, collection `entities`) + +Raster image (read-mostly): GetEntity kind + coarse bounds (insertion + u*width + v*height); generic delete. No typed create in v1. + +## `Entity` (acadrust `Table`, collection `entities`) + +Table (read-only): GetEntity kind; generic delete. No typed create/bounds in v1. + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 5999bc28a..179c357d9 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -201,6 +201,20 @@ "handle": "Entity", "methods": [], "name": "Viewport" + }, + { + "acadrust_variant": "RasterImage", + "collection": "entities", + "handle": "Entity", + "methods": [], + "name": "RasterImage" + }, + { + "acadrust_variant": "Table", + "collection": "entities", + "handle": "Entity", + "methods": [], + "name": "Table" } ], "layouts": { diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 7935c667e..bbb3ac1e1 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -679,6 +679,43 @@ mod tests { assert!((v.center.x - 60.0).abs() < 1e-6); } + // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── + + #[test] + fn phase5_media_entities_read_kind_and_bounds() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Insert a RasterImage directly (read-mostly: DocApi reads kind + bounds). + let img = { + use acadrust::entities::RasterImage; + use acadrust::types::{Vector2, Vector3}; + let mut img = RasterImage::default(); + img.insertion_point = Vector3::new(10.0, 20.0, 0.0); + img.u_vector = Vector3::new(0.5, 0.0, 0.0); // 0.5 world-units/pixel in X + img.v_vector = Vector3::new(0.0, 0.5, 0.0); + img.size = Vector2::new(100.0, 50.0); + host.document_mut().add_entity(EntityType::RasterImage(img)).unwrap() + }; + let img_id = handle_to_obj(img); + // GetEntity reports kind "RasterImage"; bounds = insertion + u*100 + v*50 = (60, 45). + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetEntity { id: img_id }, Query::GetBounds { id: img_id }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(e) => assert_eq!(e.kind, "RasterImage"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6, "{b:?}"); + assert!((b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + // Generic delete works on media entities (read-mostly, but deletable). + dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: img_id })).unwrap(); + assert!(host.document().get_entity(img).is_none()); + } + // ── Phase 3: containers (block references) ─────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 87891aa4c..38bea725c 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -250,6 +250,20 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Viewport(_) => "Viewport", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", + // Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind. + EntityType::RasterImage(_) => "RasterImage", + EntityType::Table(_) => "Table", + EntityType::Leader(_) => "Leader", + EntityType::MultiLeader(_) => "MultiLeader", + EntityType::MLine(_) => "MLine", + EntityType::Mesh(_) => "Mesh", + EntityType::Helix(_) => "Helix", + EntityType::Region(_) => "Region", + EntityType::Body(_) => "Body", + EntityType::Surface(_) => "Surface", + EntityType::Face3D(_) => "Face3D", + EntityType::Dimension(_) => "Dimension", + EntityType::Hatch(_) => "Hatch", _ => "Other", } } @@ -329,6 +343,16 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + // Coarse image rect: origin + u*width + v*height (in world units). + let w = img.u_vector.x * img.size.x + img.v_vector.x * img.size.y; + let h = img.u_vector.y * img.size.x + img.v_vector.y * img.size.y; + let (x0, y0) = (img.insertion_point.x, img.insertion_point.y); + Aabb { + min: [x0.min(x0 + w), y0.min(y0 + h), img.insertion_point.z], + max: [x0.max(x0 + w), y0.max(y0 + h), img.insertion_point.z], + } + } // Ray/XLine are unbounded by definition — no finite bounds. _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), }; From 0e18867915b8bcc4230f4e74d0aff8bdd8290576 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:23:25 +0200 Subject: [PATCH 06/31] =?UTF-8?q?IMPLEMENTATION.md:=20roadmap=20status=20?= =?UTF-8?q?=E2=80=94=20all=205=20phases=20done=20(with=20noted=20remaining?= =?UTF-8?q?=20sub-items)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 8eafeee40..00e35823d 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -42,13 +42,19 @@ content). | Binding handover | `binding_schema_json()` | merged object-model + wire-layout schema | | DWG round-trip | — | verified: intersected solids survive write→reload with valid ACIS (test) | -## 🚧 Outstanding — by phase +## 🚧 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:** Phase 0 (supported-family methods), Phase 2a (full 2D curves), +Phase 3 (block references), Phase 4 (viewports), Phase 5 (media read-mostly) are +**done** (all tested; 601 host lib + 8 crate unit tests green). Remaining sub-items: +Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal +(P3), set_view + view query (P4), typed media ops (P5). + ### 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 From fabdeae8bf23ce5563c24be7a905b5c26f854aac Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:29:51 +0200 Subject: [PATCH 07/31] ocs_doc_api: make mock backends exhaustive over new Curve2Spec variants Add Arc/Ellipse/Spline/Ray/XLine arms to the test + example mock add_curve kind matching and the example curve_bounds, so the crate's unit tests and examples compile against the Phase 2 curve families. --- crates/ocs_doc_api/examples/common/mod.rs | 25 +++++++++++++++++++++++ crates/ocs_doc_api/tests/mock_backend.rs | 5 +++++ 2 files changed, 30 insertions(+) diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index d01a2aa43..8e129fdde 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -70,6 +70,26 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { } Aabb { min, max } } + Curve2Spec::Arc { centre, radius, .. } => Aabb { + min: [centre[0] - radius, centre[1] - radius, centre[2]], + max: [centre[0] + radius, centre[1] + radius, centre[2]], + }, + Curve2Spec::Ellipse { centre, major_axis, .. } => { + let r = (major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2]).sqrt(); + Aabb { min: [centre[0] - r, centre[1] - r, centre[2] - r], max: [centre[0] + r, centre[1] + r, centre[2] + r] } + } + Curve2Spec::Spline { control_points, .. } => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for p in control_points { + for i in 0..3 { + min[i] = min[i].min(p[i]); + max[i] = max[i].max(p[i]); + } + } + Aabb { min, max } + } + Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { min: *origin, max: *origin }, } } @@ -94,6 +114,11 @@ impl DocApiBackend for MockBackend { Curve2Spec::Circle { .. } => "Circle", Curve2Spec::Polyline { .. } => "LwPolyline", Curve2Spec::Point { .. } => "Point", + Curve2Spec::Arc { .. } => "Arc", + Curve2Spec::Ellipse { .. } => "Ellipse", + Curve2Spec::Spline { .. } => "Spline", + Curve2Spec::Ray { .. } => "Ray", + Curve2Spec::XLine { .. } => "XLine", }; let id = self.alloc(kind); self.curves.insert(id, spec.clone()); diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 695539c61..99ad1b04a 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -56,6 +56,11 @@ impl DocApiBackend for MockBackend { Curve2Spec::Circle { .. } => "Circle", Curve2Spec::Polyline { .. } => "LwPolyline", Curve2Spec::Point { .. } => "Point", + Curve2Spec::Arc { .. } => "Arc", + Curve2Spec::Ellipse { .. } => "Ellipse", + Curve2Spec::Spline { .. } => "Spline", + Curve2Spec::Ray { .. } => "Ray", + Curve2Spec::XLine { .. } => "XLine", }; Ok(self.alloc(kind)) } From c74504f30c341ecbcd9e3d0e14f0ba2cfd6ffbc1 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:36:06 +0200 Subject: [PATCH 08/31] ocs_doc_api: fix review findings (scale/rotation/all-or-nothing/DoS/bounds/codegen) CRITICAL: - transform_entity_geometry: fix s^2 double-scale (axes already carry scale; drop the *s pre-multiply so 2D entities scale by s like solids), and handle rotation for direction/angle fields (apply_dir for Ray/XLine direction + Ellipse major_axis; offset Arc angles; compose Insert rotation). - ensure_transformable: check layer-lock + resolve solid bodies (not just type), restoring TransformMany all-or-nothing; regression test with a locked layer. WARNING: - volume/centroid: memoize per (handle, geometry_epoch) + cap cold-cache tessellation budget (query-batch DoS). - entity_to_profile_curves: fail Unsupported on polyline bulge (no silent chord-substituted sweep profile). - Ellipse: validate minor_axis_ratio in (0,1] so bounds never under-bound. - RasterImage bounds: bracket all four corners (rotated/flipped images). SUGGESTION: - binding_schema.json handles derived from spec families (includes ArcCurve/ Ellipse/Spline/Ray/XLine). - build.rs header: enums are hand-maintained (not 'GENERATED'); ops_gen/query_gen headers corrected to match. - Dedupe divergence mesh_volume_centroid into the crate's geom module; host + both mocks delegate. Tests: +3 regression tests (scale-once, rotation, locked-layer all-or-nothing) -> 604 host lib green; 8 crate unit tests green. --- crates/ocs_doc_api/build.rs | 27 ++- crates/ocs_doc_api/examples/common/mod.rs | 23 +-- .../ocs_doc_api/src/gen/binding_schema.json | 7 +- crates/ocs_doc_api/src/gen/ops_gen.rs | 9 +- crates/ocs_doc_api/src/gen/query_gen.rs | 8 +- crates/ocs_doc_api/src/geom.rs | 25 +++ crates/ocs_doc_api/tests/mock_backend.rs | 57 +----- src/app/doc_api.rs | 181 +++++++++++++----- src/app/doc_api_convert.rs | 78 ++++++-- src/app/plugin_host.rs | 6 + 10 files changed, 271 insertions(+), 150 deletions(-) diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index 008485158..084227843 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -1,7 +1,11 @@ //! Codegen driver (plan §10): reads `spec/entities.toml` and regenerates the -//! committed snapshots in `src/gen/` (ops/query enums, api_reference.md, -//! binding_schema.json). CI runs `cargo build` and fails on drift (the committed -//! files are the reviewable truth; build.rs rewrites them deterministically). +//! 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`), @@ -264,6 +268,19 @@ fn api_reference_md(spec: &Spec) -> String { /// The merged, self-contained binding handover schema (§10.2). Object model + /// 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 { + let mut out = vec!["Entity".to_string()]; + for fam in &spec.family { + let h = fam.handle.as_deref().unwrap_or(&fam.name); + if h != "Entity" && !out.iter().any(|x| x == h) { + out.push(h.to_string()); + } + } + out +} + fn binding_schema_json(spec: &Spec) -> String { // Trace the wire layouts of the DTO payload types via serde-reflection so // the inlined layouts are mechanically correct (mirrors the wire types). @@ -307,7 +324,9 @@ fn binding_schema_json(spec: &Spec) -> String { "root": "DocApi", "document": "Document", "collections": ["solids", "curves", "entities"], - "handles": ["Entity", "Solid", "Line", "Circle", "Polyline", "Point"], + // Derived from the spec's family handles (deduped, "Entity" first) so + // new handle families appear in the handover automatically. + "handles": handles_for_schema(spec), }, "families": families, // Traced wire layouts for every Operation/Query/payload DTO (inlined). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 8e129fdde..7156bd178 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -204,25 +204,8 @@ impl DocApiBackend for MockBackend { } } -/// Signed volume + centroid of a closed mesh via the divergence theorem. +/// Signed volume + centroid of a closed mesh — delegates to the crate's single +/// source of truth (divergence theorem) so the example mock can't drift. fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - let mut vol = 0.0; - let mut c = [0.0; 3]; - for t in &mesh.triangles { - let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - vol += tet; - for i in 0..3 { - c[i] += (v0[i] + v1[i] + v2[i]) * tet; - } - } - if vol.abs() < 1e-12 { - return (0.0, [0.0; 3]); - } - (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) + ocs_doc_api::geom::mesh_volume_centroid(mesh) } diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 179c357d9..27cc042c9 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -265,7 +265,12 @@ "Line", "Circle", "Polyline", - "Point" + "Point", + "ArcCurve", + "Ellipse", + "Spline", + "Ray", + "XLine" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 7657cb82c..685195f86 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -1,7 +1,8 @@ -// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand. -// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI -// fails on drift (`--check`). Append new variants at the END only (bincode -// discriminant stability, plan §7). +// HAND-MAINTAINED wire vocabulary (the Operation enum). This file is NOT derived +// 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, plan §7). Do NOT reorder or remove variants. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 0d9832956..6341590b0 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -1,7 +1,7 @@ -// GENERATED by build.rs from spec/entities.toml — do NOT edit by hand. -// Committed snapshot for reviewability (plan §10); build.rs regenerates and CI -// fails on drift (`--check`). Append new variants at the END only (bincode -// discriminant stability, plan §7). +// 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, plan §7). use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs index 0341d760b..92bac754d 100644 --- a/crates/ocs_doc_api/src/geom.rs +++ b/crates/ocs_doc_api/src/geom.rs @@ -13,6 +13,31 @@ fn geom_err(kind: GeometryErrorKind, msg: impl Into) -> ApiError { ApiError::geometry(kind, msg) } +/// Signed volume and centroid of a closed triangle mesh via the divergence +/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source +/// of truth shared by the host backend and the test/example mock backends. +pub fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { + let mut vol = 0.0; + let mut c = [0.0; 3]; + for t in &mesh.triangles { + let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); + let cross = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0], + ]; + let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; + vol += tet; + for i in 0..3 { + c[i] += (v0[i] + v1[i] + v2[i]) * tet; + } + } + if vol.abs() < 1e-12 { + return (0.0, [0.0; 3]); + } + (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) +} + /// The XY work plane (origin at 0, X=(1,0,0), normal=(0,0,1)). fn xy_plane() -> ApiResult { Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 99ad1b04a..3946add9f 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -1,5 +1,5 @@ -//! Unit tests over an in-memory mock `DocApiBackend` (plan §11.1): 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. @@ -119,11 +119,11 @@ impl DocApiBackend for MockBackend { } fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { let body = self.body(id)?; - Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).centroid_approx()) + Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) } fn volume(&mut self, id: ObjectId) -> ApiResult { let body = self.body(id)?; - Ok(cadkernel::brep::mesh_body(body, 0.5, 1e-3).volume_approx()) + Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) } fn entity_exists(&self, id: ObjectId) -> bool { self.kinds.contains_key(&id) @@ -138,51 +138,6 @@ impl DocApiBackend for MockBackend { } } -/// Approximate mesh volume/centroid for the mock (divergence over triangles). -trait MeshApprox { - fn volume_approx(&self) -> f64; - fn centroid_approx(&self) -> [f64; 3]; -} -impl MeshApprox for cadkernel::brep::Mesh { - fn volume_approx(&self) -> f64 { - let mut vol = 0.0; - for t in &self.triangles { - let v0 = self.positions[t[0]]; - let v1 = self.positions[t[1]]; - let v2 = self.positions[t[2]]; - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - vol += (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - } - vol - } - fn centroid_approx(&self) -> [f64; 3] { - let mut vol = 0.0; - let mut c = [0.0; 3]; - for t in &self.triangles { - let v0 = self.positions[t[0]]; - let v1 = self.positions[t[1]]; - let v2 = self.positions[t[2]]; - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - vol += tet; - for i in 0..3 { - c[i] += (v0[i] + v1[i] + v2[i]) * tet; - } - } - if vol.abs() < 1e-12 { - return [0.0; 3]; - } - [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)] - } -} fn api() -> (DocApi, Arc>) { let tp = Arc::new(ocs_doc_api::transport::InProcess::new(MockBackend::default())); @@ -196,7 +151,7 @@ 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()). + // 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); @@ -215,7 +170,7 @@ 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. + // 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"); } diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index bbb3ac1e1..bccae0bd6 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -180,11 +180,14 @@ impl DocApiBackend for HostSession<'_> { } fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - // Pre-validating this lets TransformMany be all-or-nothing. Solids and the - // supported 2D families are transformable; anything else errors up front. + // Pre-validating everything the apply loop can actually fail on is what makes + // TransformMany all-or-nothing (no mid-loop failure after earlier mutations). + // This must check more than the entity TYPE: layer-lock (update_entity returns + // false) and, for solids, that the body resolves. + let handle = obj_to_handle(id); let entity = self .document() - .get_entity(obj_to_handle(id)) + .get_entity(handle) .ok_or(ApiError::UnknownId(id))?; let ok = matches!( entity, @@ -201,11 +204,19 @@ impl DocApiBackend for HostSession<'_> { | EntityType::Point(_) | EntityType::LwPolyline(_) ); - if ok { - Ok(()) - } else { - Err(ApiError::Unsupported("transform is not supported for this entity family".into())) + if !ok { + return Err(ApiError::Unsupported("transform is not supported for this entity family".into())); } + // Locked layer -> update_entity would return false mid-loop. + if self.scene().is_layer_locked(handle) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + // Solid: the body must resolve (kernel transform/display-prep can still fail + // at apply time, but resolution is the checkable precondition). + if matches!(entity, EntityType::Solid3D(_)) { + self.resolve_body(id)?; + } + Ok(()) } fn can_remove(&self, id: ObjectId) -> ApiResult<()> { @@ -283,30 +294,11 @@ impl DocApiBackend for HostSession<'_> { } fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let handle = obj_to_handle(id); - // Serve from the scene's render-mesh metrics cache when present (avoids a - // full body tessellation + B-rep clone per query, plan review); fall back - // to the kernel mesh when the cache is cold. - if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { - if metrics.volume.abs() > 0.0 { - return Ok(metrics.centroid); - } - } - let body = self.resolve_body(id)?; - let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - Ok(mesh_volume_centroid(&mesh).1) + Ok(self.mass_properties(id)?.1) } fn volume(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { - if metrics.volume.abs() > 0.0 { - return Ok(metrics.volume); - } - } - let body = self.resolve_body(id)?; - let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - Ok(mesh_volume_centroid(&mesh).0) + Ok(self.mass_properties(id)?.0) } fn entity_exists(&self, id: ObjectId) -> bool { @@ -331,6 +323,43 @@ impl DocApiBackend for HostSession<'_> { // ── helpers ────────────────────────────────────────────────────────────────── +impl HostSession<'_> { + /// Volume + centroid for a solid, memoized per (handle, geometry_epoch) so + /// repeated queries on the SAME solid are O(1) (the common query-batch DoS). + /// Served from the render-mesh metrics cache when present; otherwise tessellates + /// the body ONCE per epoch and caches it. A per-session cold-tessellation + /// budget bounds pathological batches of many distinct cold solids. + fn mass_properties(&mut self, id: ObjectId) -> ApiResult<(f64, [f64; 3])> { + let handle = obj_to_handle(id); + let epoch = self.scene().geometry_epoch; + if let Some(&(cached_epoch, v, c)) = self.mass_cache.get(&handle) { + if cached_epoch == epoch { + return Ok((v, c)); + } + } + // Render-mesh metrics cache (avoids re-tessellation + B-rep clone). + if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { + if metrics.volume.abs() > 0.0 { + self.mass_cache.insert(handle, (epoch, metrics.volume, metrics.centroid)); + return Ok((metrics.volume, metrics.centroid)); + } + } + // Cold-cache fallback: bounded full tessellation. + const COLD_TESS_BUDGET: usize = 256; + if self.cold_tess_used >= COLD_TESS_BUDGET { + return Err(ApiError::Unsupported( + "volume/centroid cold-tessellation budget exceeded for this session; regenerate the mesh or query fewer distinct solids".into(), + )); + } + self.cold_tess_used += 1; + let body = self.resolve_body(id)?; + let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); + let (v, c) = mesh_volume_centroid(&mesh); + self.mass_cache.insert(handle, (epoch, v, c)); + Ok((v, c)) + } +} + fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { cadkernel::brep::Placement { x_axis: p.x_axis, @@ -344,25 +373,8 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { /// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source /// of truth for both (used by the cold-cache `volume`/`centroid` fallback). fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - let mut vol = 0.0; - let mut c = [0.0; 3]; - for t in &mesh.triangles { - let (v0, v1, v2) = (mesh.positions[t[0]], mesh.positions[t[1]], mesh.positions[t[2]]); - let cross = [ - v1[1] * v2[2] - v1[2] * v2[1], - v1[2] * v2[0] - v1[0] * v2[2], - v1[0] * v2[1] - v1[1] * v2[0], - ]; - let tet = (v0[0] * cross[0] + v0[1] * cross[1] + v0[2] * cross[2]) / 6.0; - vol += tet; - for i in 0..3 { - c[i] += (v0[i] + v1[i] + v2[i]) * tet; - } - } - if vol.abs() < 1e-12 { - return (0.0, [0.0; 3]); - } - (vol, [c[0] / (4.0 * vol), c[1] / (4.0 * vol), c[2] / (4.0 * vol)]) + // Delegate to the crate's single source of truth (divergence theorem). + ocs_doc_api::geom::mesh_volume_centroid(mesh) } #[cfg(test)] @@ -679,6 +691,81 @@ mod tests { assert!((v.center.x - 60.0).abs() < 1e-6); } + // ── Regression tests for review fixes (s^2 scale, rotation, all-or-nothing) ── + + #[test] + fn fix_uniform_scale_applied_once_not_squared() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let circle = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0, 0.0, 0.0], radius: 1.0 }))).unwrap()); + // Uniform scale by 2 (x_axis=(2,0,0) etc). Radius must become 2 (s), not 4 (s^2). + let placement = ocs_doc_api::PlacementSpec { + x_axis: [2.0, 0.0, 0.0], y_axis: [0.0, 2.0, 0.0], z_axis: [0.0, 0.0, 2.0], origin: [0.0; 3], + }; + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: circle, placement })).unwrap(); + let handle = obj_to_handle(circle); + let Some(EntityType::Circle(c)) = host.document().get_entity(handle) else { panic!("circle not found") }; + assert!((c.radius - 2.0).abs() < 1e-9, "radius {} (must be 2.0, not 4.0)", c.radius); + } + + #[test] + fn fix_rotation_rotates_ellipse_ray_and_insert() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // 90-degree Z-rotation: x_axis=(0,1,0), y_axis=(-1,0,0). + let rot90 = ocs_doc_api::PlacementSpec { + x_axis: [0.0, 1.0, 0.0], y_axis: [-1.0, 0.0, 0.0], z_axis: [0.0, 0.0, 1.0], origin: [0.0; 3], + }; + // Ray along +X must become along +Y after a 90° rotation. + let ray = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ray { origin: [0.0, 0.0, 0.0], direction: [1.0, 0.0, 0.0] }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: ray, placement: rot90 })).unwrap(); + let Some(EntityType::Ray(r)) = host.document().get_entity(obj_to_handle(ray)) else { panic!("ray not found") }; + assert!((r.direction.y - 1.0).abs() < 1e-6 && r.direction.x.abs() < 1e-6, "ray dir {:?}", r.direction); + + // Ellipse major_axis along +X must rotate to +Y (length preserved). + let ell = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ellipse { centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: std::f64::consts::TAU }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: ell, placement: rot90 })).unwrap(); + let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { panic!("ellipse not found") }; + assert!((e.major_axis.y - 4.0).abs() < 1e-6 && e.major_axis.x.abs() < 1e-6, "ellipse axis {:?}", e.major_axis); + + // Arc angles offset by +90° (π/2). + let arc = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Arc { centre: [0.0; 3], radius: 2.0, start_angle: 0.0, end_angle: 1.0 }))).unwrap()); + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: arc, placement: rot90 })).unwrap(); + let Some(EntityType::Arc(a)) = host.document().get_entity(obj_to_handle(arc)) else { panic!("arc not found") }; + let half_pi = std::f64::consts::FRAC_PI_2; + assert!((a.start_angle - half_pi).abs() < 1e-6, "arc start {} (expected {})", a.start_angle, half_pi); + } + + #[test] + fn fix_transform_many_locked_layer_fails_all_or_nothing() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Point { position: [0.0; 3] }))).unwrap()); + let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Point { position: [1.0; 3] }))).unwrap()); + // Lock the layer holding `b`. + let bh = obj_to_handle(b); + let layer = host.document().get_entity(bh).unwrap().common().layer.clone(); + host.document_mut().layers.get_mut(&layer).unwrap().lock(); + let rev_before = host.scene().geometry_epoch; + + // TransformMany [a, b] must fail all-or-nothing (b is locked) BEFORE mutating a. + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::TransformMany { + ids: vec![a, b], + placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), + })).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. } | ApiError::Unsupported(_) | ApiError::UnknownId(_)), "{err:?}"); + // `a` was NOT transformed (all-or-nothing): its location is unchanged. + let Some(EntityType::Point(pa)) = host.document().get_entity(obj_to_handle(a)) else { panic!("point a not found") }; + assert!((pa.location.x - 0.0).abs() < 1e-9, "a moved despite locked batch member"); + assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); + } + // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 38bea725c..ac5f6a084 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -30,8 +30,14 @@ pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult= 2 vertices")); } - // Build a straight-segment polyline chain (bulge arcs land in a later - // phase; straight segments cover the common rectangular profile case). + // Fail explicitly on bulge arcs rather than silently producing a + // chord-substituted (wrong-shape) profile for Extrude/Revolve. + if pl.vertices.iter().any(|v| v.bulge != 0.0) { + return Err(ApiError::Unsupported( + "polyline profiles with bulge (arc) segments are not yet supported for sweep ops".into(), + )); + } + // Build a straight-segment polyline chain. let pts: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); let mut segs = Vec::with_capacity(pts.len()); for i in 0..pts.len() { @@ -64,16 +70,26 @@ pub fn transform_entity_geometry( entity: &EntityType, p: &ocs_doc_api::PlacementSpec, ) -> ApiResult { - // Affine point transform: out = R * (s * v) + t. Uniform scale s = |x_axis|. + // 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 { - let x = v.x * s; - let y = v.y * s; - let z = v.z * s; Vector3::new( - p.x_axis[0] * x + p.y_axis[0] * y + p.z_axis[0] * z + p.origin[0], - p.x_axis[1] * x + p.y_axis[1] * y + p.z_axis[1] * z + p.origin[1], - p.x_axis[2] * x + p.y_axis[2] * y + p.z_axis[2] * z + p.origin[2], + 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], + ) + }; + // 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 { @@ -93,15 +109,16 @@ pub fn transform_entity_geometry( 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); - // Scale the major-axis vector by the uniform scale (rigid similarity). - e.major_axis = Vector3::new( - e.major_axis.x * s, e.major_axis.y * s, e.major_axis.z * s, - ); + // The major-axis is a direction+length vector: rotate+scale it (no origin). + e.major_axis = apply_dir(e.major_axis); EntityType::Ellipse(e) } EntityType::Spline(sp) => { @@ -117,16 +134,20 @@ pub fn transform_entity_geometry( 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) => { @@ -206,6 +227,14 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { EntityType::Arc(e) } 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) { + return Err(ApiError::validation( + "CreateCurve", + format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"), + )); + } let mut e = Ellipse::new(); e.center = v3(*centre); e.major_axis = v3(*major_axis); @@ -344,13 +373,24 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - // Coarse image rect: origin + u*width + v*height (in world units). - let w = img.u_vector.x * img.size.x + img.v_vector.x * img.size.y; - let h = img.u_vector.y * img.size.x + img.v_vector.y * img.size.y; - let (x0, y0) = (img.insertion_point.x, img.insertion_point.y); + // 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: [x0.min(x0 + w), y0.min(y0 + h), img.insertion_point.z], - max: [x0.max(x0 + w), y0.max(y0 + h), img.insertion_point.z], + min: [min_x, min_y, img.insertion_point.z], + max: [max_x, max_y, img.insertion_point.z], } } // Ray/XLine are unbounded by definition — no finite bounds. diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 3e5977de2..dca04abb2 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -18,6 +18,10 @@ pub(crate) struct HostSession<'a> { doc_store: Option>, /// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`). doc_api_pending: Option, + /// Memoized volume/centroid per (handle, geometry_epoch) for DocApi mass queries. + pub(crate) mass_cache: std::collections::HashMap, + /// Per-session count of cold-cache mass tessellations (budget for query DoS). + pub(crate) cold_tess_used: usize, } impl<'a> HostSession<'a> { @@ -27,6 +31,8 @@ impl<'a> HostSession<'a> { tab, doc_store: None, doc_api_pending: None, + mass_cache: std::collections::HashMap::new(), + cold_tess_used: 0, } } From 1925a83a25ab03b063e0220b3f4b8f500610903a Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:44:53 +0200 Subject: [PATCH 09/31] ocs_doc_api: spec<->wire consistency + append-only baseline tests Add tests/spec_wire_consistency.rs: (a) every op/query name in spec/entities.toml maps to an existing Operation/Query variant; (b) both enums are append-only relative to a recorded variant-order baseline (bincode discriminant stability). Adds toml to dev-dependencies. 3 tests green. --- crates/ocs_doc_api/Cargo.toml | 1 + .../tests/spec_wire_consistency.rs | 170 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 crates/ocs_doc_api/tests/spec_wire_consistency.rs diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 6d9e653e0..c21cf0eb1 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -26,6 +26,7 @@ toml = "0.8" [dev-dependencies] serde_json = "1" +toml = "0.8" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67f2427282d28922d9263e37b0136b6632e929b3", features = ["acis", "offset"] } diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs new file mode 100644 index 000000000..aa72ef557 --- /dev/null +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -0,0 +1,170 @@ +//! 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). + +use std::collections::BTreeSet; + +use serde::Deserialize; + +#[derive(Deserialize)] +struct Spec { + #[serde(default)] + family: Vec, +} +#[derive(Deserialize)] +struct Family { + #[serde(default)] + handle: Option, + #[serde(default)] + constructor: Vec, + #[serde(default)] + method: Vec, +} +#[derive(Deserialize)] +struct MethodRef { + #[serde(default)] + op: Option, + #[serde(default)] + query: Option, +} + +fn spec() -> Spec { + let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/spec/entities.toml")) + .expect("read spec/entities.toml"); + toml::from_str(&src).expect("parse spec/entities.toml") +} + +/// The variant names of the `Operation` enum, in declaration order. +fn operation_variants() -> Vec { + use ocs_doc_api::ops::{BoolOp, Curve2Spec, InsertSpec, Operation, PlacementSpec, SolidPrimitive, ViewportSpec}; + use ocs_doc_api::ObjectId; + let _marker: Vec = vec![ + 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::CreateMany(vec![]), + 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 }), + ]; + // 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() + .map(|op| { + let dbg = format!("{:?}", op); + dbg.split(|c: char| c == '(' || c == ' ' || c == '{').next().unwrap_or("").to_string() + }) + .collect() +} + +/// The variant names of the `Query` enum, in declaration order. +fn query_variants() -> Vec { + use ocs_doc_api::query::Query; + use ocs_doc_api::ObjectId; + let marker: Vec = 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::GetGeometryRevision, + ]; + marker + .iter() + .map(|q| { + let dbg = format!("{:?}", q); + dbg.split(|c: char| c == '(' || c == ' ' || c == '{').next().unwrap_or("").to_string() + }) + .collect() +} + +#[test] +fn every_spec_op_and_query_maps_to_an_enum_variant() { + let spec = spec(); + let op_names: BTreeSet = operation_variants().into_iter().collect(); + let query_names: BTreeSet = query_variants().into_iter().collect(); + + let mut missing = Vec::new(); + for fam in &spec.family { + 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("?"))); + } + } + if let Some(q) = &m.query { + if !query_names.contains(q) { + 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:?}"); +} + +/// Recorded baseline of the `Operation` variant order (append-only contract). +/// New variants must be APPENDED after these; none may be removed or reordered. +const OPERATION_BASELINE: &[&str] = &[ + "CreateSolid", + "CreateCurve", + "Extrude", + "Revolve", + "Transform", + "SolidBoolean", + "AddVertex", + "Delete", + "CreateMany", + "TransformMany", + "DeleteMany", + "CreateInsert", + "CreateViewport", +]; + +/// Recorded baseline of the `Query` variant order. +const QUERY_BASELINE: &[&str] = &[ + "GetEntity", + "GetBounds", + "GetCentroid", + "GetVolume", + "GetIntersects", + "GetGeometryRevision", +]; + +#[test] +fn operation_enum_is_append_only_vs_baseline() { + let current = operation_variants(); + assert!( + current.len() >= OPERATION_BASELINE.len(), + "Operation enum shrank: {current:?} < baseline {OPERATION_BASELINE:?}" + ); + for (i, name) in OPERATION_BASELINE.iter().enumerate() { + assert_eq!( + current[i], *name, + "Operation variant {i} changed (append-only violation): {current:?}" + ); + } +} + +#[test] +fn query_enum_is_append_only_vs_baseline() { + let current = query_variants(); + assert!( + current.len() >= QUERY_BASELINE.len(), + "Query enum shrank: {current:?} < baseline {QUERY_BASELINE:?}" + ); + for (i, name) in QUERY_BASELINE.iter().enumerate() { + assert_eq!( + current[i], *name, + "Query variant {i} changed (append-only violation): {current:?}" + ); + } +} From fc0e2c2c9db09563ec94ad002165703aecbdaf02 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:07:41 +0200 Subject: [PATCH 10/31] ocs_doc_api: Phase 2b-a (Text/MText annotations) - CreateText/CreateMText/SetTextContent ops + GetTextContent query (append-only). - backend add_text/add_mtext/set_text_content/text_content (Text.value/MText.value). - facade create_text/create_mtext + Text/MText handles with content()/set_content(). - Text/MText coarse bounds + transform (insertion point); ensure_transformable. - spec Text/MText families; api_reference + binding_schema regenerated; mocks. - can_modify backend hook (exists + not-locked) for SetTextContent validation. - consistency baseline extended with the appended variants. - Test phase2b_text_mtext_create_content_transform -> 605 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 +- crates/ocs_doc_api/examples/common/mod.rs | 23 +++++ crates/ocs_doc_api/spec/entities.toml | 53 ++++++++++ crates/ocs_doc_api/src/backend.rs | 23 +++++ crates/ocs_doc_api/src/executor.rs | 23 +++++ crates/ocs_doc_api/src/facade.rs | 44 +++++++++ crates/ocs_doc_api/src/gen/api_reference.md | 26 +++++ .../ocs_doc_api/src/gen/binding_schema.json | 74 +++++++++++++- crates/ocs_doc_api/src/gen/ops_gen.rs | 6 +- crates/ocs_doc_api/src/gen/query_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 20 ++++ crates/ocs_doc_api/src/query.rs | 3 + crates/ocs_doc_api/tests/mock_backend.rs | 23 +++++ .../tests/spec_wire_consistency.rs | 8 ++ src/app/doc_api.rs | 98 +++++++++++++++++++ src/app/doc_api_convert.rs | 19 ++++ 16 files changed, 449 insertions(+), 4 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 00e35823d..73aab0b02 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -61,8 +61,12 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal (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. -- **Remaining (2b):** annotations `Text`/`MText` (create + content get/set), `Hatch` - (create + boundary query), `Dimension` (typed sub-types). +- **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. +- **Remaining (2b-b/c):** `Hatch` (create + boundary query), `Dimension` (typed + sub-types). - **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline) are supported (Phase 0). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 7156bd178..e0ba05758 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -21,6 +21,8 @@ pub struct MockBackend { kinds: HashMap, /// Stored 2D curve specs so `bounds()` works on non-solid entities too. curves: HashMap, + /// Stored text content for Text/MText annotations. + text_values: HashMap, } impl MockBackend { @@ -133,6 +135,27 @@ impl DocApiBackend for MockBackend { fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { Ok(self.alloc("Viewport")) } + fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { + let id = self.alloc("Text"); + self.text_values.insert(id, spec.value.clone()); + Ok(id) + } + fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { + let id = self.alloc("MText"); + self.text_values.insert(id, spec.value.clone()); + Ok(id) + } + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { + if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { + e.insert(value.to_string()); + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn text_content(&self, id: ObjectId) -> ApiResult { + self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 7eb4fc097..0740139c1 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -237,6 +237,59 @@ args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; op = "CreateCurve" fixed = { curve = "XLine" } +# ============================================================================ +# ANNOTATION FAMILY (phase 2b) — text annotations +# ============================================================================ +[[family]] +name = "Text" +acadrust_variant = "Text" +handle = "Text" +collection = "curves" +description = "Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." + +[[family.constructor]] +name = "create_text" +args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] +op = "CreateText" + +[[family.method]] +name = "content" +kind = "query" +query = "GetTextContent" +returns = "String" + +[[family.method]] +name = "set_content" +kind = "op" +op = "SetTextContent" +args = [{ name = "value", ty = "&str" }] +returns = "()" + +[[family]] +name = "MText" +acadrust_variant = "MText" +handle = "MText" +collection = "curves" +description = "Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." + +[[family.constructor]] +name = "create_mtext" +args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }] +op = "CreateMText" + +[[family.method]] +name = "content" +kind = "query" +query = "GetTextContent" +returns = "String" + +[[family.method]] +name = "set_content" +kind = "op" +op = "SetTextContent" +args = [{ name = "value", ty = "&str" }] +returns = "()" + # ============================================================================ # CONTAINER FAMILY (phase 3) — block references # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 5fc03379e..d1a166854 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -42,6 +42,29 @@ pub trait DocApiBackend { /// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`. fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult; + /// Add a single-line `TEXT` annotation; returns the fresh `ObjectId`. + fn add_text(&mut self, spec: &crate::ops::TextSpec) -> ApiResult; + + /// Add a multi-line `MTEXT` annotation; returns the fresh `ObjectId`. + fn add_mtext(&mut self, spec: &crate::ops::MTextSpec) -> ApiResult; + + /// Set the text content of a Text/MText annotation in place. + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()>; + + /// The text content of a Text/MText annotation. `Unsupported` for other kinds. + fn text_content(&self, id: ObjectId) -> ApiResult; + + /// 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. + /// Default: existence + not-locked (backends narrow the family check). + fn can_modify(&self, id: ObjectId) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + /// Remove any first-level entity by id. Returns `false` if absent; returns /// `Err` if the entity exists but cannot be removed (e.g. locked layer). fn remove_entity(&mut self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 5267229b3..75f2cbbae 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -189,6 +189,28 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_text(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::CreateMText(spec) => { + b.push_undo(name); + let id = b.add_mtext(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::SetTextContent { id, value } => { + b.can_modify(*id).map_err(|e| match e { + ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")), + other => other, + })?; + b.push_undo(name); + b.set_text_content(*id, value)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -226,6 +248,7 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes QueryResult::Intersects(ba.overlaps(&bb)) } Query::GetGeometryRevision => QueryResult::Revision(b.revision()), + Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?), }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 02bb6bbf0..581d1f495 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -267,6 +267,8 @@ handle!(Ellipse); handle!(Spline); handle!(Ray); handle!(XLine); +handle!(Text); +handle!(MText); impl Entity { pub fn view(&self) -> ApiResult { @@ -331,6 +333,27 @@ impl Polyline { } } +macro_rules! text_handle { + ($name:ident) => { + impl $name { + /// The text content of this annotation. + pub fn content(&self) -> ApiResult { + 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() })?; + Ok(()) + } + } + }; +} +text_handle!(Text); +text_handle!(MText); + // ── Collections (construction / lookup) ───────────────────────────────────── #[derive(Clone)] @@ -442,6 +465,27 @@ impl CurveCollection { let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; Ok(XLine::new(s, id)) } + /// Single-line TEXT annotation. + pub fn create_text(&self, value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> ApiResult { + 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 { + 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 (plan §5.3): all-or-nothing, one undo step. pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { let specs: Vec = positions diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 71f95f743..1813d743d 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -87,6 +87,32 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_xline**(`origin: [f64; 3], direction: [f64; 3]`) -> `XLine` +## `Text` (acadrust `Text`, collection `curves`) + +Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. + +### Constructors (`doc.curves()`) + +- **create_text**(`value: &str, insertion_point: [f64; 3], height: f64, rotation: f64`) -> `Text` + +### Methods + +- **content**(``) -> `String` — query `GetTextContent` +- **set_content**(`value: &str`) -> `()` — op `SetTextContent` + +## `MText` (acadrust `MText`, collection `curves`) + +Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. + +### Constructors (`doc.curves()`) + +- **create_mtext**(`value: &str, insertion_point: [f64; 3], height: f64`) -> `MText` + +### Methods + +- **content**(``) -> `String` — query `GetTextContent` +- **set_content**(`value: &str`) -> `()` — op `SetTextContent` + ## `Entity` (acadrust `Insert`, collection `entities`) Block reference. Place an existing BlockRecord by name; transform moves its insert point. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 27cc042c9..0b8941def 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -188,6 +188,76 @@ "methods": [], "name": "XLine" }, + { + "acadrust_variant": "Text", + "collection": "curves", + "handle": "Text", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "content", + "op": null, + "query": "GetTextContent", + "read_only": true, + "returns": "String" + }, + { + "args": [ + { + "name": "value", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_content", + "op": "SetTextContent", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "Text" + }, + { + "acadrust_variant": "MText", + "collection": "curves", + "handle": "MText", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "content", + "op": null, + "query": "GetTextContent", + "read_only": true, + "returns": "String" + }, + { + "args": [ + { + "name": "value", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_content", + "op": "SetTextContent", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "MText" + }, { "acadrust_variant": "Insert", "collection": "entities", @@ -270,7 +340,9 @@ "Ellipse", "Spline", "Ray", - "XLine" + "XLine", + "Text", + "MText" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 685195f86..37e22ac7c 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, PlacementSpec, SolidPrimitive, ViewportSpec}; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, TextSpec, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -32,4 +32,8 @@ pub enum Operation { CreateInsert(InsertSpec), // paper-space CreateViewport(ViewportSpec), + // annotations + CreateText(TextSpec), + CreateMText(MTextSpec), + SetTextContent { id: ObjectId, value: String }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 6341590b0..743cae602 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -19,4 +19,6 @@ pub enum Query { /// Whether two entities' bounds overlap (two-phase conditional modelling). GetIntersects { a: ObjectId, b: ObjectId }, GetGeometryRevision, + /// The text content of a Text/MText annotation. + GetTextContent { id: ObjectId }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 8d3566059..0738122f5 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -79,6 +79,23 @@ pub struct ViewportSpec { pub view_height: f64, } +/// Construction spec for a single-line `TEXT` annotation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TextSpec { + pub value: String, + pub insertion_point: [f64; 3], + pub height: f64, + pub rotation: f64, +} + +/// Construction spec for a multi-line `MTEXT` annotation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MTextSpec { + pub value: String, + pub insertion_point: [f64; 3], + pub height: f64, +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -128,6 +145,9 @@ impl crate::gen::Operation { DeleteMany(_) => "DeleteMany", CreateInsert(_) => "CreateInsert", CreateViewport(_) => "CreateViewport", + CreateText(_) => "CreateText", + CreateMText(_) => "CreateMText", + SetTextContent { .. } => "SetTextContent", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 5c40942d8..b971eb58c 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -43,6 +43,8 @@ pub enum QueryResult { /// Whether two entities' bounds overlap (two-phase conditional modelling). Intersects(bool), Revision(GeometryRevision), + /// The text content of a Text/MText annotation. + TextContent(String), } /// Convenience: the query name for diagnostics. @@ -56,6 +58,7 @@ impl crate::gen::Query { GetVolume { .. } => "GetVolume", GetIntersects { .. } => "GetIntersects", GetGeometryRevision => "GetGeometryRevision", + GetTextContent { .. } => "GetTextContent", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 3946add9f..72e907b6c 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -21,6 +21,8 @@ struct MockBackend { undo_steps: u32, bodies: HashMap, kinds: HashMap, + /// Stored text content for Text/MText annotations. + text_values: HashMap, } impl MockBackend { @@ -73,6 +75,27 @@ impl DocApiBackend for MockBackend { fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { Ok(self.alloc("Viewport")) } + fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { + let id = self.alloc("Text"); + self.text_values.insert(id, spec.value.clone()); + Ok(id) + } + fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { + let id = self.alloc("MText"); + self.text_values.insert(id, spec.value.clone()); + Ok(id) + } + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { + if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { + e.insert(value.to_string()); + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn text_content(&self, id: ObjectId) -> ApiResult { + self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index aa72ef557..e8915c915 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -53,6 +53,9 @@ fn operation_variants() -> Vec { 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() }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -77,6 +80,7 @@ fn query_variants() -> Vec { 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) }, ]; marker .iter() @@ -127,6 +131,9 @@ const OPERATION_BASELINE: &[&str] = &[ "DeleteMany", "CreateInsert", "CreateViewport", + "CreateText", + "CreateMText", + "SetTextContent", ]; /// Recorded baseline of the `Query` variant order. @@ -137,6 +144,7 @@ const QUERY_BASELINE: &[&str] = &[ "GetVolume", "GetIntersects", "GetGeometryRevision", + "GetTextContent", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index bccae0bd6..d0977d269 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -139,6 +139,64 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { + use acadrust::entities::Text; + use acadrust::types::Vector3; + let mut t = Text::new(); + t.value = spec.value.clone(); + t.insertion_point = Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]); + t.height = spec.height; + t.rotation = spec.rotation; + let handle = self.scene_mut().add_entity(EntityType::Text(t)); + Ok(handle_to_obj(handle)) + } + + fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { + use acadrust::entities::MText; + use acadrust::types::Vector3; + let mut t = MText::with_value( + spec.value.clone(), + Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]), + ); + t.height = spec.height; + let handle = self.scene_mut().add_entity(EntityType::MText(t)); + Ok(handle_to_obj(handle)) + } + + fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { + let handle = obj_to_handle(id); + let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; + let new_entity = match entity { + EntityType::Text(mut t) => { t.value = value.to_string(); EntityType::Text(t) } + EntityType::MText(mut t) => { t.value = value.to_string(); EntityType::MText(t) } + _ => return Err(ApiError::Unsupported("SetTextContent is only for Text/MText".into())), + }; + if !self.scene_mut().update_entity(new_entity) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + + fn text_content(&self, id: ObjectId) -> ApiResult { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + match entity { + EntityType::Text(t) => Ok(t.value.clone()), + EntityType::MText(t) => Ok(t.value.clone()), + _ => Err(ApiError::Unsupported("GetTextContent is only for Text/MText".into())), + } + } + + fn can_modify(&self, id: ObjectId) -> ApiResult<()> { + let handle = obj_to_handle(id); + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if self.scene().is_layer_locked(handle) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -201,6 +259,8 @@ impl DocApiBackend for HostSession<'_> { | EntityType::XLine(_) | EntityType::Insert(_) | EntityType::Viewport(_) + | EntityType::Text(_) + | EntityType::MText(_) | EntityType::Point(_) | EntityType::LwPolyline(_) ); @@ -766,6 +826,44 @@ mod tests { assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected TransformMany"); } + // ── Phase 2b-a: annotations (Text/MText) ───────────────────────────────── + + #[test] + fn phase2b_text_mtext_create_content_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Create a TEXT, read its content, set it, verify. + let text = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateText( + ocs_doc_api::ops::TextSpec { value: "hello".into(), insertion_point: [1.0, 2.0, 0.0], height: 2.5, rotation: 0.0 }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetTextContent { id: text }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::TextContent(s) => assert_eq!(s, "hello"), + other => panic!("expected content, got {other:?}"), + } + dispatch(&mut host, DocApiEnvelope::op(Operation::SetTextContent { id: text, value: "world".into() })).unwrap(); + let Some(EntityType::Text(t)) = host.document().get_entity(obj_to_handle(text)) else { panic!("text not found") }; + assert_eq!(t.value, "world"); + + // SetTextContent on a non-text entity is Unsupported. + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] }))).unwrap()); + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::SetTextContent { id: line, value: "x".into() })).is_err()); + + // MTEXT create + content + transform (insertion point moves). + let mtext = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMText( + ocs_doc_api::ops::MTextSpec { value: "multi\nline".into(), insertion_point: [5.0, 5.0, 0.0], height: 3.0 }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetTextContent { id: mtext }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::TextContent(s) => assert_eq!(s, "multi\nline"), + other => panic!("expected content, got {other:?}"), + } + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { + id: mtext, placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]) })).unwrap(); + let Some(EntityType::MText(t)) = host.document().get_entity(obj_to_handle(mtext)) else { panic!("mtext not found") }; + assert!((t.insertion_point.x - 15.0).abs() < 1e-6); + } + // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index ac5f6a084..856050119 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -155,6 +155,16 @@ pub fn transform_entity_geometry( 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); @@ -393,6 +403,15 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult 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], + }, // Ray/XLine are unbounded by definition — no finite bounds. _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), }; From ba8e5ed0cd6c11728eb3e8fb5814606db9d3148b Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:24:11 +0200 Subject: [PATCH 11/31] ocs_doc_api: Phase 2b-b (Hatch) - CreateHatch(HatchSpec) op + GetHatchBoundary query (append-only). - HatchSpec{boundary: Vec<[f64;2]>, solid}; executor validates >= 3 boundary points. - backend add_hatch (Line-edge BoundaryPath, solid/pattern) + hatch_boundary (reconstruct loops from Line edges; non-line edges -> Unsupported). - Hatch bounds from boundary edges; facade create_hatch + Entity handle. - spec Hatch family; api_reference + binding_schema regenerated; mocks + baseline. - Test phase2b_hatch_create_boundary_bounds_delete -> 606 host lib + 8 crate + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 6 +- crates/ocs_doc_api/examples/common/mod.rs | 10 +++ crates/ocs_doc_api/spec/entities.toml | 21 +++++ crates/ocs_doc_api/src/backend.rs | 6 ++ crates/ocs_doc_api/src/executor.rs | 11 +++ crates/ocs_doc_api/src/facade.rs | 10 +++ crates/ocs_doc_api/src/gen/api_reference.md | 12 +++ .../ocs_doc_api/src/gen/binding_schema.json | 19 +++++ crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/gen/query_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 11 +++ crates/ocs_doc_api/src/query.rs | 3 + crates/ocs_doc_api/tests/mock_backend.rs | 10 +++ .../tests/spec_wire_consistency.rs | 4 + src/app/doc_api.rs | 77 +++++++++++++++++++ src/app/doc_api_convert.rs | 20 +++++ 16 files changed, 223 insertions(+), 3 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 73aab0b02..3670bb15c 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -65,8 +65,10 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal (`CreateText`/`CreateMText` wire ops), `content()`/`set_content()` (new `GetTextContent` query + `SetTextContent` op), transform (insertion point), coarse bounds. Covered by `phase2b_*` test. -- **Remaining (2b-b/c):** `Hatch` (create + boundary query), `Dimension` (typed - sub-types). +- **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. +- **Remaining (2b-c):** `Dimension` (typed sub-types). - **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline) are supported (Phase 0). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index e0ba05758..114589d77 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -23,6 +23,8 @@ pub struct MockBackend { curves: HashMap, /// Stored text content for Text/MText annotations. text_values: HashMap, + /// Stored hatch boundary loops. + hatch_boundaries: HashMap>>, } impl MockBackend { @@ -156,6 +158,14 @@ impl DocApiBackend for MockBackend { fn text_content(&self, id: ObjectId) -> ApiResult { self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id)) } + fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { + let id = self.alloc("Hatch"); + self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]); + Ok(id) + } + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { + self.hatch_boundaries.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 0740139c1..b978c72d0 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -290,6 +290,27 @@ op = "SetTextContent" args = [{ name = "value", ty = "&str" }] returns = "()" +# ============================================================================ +# HATCH FAMILY (phase 2b) — filled regions +# ============================================================================ +[[family]] +name = "Hatch" +acadrust_variant = "Hatch" +handle = "Entity" +collection = "curves" +description = "Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete." + +[[family.constructor]] +name = "create_hatch" +args = [{ name = "boundary", ty = "&[[f64; 2]]" }, { name = "solid", ty = "bool" }] +op = "CreateHatch" + +[[family.method]] +name = "boundary" +kind = "query" +query = "GetHatchBoundary" +returns = "Vec>" + # ============================================================================ # CONTAINER FAMILY (phase 3) — block references # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index d1a166854..f8c05aaac 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -54,6 +54,12 @@ pub trait DocApiBackend { /// The text content of a Text/MText annotation. `Unsupported` for other kinds. fn text_content(&self, id: ObjectId) -> ApiResult; + /// Add a `HATCH` (single closed polyline boundary); returns the fresh `ObjectId`. + fn add_hatch(&mut self, spec: &crate::ops::HatchSpec) -> ApiResult; + + /// A hatch's boundary loops (outer + islands) as closed polylines. + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>>; + /// 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. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 75f2cbbae..ffc0ca882 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -211,6 +211,16 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // Validate the boundary (>= 3 points) before committing. + if spec.boundary.len() < 3 { + return Err(ApiError::validation(name, "hatch boundary needs >= 3 points")); + } + b.push_undo(name); + let id = b.add_hatch(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -249,6 +259,7 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes } Query::GetGeometryRevision => QueryResult::Revision(b.revision()), Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?), + Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(id)?), }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 581d1f495..7bece8d08 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -465,6 +465,16 @@ impl CurveCollection { let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; Ok(XLine::new(s, id)) } + /// A solid (or pattern) HATCH over a single closed polyline boundary. + pub fn create_hatch(&self, boundary: &[[f64; 2]], solid: bool) -> ApiResult { + 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)) + } + /// Single-line TEXT annotation. pub fn create_text(&self, value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> ApiResult { let receipt = self.session.apply_op(Operation::CreateText(crate::ops::TextSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 1813d743d..4ce34e0e6 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -113,6 +113,18 @@ Multi-line text annotation. content()/set_content() read+replace the value; tran - **content**(``) -> `String` — query `GetTextContent` - **set_content**(`value: &str`) -> `()` — op `SetTextContent` +## `Entity` (acadrust `Hatch`, collection `curves`) + +Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete. + +### Constructors (`doc.curves()`) + +- **create_hatch**(`boundary: &[[f64; 2]], solid: bool`) -> `Entity` + +### Methods + +- **boundary**(``) -> `Vec>` — query `GetHatchBoundary` + ## `Entity` (acadrust `Insert`, collection `entities`) Block reference. Place an existing BlockRecord by name; transform moves its insert point. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 0b8941def..36ec3db4f 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -258,6 +258,25 @@ ], "name": "MText" }, + { + "acadrust_variant": "Hatch", + "collection": "curves", + "handle": "Entity", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "boundary", + "op": null, + "query": "GetHatchBoundary", + "read_only": true, + "returns": "Vec>" + } + ], + "name": "Hatch" + }, { "acadrust_variant": "Insert", "collection": "entities", diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 37e22ac7c..e6ebc2284 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, EntitySpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, TextSpec, ViewportSpec}; +use crate::ops::{BoolOp, Curve2Spec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, TextSpec, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -36,4 +36,6 @@ pub enum Operation { CreateText(TextSpec), CreateMText(MTextSpec), SetTextContent { id: ObjectId, value: String }, + // hatch + CreateHatch(HatchSpec), } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 743cae602..7c7f093fa 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -21,4 +21,6 @@ pub enum Query { GetGeometryRevision, /// The text content of a Text/MText annotation. GetTextContent { id: ObjectId }, + /// The boundary loops of a Hatch (outer + islands) as polylines. + GetHatchBoundary { id: ObjectId }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 0738122f5..2953f6f85 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -96,6 +96,16 @@ pub struct MTextSpec { pub height: f64, } +/// Construction spec for a `HATCH` with a single closed polyline boundary +/// (straight segments). `solid` selects solid fill vs a pattern (pattern naming +/// is a later refinement). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HatchSpec { + /// Closed boundary as an ordered polyline (first==last implied; closed). + pub boundary: Vec<[f64; 2]>, + pub solid: bool, +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -148,6 +158,7 @@ impl crate::gen::Operation { CreateText(_) => "CreateText", CreateMText(_) => "CreateMText", SetTextContent { .. } => "SetTextContent", + CreateHatch(_) => "CreateHatch", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index b971eb58c..5793f33c3 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -45,6 +45,8 @@ pub enum QueryResult { Revision(GeometryRevision), /// The text content of a Text/MText annotation. TextContent(String), + /// A hatch's boundary loops (outer + islands), each a closed polyline. + HatchBoundary(Vec>), } /// Convenience: the query name for diagnostics. @@ -59,6 +61,7 @@ impl crate::gen::Query { GetIntersects { .. } => "GetIntersects", GetGeometryRevision => "GetGeometryRevision", GetTextContent { .. } => "GetTextContent", + GetHatchBoundary { .. } => "GetHatchBoundary", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 72e907b6c..c98bc7acf 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -23,6 +23,8 @@ struct MockBackend { kinds: HashMap, /// Stored text content for Text/MText annotations. text_values: HashMap, + /// Stored hatch boundary loops. + hatch_boundaries: HashMap>>, } impl MockBackend { @@ -96,6 +98,14 @@ impl DocApiBackend for MockBackend { fn text_content(&self, id: ObjectId) -> ApiResult { self.text_values.get(&id).cloned().ok_or(ApiError::UnknownId(id)) } + fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { + let id = self.alloc("Hatch"); + self.hatch_boundaries.insert(id, vec![spec.boundary.clone()]); + Ok(id) + } + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { + self.hatch_boundaries.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index e8915c915..c04814347 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -56,6 +56,7 @@ fn operation_variants() -> Vec { 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 }), ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -81,6 +82,7 @@ fn query_variants() -> Vec { 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) }, ]; marker .iter() @@ -134,6 +136,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateText", "CreateMText", "SetTextContent", + "CreateHatch", ]; /// Recorded baseline of the `Query` variant order. @@ -145,6 +148,7 @@ const QUERY_BASELINE: &[&str] = &[ "GetIntersects", "GetGeometryRevision", "GetTextContent", + "GetHatchBoundary", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d0977d269..a66be9384 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -197,6 +197,47 @@ impl DocApiBackend for HostSession<'_> { Ok(()) } + fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { + use acadrust::entities::{BoundaryEdge, BoundaryPath, Hatch, LineEdge}; + use acadrust::types::Vector2; + let mut hatch = if spec.solid { Hatch::solid() } else { Hatch::default() }; + // Build a BoundaryPath of Line edges around the closed polyline. + let n = spec.boundary.len(); + let mut path = BoundaryPath::new(); + for i in 0..n { + let start = spec.boundary[i]; + let end = spec.boundary[(i + 1) % n]; + path.edges.push(BoundaryEdge::Line(LineEdge { + start: Vector2::new(start[0], start[1]), + end: Vector2::new(end[0], end[1]), + })); + } + hatch.paths.push(path); + let handle = self.scene_mut().add_entity(EntityType::Hatch(hatch)); + Ok(handle_to_obj(handle)) + } + + fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + let EntityType::Hatch(h) = entity else { + return Err(ApiError::Unsupported("GetHatchBoundary is only for Hatch".into())); + }; + // Reconstruct each boundary loop's vertices from its Line edges. + let mut loops = Vec::with_capacity(h.paths.len()); + for path in &h.paths { + let mut pts = Vec::with_capacity(path.edges.len()); + for edge in &path.edges { + match edge { + acadrust::entities::BoundaryEdge::Line(le) => pts.push([le.start.x, le.start.y]), + // Arc/ellipse boundary edges are a later refinement (no vertex list). + _ => return Err(ApiError::Unsupported("hatch boundary contains non-line edges".into())), + } + } + loops.push(pts); + } + Ok(loops) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -864,6 +905,42 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 2b-b: hatch ──────────────────────────────────────────────────── + + #[test] + fn phase2b_hatch_create_boundary_bounds_delete() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Solid hatch over a unit square boundary. + let sq = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let hatch = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateHatch( + ocs_doc_api::ops::HatchSpec { boundary: sq.clone(), solid: true }))).unwrap()); + + // Boundary round-trips (one loop, 4 vertices). + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetHatchBoundary { id: hatch }, Query::GetBounds { id: hatch }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::HatchBoundary(loops) => { + assert_eq!(loops.len(), 1); + assert_eq!(loops[0].len(), 4); + assert!((loops[0][0][0] - 0.0).abs() < 1e-9 && (loops[0][2][0] - 10.0).abs() < 1e-9); + } + other => panic!("expected boundary, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => assert!((b.min[0] - 0.0).abs() < 1e-6 && (b.max[1] - 10.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + + // Boundary with < 3 points is a validation error. + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::CreateHatch( + ocs_doc_api::ops::HatchSpec { boundary: vec![[0.0, 0.0], [1.0, 1.0]], solid: true }))).is_err()); + + // Generic delete works. + dispatch(&mut host, DocApiEnvelope::op(Operation::Delete { id: hatch })).unwrap(); + assert!(host.document().get_entity(obj_to_handle(hatch)).is_none()); + } + // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 856050119..b39bfa9f3 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -412,6 +412,26 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + 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 } + } // Ray/XLine are unbounded by definition — no finite bounds. _ => return Err(ApiError::Unsupported("bounds for this entity family is not yet implemented".into())), }; From 2dba0158ca44569307468f59520e596cdf811765 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:43:37 +0200 Subject: [PATCH 12/31] =?UTF-8?q?ocs=5Fdoc=5Fapi:=20Phase=202b-c=20(linear?= =?UTF-8?q?=20Dimension)=20=E2=80=94=20Phase=202=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateDimensionLinear(DimensionSpec) op + GetDimensionMeasurement query (append-only). - backend add_dimension_linear (Dimension::Linear via DimensionLinear::new) + dimension_measurement (linear/radius/diameter distance). - Dimension bounds from measured + definition points; Dimension handle + measurement(). - spec Dimension family; api_reference + binding_schema regenerated; mocks + baseline. - Test phase2c_dimension_linear_create_measurement_bounds -> 607 host lib + 8 crate + 3 consistency green. Phase 2 (full 2D + annotations) is now complete. --- crates/ocs_doc_api/IMPLEMENTATION.md | 5 +- crates/ocs_doc_api/examples/common/mod.rs | 14 +++++ crates/ocs_doc_api/spec/entities.toml | 21 +++++++ crates/ocs_doc_api/src/backend.rs | 6 ++ crates/ocs_doc_api/src/executor.rs | 7 +++ crates/ocs_doc_api/src/facade.rs | 22 ++++++++ crates/ocs_doc_api/src/gen/api_reference.md | 12 ++++ .../ocs_doc_api/src/gen/binding_schema.json | 22 +++++++- crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/gen/query_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 10 ++++ crates/ocs_doc_api/src/query.rs | 3 + crates/ocs_doc_api/tests/mock_backend.rs | 14 +++++ .../tests/spec_wire_consistency.rs | 4 ++ src/app/doc_api.rs | 56 +++++++++++++++++++ src/app/doc_api_convert.rs | 10 ++++ 16 files changed, 209 insertions(+), 3 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 3670bb15c..4d2fdde13 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -68,7 +68,10 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal - **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. -- **Remaining (2b-c):** `Dimension` (typed sub-types). +- **Done (2b-c):** linear `Dimension` — `create_dimension_linear(first, second, + definition)` (`CreateDimensionLinear`), `measurement()` → distance + (`GetDimensionMeasurement`), bounds. Covered by `phase2c_*` test. Radius/diameter/ + angular dimension sub-types are a later refinement. - **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline) are supported (Phase 0). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 114589d77..fce8ca73e 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -25,6 +25,8 @@ pub struct MockBackend { text_values: HashMap, /// Stored hatch boundary loops. hatch_boundaries: HashMap>>, + /// Stored dimension measurements. + dimension_measurements: HashMap, } impl MockBackend { @@ -166,6 +168,18 @@ impl DocApiBackend for MockBackend { fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { self.hatch_boundaries.get(&id).cloned().ok_or(ApiError::UnknownId(id)) } + fn add_dimension_linear(&mut self, spec: &ocs_doc_api::ops::DimensionSpec) -> ApiResult { + let id = self.alloc("Dimension"); + let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) + + (spec.first_point[1] - spec.second_point[1]).powi(2) + + (spec.first_point[2] - spec.second_point[2]).powi(2)) + .sqrt(); + self.dimension_measurements.insert(id, d); + Ok(id) + } + fn dimension_measurement(&self, id: ObjectId) -> ApiResult { + self.dimension_measurements.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) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index b978c72d0..5dd6be8a6 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -311,6 +311,27 @@ kind = "query" query = "GetHatchBoundary" returns = "Vec>" +# ============================================================================ +# DIMENSION FAMILY (phase 2c) — linear dimensions +# ============================================================================ +[[family]] +name = "Dimension" +acadrust_variant = "Dimension" +handle = "Dimension" +collection = "curves" +description = "Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points." + +[[family.constructor]] +name = "create_dimension_linear" +args = [{ name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "definition_point", ty = "[f64; 3]" }] +op = "CreateDimensionLinear" + +[[family.method]] +name = "measurement" +kind = "query" +query = "GetDimensionMeasurement" +returns = "f64" + # ============================================================================ # CONTAINER FAMILY (phase 3) — block references # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index f8c05aaac..8a591b204 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -60,6 +60,12 @@ pub trait DocApiBackend { /// A hatch's boundary loops (outer + islands) as closed polylines. fn hatch_boundary(&self, id: ObjectId) -> ApiResult>>; + /// Add a linear `DIMENSION`; returns the fresh `ObjectId`. + fn add_dimension_linear(&mut self, spec: &crate::ops::DimensionSpec) -> ApiResult; + + /// A dimension's measured value (distance for linear/radius, degrees for angular). + fn dimension_measurement(&self, id: ObjectId) -> ApiResult; + /// 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. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index ffc0ca882..7ab6b106f 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -221,6 +221,12 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_dimension_linear(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -260,6 +266,7 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes Query::GetGeometryRevision => QueryResult::Revision(b.revision()), Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?), Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(id)?), + Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(id)?), }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 7bece8d08..05689a0ce 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -269,6 +269,18 @@ handle!(Ray); handle!(XLine); handle!(Text); handle!(MText); +handle!(Dimension); + +impl Dimension { + /// The measured value of this dimension (distance for linear/radius, degrees + /// for angular). + pub fn measurement(&self) -> ApiResult { + match self.session.one_query(Query::GetDimensionMeasurement { id: self.id })? { + QueryResult::DimensionMeasurement(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected measurement result".into())), + } + } +} impl Entity { pub fn view(&self) -> ApiResult { @@ -475,6 +487,16 @@ impl CurveCollection { Ok(Entity::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 { + 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 { let receipt = self.session.apply_op(Operation::CreateText(crate::ops::TextSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 4ce34e0e6..d3366621d 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -125,6 +125,18 @@ Filled region over a closed polyline boundary. boundary() returns the loops; bou - **boundary**(``) -> `Vec>` — query `GetHatchBoundary` +## `Dimension` (acadrust `Dimension`, collection `curves`) + +Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points. + +### Constructors (`doc.curves()`) + +- **create_dimension_linear**(`first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]`) -> `Dimension` + +### Methods + +- **measurement**(``) -> `f64` — query `GetDimensionMeasurement` + ## `Entity` (acadrust `Insert`, collection `entities`) Block reference. Place an existing BlockRecord by name; transform moves its insert point. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 36ec3db4f..45646f17c 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -277,6 +277,25 @@ ], "name": "Hatch" }, + { + "acadrust_variant": "Dimension", + "collection": "curves", + "handle": "Dimension", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "measurement", + "op": null, + "query": "GetDimensionMeasurement", + "read_only": true, + "returns": "f64" + } + ], + "name": "Dimension" + }, { "acadrust_variant": "Insert", "collection": "entities", @@ -361,7 +380,8 @@ "Ray", "XLine", "Text", - "MText" + "MText", + "Dimension" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index e6ebc2284..fa1f908de 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, TextSpec, ViewportSpec}; +use crate::ops::{BoolOp, Curve2Spec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, TextSpec, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -38,4 +38,6 @@ pub enum Operation { SetTextContent { id: ObjectId, value: String }, // hatch CreateHatch(HatchSpec), + // dimensions + CreateDimensionLinear(DimensionSpec), } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 7c7f093fa..44bc8af19 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -23,4 +23,6 @@ pub enum Query { GetTextContent { id: ObjectId }, /// The boundary loops of a Hatch (outer + islands) as polylines. GetHatchBoundary { id: ObjectId }, + /// The measured value of a Dimension (distance or angle). + GetDimensionMeasurement { id: ObjectId }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 2953f6f85..dd688c88e 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -106,6 +106,15 @@ pub struct HatchSpec { pub solid: bool, } +/// Construction spec for a linear `DIMENSION`: the two measured points and the +/// dimension-line definition point. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DimensionSpec { + pub first_point: [f64; 3], + pub second_point: [f64; 3], + pub definition_point: [f64; 3], +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -159,6 +168,7 @@ impl crate::gen::Operation { CreateMText(_) => "CreateMText", SetTextContent { .. } => "SetTextContent", CreateHatch(_) => "CreateHatch", + CreateDimensionLinear(_) => "CreateDimensionLinear", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 5793f33c3..1e6e1fe52 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -47,6 +47,8 @@ pub enum QueryResult { TextContent(String), /// A hatch's boundary loops (outer + islands), each a closed polyline. HatchBoundary(Vec>), + /// A dimension's measured value (distance for linear/radius, degrees for angular). + DimensionMeasurement(f64), } /// Convenience: the query name for diagnostics. @@ -62,6 +64,7 @@ impl crate::gen::Query { GetGeometryRevision => "GetGeometryRevision", GetTextContent { .. } => "GetTextContent", GetHatchBoundary { .. } => "GetHatchBoundary", + GetDimensionMeasurement { .. } => "GetDimensionMeasurement", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index c98bc7acf..6cfe3485e 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -25,6 +25,8 @@ struct MockBackend { text_values: HashMap, /// Stored hatch boundary loops. hatch_boundaries: HashMap>>, + /// Stored dimension measurements. + dimension_measurements: HashMap, } impl MockBackend { @@ -106,6 +108,18 @@ impl DocApiBackend for MockBackend { fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { self.hatch_boundaries.get(&id).cloned().ok_or(ApiError::UnknownId(id)) } + fn add_dimension_linear(&mut self, spec: &ocs_doc_api::ops::DimensionSpec) -> ApiResult { + let id = self.alloc("Dimension"); + let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) + + (spec.first_point[1] - spec.second_point[1]).powi(2) + + (spec.first_point[2] - spec.second_point[2]).powi(2)) + .sqrt(); + self.dimension_measurements.insert(id, d); + Ok(id) + } + fn dimension_measurement(&self, id: ObjectId) -> ApiResult { + self.dimension_measurements.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) { Ok(()) diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index c04814347..3e51c937e 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -57,6 +57,7 @@ fn operation_variants() -> Vec { 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] }), ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -83,6 +84,7 @@ fn query_variants() -> Vec { Query::GetGeometryRevision, Query::GetTextContent { id: ObjectId::from_u64(0) }, Query::GetHatchBoundary { id: ObjectId::from_u64(0) }, + Query::GetDimensionMeasurement { id: ObjectId::from_u64(0) }, ]; marker .iter() @@ -137,6 +139,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateMText", "SetTextContent", "CreateHatch", + "CreateDimensionLinear", ]; /// Recorded baseline of the `Query` variant order. @@ -149,6 +152,7 @@ const QUERY_BASELINE: &[&str] = &[ "GetGeometryRevision", "GetTextContent", "GetHatchBoundary", + "GetDimensionMeasurement", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index a66be9384..308c4834e 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -238,6 +238,30 @@ impl DocApiBackend for HostSession<'_> { Ok(loops) } + fn add_dimension_linear(&mut self, spec: &ocs_doc_api::ops::DimensionSpec) -> ApiResult { + use acadrust::entities::{Dimension, DimensionLinear}; + use acadrust::types::Vector3; + let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); + let mut dim = DimensionLinear::new(v3(spec.first_point), v3(spec.second_point)); + dim.definition_point = v3(spec.definition_point); + let handle = self.scene_mut().add_entity(EntityType::Dimension(Dimension::Linear(dim))); + Ok(handle_to_obj(handle)) + } + + fn dimension_measurement(&self, id: ObjectId) -> ApiResult { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + let EntityType::Dimension(d) = entity else { + return Err(ApiError::Unsupported("GetDimensionMeasurement is only for Dimension".into())); + }; + // Distance for linear/radius/diameter; degrees for angular. + Ok(match d { + acadrust::entities::Dimension::Linear(x) => x.measurement(), + acadrust::entities::Dimension::Radius(x) => x.measurement(), + acadrust::entities::Dimension::Diameter(x) => x.measurement(), + _ => return Err(ApiError::Unsupported("dimension measurement for this sub-type".into())), + }) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -905,6 +929,38 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 2b-c: dimension ───────────────────────────────────────────────── + + #[test] + fn phase2c_dimension_linear_create_measurement_bounds() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Linear dimension from (0,0,0) to (30,0,0) with the line at (0,5,0). + let dim = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateDimensionLinear( + ocs_doc_api::ops::DimensionSpec { + first_point: [0.0, 0.0, 0.0], + second_point: [30.0, 0.0, 0.0], + definition_point: [0.0, 5.0, 0.0], + }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetDimensionMeasurement { id: dim }, + Query::GetEntity { id: dim }, + Query::GetBounds { id: dim }, + ])).unwrap(); + match &receipt.query_results[0] { + QueryResult::DimensionMeasurement(v) => assert!((*v - 30.0).abs() < 1e-6, "measurement {v}"), + other => panic!("expected measurement, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[2] { + QueryResult::Bounds(b) => assert!((b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 30.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + } + // ── Phase 2b-b: hatch ──────────────────────────────────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index b39bfa9f3..4123e6dc9 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -412,6 +412,16 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + let pts = [d.first_point, d.second_point, d.definition_point]; + 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)]; + } + Aabb { min, max } + } EntityType::Hatch(h) => { let mut min = [f64::INFINITY; 3]; let mut max = [f64::NEG_INFINITY; 3]; From 67655add72365151049c8181f50513d13383c05e Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:02:23 +0200 Subject: [PATCH 13/31] ocs_doc_api: Phase 3 attributes + nested block traversal - SetAttribute op + GetAttributes/GetBlockEntities queries (append-only). - backend set_attribute (add/update Insert attribute), attributes (tag,value pairs), block_entities (read-only traversal via BlockRecord.entity_handles -> id+kind+bounds). - facade Entity::set_attribute/attributes, Document::block_entities. - spec Insert methods; api_reference + binding_schema regenerated; mocks + baseline. - Query enum: drop Copy (GetBlockEntities carries String). - Test phase3_attributes_and_block_traversal -> 608 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 9 +- crates/ocs_doc_api/examples/common/mod.rs | 21 ++++ crates/ocs_doc_api/spec/entities.toml | 13 +++ crates/ocs_doc_api/src/backend.rs | 9 ++ crates/ocs_doc_api/src/executor.rs | 32 +++++-- crates/ocs_doc_api/src/facade.rs | 20 ++++ crates/ocs_doc_api/src/gen/api_reference.md | 5 + .../ocs_doc_api/src/gen/binding_schema.json | 34 ++++++- crates/ocs_doc_api/src/gen/ops_gen.rs | 2 + crates/ocs_doc_api/src/gen/query_gen.rs | 7 +- crates/ocs_doc_api/src/ops.rs | 1 + crates/ocs_doc_api/src/query.rs | 6 ++ crates/ocs_doc_api/tests/mock_backend.rs | 21 ++++ .../tests/spec_wire_consistency.rs | 6 ++ src/app/doc_api.rs | 95 +++++++++++++++++++ 15 files changed, 267 insertions(+), 14 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 4d2fdde13..a330db132 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -79,8 +79,13 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal - **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. -- **Remaining:** `AttributeDefinition`/`AttributeEntity` typed create + attribute - get/set; read-only nested block-content traversal. +- **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. +- **Remaining:** typed `AttributeDefinition` (in-block attribute-definition) + create. ### Phase 4 — paper-space & viewports - **Done:** `create_viewport(center, width, height, view_target, view_height)` → diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index fce8ca73e..d6a5c7900 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -27,6 +27,8 @@ pub struct MockBackend { hatch_boundaries: HashMap>>, /// Stored dimension measurements. dimension_measurements: HashMap, + /// Stored insert attributes. + attributes_store: HashMap>, } impl MockBackend { @@ -180,6 +182,25 @@ impl DocApiBackend for MockBackend { fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let attrs = self.attributes_store.entry(id).or_default(); + if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { + a.1 = value.to_string(); + } else { + attrs.push((tag.to_string(), value.to_string())); + } + Ok(()) + } + fn attributes(&self, id: ObjectId) -> ApiResult> { + self.attributes_store.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } + fn block_entities(&self, _block_name: &str) -> ApiResult> { + Ok(Vec::new()) + } +} fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 5dd6be8a6..174f5a253 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -347,6 +347,19 @@ name = "create_insert" args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] op = "CreateInsert" +[[family.method]] +name = "attributes" +kind = "query" +query = "GetAttributes" +returns = "Vec<(String, String)>" + +[[family.method]] +name = "set_attribute" +kind = "op" +op = "SetAttribute" +args = [{ name = "tag", ty = "&str" }, { name = "value", ty = "&str" }] +returns = "()" + # ============================================================================ # PAPER-SPACE FAMILY (phase 4) — viewports # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 8a591b204..13d09461b 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -66,6 +66,15 @@ pub trait DocApiBackend { /// A dimension's measured value (distance for linear/radius, degrees for angular). fn dimension_measurement(&self, id: ObjectId) -> ApiResult; + /// Set an attribute `value` for `tag` on an insert (adds if absent). Insert-only. + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()>; + + /// An insert's attributes as (tag, value) pairs. Insert-only. + fn attributes(&self, id: ObjectId) -> ApiResult>; + + /// The entities inside a block definition (read-only traversal), as views. + fn block_entities(&self, block_name: &str) -> ApiResult>; + /// 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. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 7ab6b106f..f8cfa8962 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -227,6 +227,16 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.can_modify(*id).map_err(|e| match e { + ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")), + other => other, + })?; + b.push_undo(name); + b.set_attribute(*id, tag, value)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -253,20 +263,22 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes } let mut results = Vec::with_capacity(queries.len()); for q in &queries { - let r = match *q { - Query::GetEntity { id } => QueryResult::Entity(b.get_entity(id)?), - Query::GetBounds { id } => QueryResult::Bounds(b.bounds(id)?), - Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(id)?), - Query::GetVolume { id } => QueryResult::Volume(b.volume(id)?), + let r = match q { + Query::GetEntity { id } => QueryResult::Entity(b.get_entity(*id)?), + Query::GetBounds { id } => QueryResult::Bounds(b.bounds(*id)?), + Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(*id)?), + Query::GetVolume { id } => QueryResult::Volume(b.volume(*id)?), Query::GetIntersects { a, b: bid } => { - let ba = b.bounds(a)?; - let bb = b.bounds(bid)?; + let ba = b.bounds(*a)?; + let bb = b.bounds(*bid)?; QueryResult::Intersects(ba.overlaps(&bb)) } Query::GetGeometryRevision => QueryResult::Revision(b.revision()), - Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(id)?), - Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(id)?), - Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(id)?), + Query::GetTextContent { id } => QueryResult::TextContent(b.text_content(*id)?), + Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(*id)?), + Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(*id)?), + Query::GetAttributes { id } => QueryResult::Attributes(b.attributes(*id)?), + Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(b.block_entities(block_name)?), }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 05689a0ce..e0e7648eb 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -128,6 +128,14 @@ impl Document { } } + /// Read-only traversal of a block definition's entities (ids + kinds + bounds). + pub fn block_entities(&self, block_name: &str) -> ApiResult> { + 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())), + } + } + /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). /// The closure records queries on a [`QueryBatch`]; the results are returned /// in the same order as a [`QueryResults`] view the caller destructures. @@ -289,6 +297,18 @@ impl Entity { _ => Err(ApiError::Transport("unexpected entity result".into())), } } + /// This insert's attributes as (tag, value) pairs. Insert-only. + pub fn attributes(&self) -> ApiResult> { + 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() })?; + Ok(()) + } pub fn as_solid(&self) -> Option { // 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)) diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index d3366621d..5a0dbdb06 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -145,6 +145,11 @@ Block reference. Place an existing BlockRecord by name; transform moves its inse - **create_insert**(`block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64`) -> `Entity` +### Methods + +- **attributes**(``) -> `Vec<(String, String)>` — query `GetAttributes` +- **set_attribute**(`tag: &str, value: &str`) -> `()` — op `SetAttribute` + ## `Entity` (acadrust `Viewport`, collection `entities`) Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 45646f17c..cb9d1e307 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -300,7 +300,39 @@ "acadrust_variant": "Insert", "collection": "entities", "handle": "Entity", - "methods": [], + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "attributes", + "op": null, + "query": "GetAttributes", + "read_only": true, + "returns": "Vec<(String, String)>" + }, + { + "args": [ + { + "name": "tag", + "ty": "&str" + }, + { + "name": "value", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_attribute", + "op": "SetAttribute", + "query": null, + "read_only": false, + "returns": "()" + } + ], "name": "Insert" }, { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index fa1f908de..f0ddb86d9 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -40,4 +40,6 @@ pub enum Operation { CreateHatch(HatchSpec), // dimensions CreateDimensionLinear(DimensionSpec), + // attributes + SetAttribute { id: ObjectId, tag: String, value: String }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 44bc8af19..531febeed 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -9,7 +9,8 @@ use crate::id::ObjectId; /// A typed read query (plan §5). Read-only: no mutation, no undo, no revision /// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +/// (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 }, @@ -25,4 +26,8 @@ pub enum Query { GetHatchBoundary { id: ObjectId }, /// The measured value of a Dimension (distance or angle). GetDimensionMeasurement { id: ObjectId }, + /// An insert's attributes as (tag, value) pairs. + GetAttributes { id: ObjectId }, + /// The entity ids + kinds inside a block definition (read-only traversal). + GetBlockEntities { block_name: String }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index dd688c88e..eef7025ea 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -169,6 +169,7 @@ impl crate::gen::Operation { SetTextContent { .. } => "SetTextContent", CreateHatch(_) => "CreateHatch", CreateDimensionLinear(_) => "CreateDimensionLinear", + SetAttribute { .. } => "SetAttribute", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 1e6e1fe52..b2103b025 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -49,6 +49,10 @@ pub enum QueryResult { HatchBoundary(Vec>), /// A dimension's measured value (distance for linear/radius, degrees for angular). DimensionMeasurement(f64), + /// An insert's attributes as (tag, value) pairs. + Attributes(Vec<(String, String)>), + /// The entities inside a block definition (read-only traversal). + BlockEntities(Vec), } /// Convenience: the query name for diagnostics. @@ -65,6 +69,8 @@ impl crate::gen::Query { GetTextContent { .. } => "GetTextContent", GetHatchBoundary { .. } => "GetHatchBoundary", GetDimensionMeasurement { .. } => "GetDimensionMeasurement", + GetAttributes { .. } => "GetAttributes", + GetBlockEntities { .. } => "GetBlockEntities", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 6cfe3485e..743d4e9dd 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -27,6 +27,8 @@ struct MockBackend { hatch_boundaries: HashMap>>, /// Stored dimension measurements. dimension_measurements: HashMap, + /// Stored insert attributes. + attributes_store: HashMap>, } impl MockBackend { @@ -120,6 +122,25 @@ impl DocApiBackend for MockBackend { fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let attrs = self.attributes_store.entry(id).or_default(); + if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { + a.1 = value.to_string(); + } else { + attrs.push((tag.to_string(), value.to_string())); + } + Ok(()) + } + fn attributes(&self, id: ObjectId) -> ApiResult> { + self.attributes_store.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } + fn block_entities(&self, _block_name: &str) -> ApiResult> { + Ok(Vec::new()) + } +} fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 3e51c937e..2abb5422c 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -58,6 +58,7 @@ fn operation_variants() -> Vec { 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() }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -85,6 +86,8 @@ fn query_variants() -> Vec { 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() }, ]; marker .iter() @@ -140,6 +143,7 @@ const OPERATION_BASELINE: &[&str] = &[ "SetTextContent", "CreateHatch", "CreateDimensionLinear", + "SetAttribute", ]; /// Recorded baseline of the `Query` variant order. @@ -153,6 +157,8 @@ const QUERY_BASELINE: &[&str] = &[ "GetTextContent", "GetHatchBoundary", "GetDimensionMeasurement", + "GetAttributes", + "GetBlockEntities", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 308c4834e..32f7d257b 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -262,6 +262,55 @@ impl DocApiBackend for HostSession<'_> { }) } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { + let handle = obj_to_handle(id); + let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; + let EntityType::Insert(mut ins) = entity else { + return Err(ApiError::Unsupported("SetAttribute is only for Insert entities".into())); + }; + if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) { + attr.value = value.to_string(); + } else { + let mut attr = acadrust::entities::AttributeEntity::default(); + attr.tag = tag.to_string(); + attr.value = value.to_string(); + attr.insertion_point = ins.insert_point; + ins.attributes.push(attr); + } + if !self.scene_mut().update_entity(EntityType::Insert(ins)) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + + fn attributes(&self, id: ObjectId) -> ApiResult> { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + let EntityType::Insert(ins) = entity else { + return Err(ApiError::Unsupported("GetAttributes is only for Insert entities".into())); + }; + Ok(ins.attributes.iter().map(|a| (a.tag.clone(), a.value.clone())).collect()) + } + + fn block_entities(&self, block_name: &str) -> ApiResult> { + let br = self + .document() + .block_records + .get(block_name) + .ok_or_else(|| ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}")))?; + // Read-only traversal of the block definition's entities (handle lookups). + let mut out = Vec::with_capacity(br.entity_handles.len()); + for h in &br.entity_handles { + let id = handle_to_obj(*h); + let (kind, bounds) = match self.document().get_entity(*h) { + Some(e) => (crate::app::doc_api_convert::entity_kind_name(e).to_string(), + crate::app::doc_api_convert::entity_bounds(Some(e), id).ok()), + None => ("Missing".to_string(), None), + }; + out.push(EntityView { id, kind, bounds }); + } + Ok(out) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -929,6 +978,52 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 3 remaining: attributes + nested block traversal ────────────── + + #[test] + fn phase3_attributes_and_block_traversal() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A block "Door" containing a line; insert it, then set/read attributes. + host.document_mut().block_records.add(acadrust::tables::BlockRecord::new("Door")).unwrap(); + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Line { start: [0.0; 3], end: [10.0; 3] }))).unwrap()); + host.document_mut().block_records.get_mut("Door").unwrap().entity_handles.push(obj_to_handle(line)); + + let ins = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateInsert( + ocs_doc_api::ops::InsertSpec { block_name: "Door".into(), insert_point: [0.0; 3], scale: 1.0, rotation: 0.0 }))).unwrap()); + + // set_attribute adds a new attribute; get_attributes reads it back. + dispatch(&mut host, DocApiEnvelope::op(Operation::SetAttribute { id: ins, tag: "HANDLE".into(), value: "A-1".into() })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Attributes(attrs) => { + assert_eq!(attrs.len(), 1); + assert_eq!(attrs[0], ("HANDLE".to_string(), "A-1".to_string())); + } + other => panic!("expected attributes, got {other:?}"), + } + // set_attribute on an existing tag updates it. + dispatch(&mut host, DocApiEnvelope::op(Operation::SetAttribute { id: ins, tag: "HANDLE".into(), value: "A-2".into() })).unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Attributes(attrs) => assert_eq!(attrs[0].1, "A-2"), + other => panic!("expected attributes, got {other:?}"), + } + + // Nested traversal: GetBlockEntities("Door") returns the line. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBlockEntities { block_name: "Door".into() }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::BlockEntities(v) => { + assert_eq!(v.len(), 1); + assert_eq!(v[0].kind, "Line"); + } + other => panic!("expected block entities, got {other:?}"), + } + // Unknown block -> validation error. + assert!(dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBlockEntities { block_name: "Nope".into() }])).is_err()); + } + // ── Phase 2b-c: dimension ───────────────────────────────────────────────── #[test] From e6c132e0ebb5c533a7f8570cf990822b33170551 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:16:27 +0200 Subject: [PATCH 14/31] ocs_doc_api: Phase 4 set_view + viewport-view query - SetViewportView op + GetViewportView query (append-only). - backend set_viewport_view (update Viewport.view_target/view_height) + viewport_view. - facade Entity::set_view/viewport_view (Viewport returns Entity). - spec Viewport methods; api_reference + binding_schema regenerated; mocks + baseline. - Test phase4_set_view_and_view_query -> 609 host lib + 3 consistency green. - Phase 4 complete. --- crates/ocs_doc_api/IMPLEMENTATION.md | 6 +- crates/ocs_doc_api/examples/common/mod.rs | 18 +++++- crates/ocs_doc_api/spec/entities.toml | 13 +++++ crates/ocs_doc_api/src/backend.rs | 6 ++ crates/ocs_doc_api/src/executor.rs | 14 +++++ crates/ocs_doc_api/src/facade.rs | 12 ++++ crates/ocs_doc_api/src/gen/api_reference.md | 5 ++ .../ocs_doc_api/src/gen/binding_schema.json | 34 ++++++++++- crates/ocs_doc_api/src/gen/ops_gen.rs | 2 + crates/ocs_doc_api/src/gen/query_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 1 + crates/ocs_doc_api/src/query.rs | 3 + crates/ocs_doc_api/tests/mock_backend.rs | 18 +++++- .../tests/spec_wire_consistency.rs | 4 ++ src/app/doc_api.rs | 56 +++++++++++++++++++ 15 files changed, 187 insertions(+), 7 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index a330db132..653ca79b0 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -91,8 +91,10 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal - **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. -- **Remaining:** `set_view` (retarget/re-zoom an existing viewport) and a - viewport-view query (view target/center/scale DTOs). +- **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` diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index d6a5c7900..da2614520 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -29,6 +29,8 @@ pub struct MockBackend { dimension_measurements: HashMap, /// Stored insert attributes. attributes_store: HashMap>, + /// Stored viewport views (target, height). + viewport_views: HashMap, } impl MockBackend { @@ -138,8 +140,10 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("Insert")) } - fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - Ok(self.alloc("Viewport")) + fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + let id = self.alloc("Viewport"); + self.viewport_views.insert(id, (spec.view_target, spec.view_height)); + Ok(id) } fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { let id = self.alloc("Text"); @@ -200,6 +204,16 @@ impl DocApiBackend for MockBackend { fn block_entities(&self, _block_name: &str) -> ApiResult> { Ok(Vec::new()) } + fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + self.viewport_views.insert(id, (view_target, view_height)); + Ok(()) + } + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { + self.viewport_views.get(&id).copied().ok_or(ApiError::UnknownId(id)) + } } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 174f5a253..f28d8df80 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -375,6 +375,19 @@ name = "create_viewport" 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" }] op = "CreateViewport" +[[family.method]] +name = "viewport_view" +kind = "query" +query = "GetViewportView" +returns = "([f64; 3], f64)" + +[[family.method]] +name = "set_view" +kind = "op" +op = "SetViewportView" +args = [{ name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] +returns = "()" + # ============================================================================ # MEDIA & MISC (phase 5, read-mostly) — read-only EntityView, no typed create. # These families report a real `kind` via GetEntity and coarse bounds where diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 13d09461b..edca3215f 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -75,6 +75,12 @@ pub trait DocApiBackend { /// The entities inside a block definition (read-only traversal), as views. fn block_entities(&self, block_name: &str) -> ApiResult>; + /// 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<()>; + + /// A viewport's view (target WCS + zoom height). Viewport-only. + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>; + /// 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. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index f8cfa8962..319339b6e 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -237,6 +237,16 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.can_modify(*id).map_err(|e| match e { + ApiError::UnknownId(_) => ApiError::validation(name, format!("unknown ObjectId {id:?}")), + other => other, + })?; + b.push_undo(name); + b.set_viewport_view(*id, *view_target, *view_height)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -279,6 +289,10 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(*id)?), Query::GetAttributes { id } => QueryResult::Attributes(b.attributes(*id)?), Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(b.block_entities(block_name)?), + Query::GetViewportView { id } => { + let (target, height) = b.viewport_view(*id)?; + QueryResult::ViewportView { target, height } + } }; results.push(r); } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index e0e7648eb..2912d5905 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -309,6 +309,18 @@ impl Entity { 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 })? { + QueryResult::ViewportView { target, height } => Ok((target, height)), + _ => 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 })?; + Ok(()) + } pub fn as_solid(&self) -> Option { // 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)) diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 5a0dbdb06..25e7cad9a 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -158,6 +158,11 @@ Paper-space viewport: a width×height window at center looking at view_target wi - **create_viewport**(`center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64`) -> `Entity` +### Methods + +- **viewport_view**(``) -> `([f64; 3], f64)` — query `GetViewportView` +- **set_view**(`view_target: [f64; 3], view_height: f64`) -> `()` — op `SetViewportView` + ## `Entity` (acadrust `RasterImage`, collection `entities`) Raster image (read-mostly): GetEntity kind + coarse bounds (insertion + u*width + v*height); generic delete. No typed create in v1. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index cb9d1e307..a405f67cb 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -339,7 +339,39 @@ "acadrust_variant": "Viewport", "collection": "entities", "handle": "Entity", - "methods": [], + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "viewport_view", + "op": null, + "query": "GetViewportView", + "read_only": true, + "returns": "([f64; 3], f64)" + }, + { + "args": [ + { + "name": "view_target", + "ty": "[f64; 3]" + }, + { + "name": "view_height", + "ty": "f64" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_view", + "op": "SetViewportView", + "query": null, + "read_only": false, + "returns": "()" + } + ], "name": "Viewport" }, { diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index f0ddb86d9..682db65ab 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -42,4 +42,6 @@ pub enum Operation { CreateDimensionLinear(DimensionSpec), // attributes SetAttribute { id: ObjectId, tag: String, value: String }, + // viewport view + SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index 531febeed..ffeefb046 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -30,4 +30,6 @@ pub enum Query { GetAttributes { id: ObjectId }, /// The entity ids + kinds inside a block definition (read-only traversal). GetBlockEntities { block_name: String }, + /// A viewport's view (target + zoom height). + GetViewportView { id: ObjectId }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index eef7025ea..3e139ce2e 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -170,6 +170,7 @@ impl crate::gen::Operation { CreateHatch(_) => "CreateHatch", CreateDimensionLinear(_) => "CreateDimensionLinear", SetAttribute { .. } => "SetAttribute", + SetViewportView { .. } => "SetViewportView", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index b2103b025..4b9f82076 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -53,6 +53,8 @@ pub enum QueryResult { Attributes(Vec<(String, String)>), /// The entities inside a block definition (read-only traversal). BlockEntities(Vec), + /// A viewport's view: target point (WCS) + zoom height. + ViewportView { target: [f64; 3], height: f64 }, } /// Convenience: the query name for diagnostics. @@ -71,6 +73,7 @@ impl crate::gen::Query { GetDimensionMeasurement { .. } => "GetDimensionMeasurement", GetAttributes { .. } => "GetAttributes", GetBlockEntities { .. } => "GetBlockEntities", + GetViewportView { .. } => "GetViewportView", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 743d4e9dd..e6f6c36a6 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -29,6 +29,8 @@ struct MockBackend { dimension_measurements: HashMap, /// Stored insert attributes. attributes_store: HashMap>, + /// Stored viewport views (target, height). + viewport_views: HashMap, } impl MockBackend { @@ -78,8 +80,10 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("Insert")) } - fn add_viewport(&mut self, _spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - Ok(self.alloc("Viewport")) + fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { + let id = self.alloc("Viewport"); + self.viewport_views.insert(id, (spec.view_target, spec.view_height)); + Ok(id) } fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { let id = self.alloc("Text"); @@ -140,6 +144,16 @@ impl DocApiBackend for MockBackend { fn block_entities(&self, _block_name: &str) -> ApiResult> { Ok(Vec::new()) } + fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + self.viewport_views.insert(id, (view_target, view_height)); + Ok(()) + } + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { + self.viewport_views.get(&id).copied().ok_or(ApiError::UnknownId(id)) + } } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 2abb5422c..1309a00bd 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -59,6 +59,7 @@ fn operation_variants() -> Vec { 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 }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -88,6 +89,7 @@ fn query_variants() -> Vec { 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() @@ -144,6 +146,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateHatch", "CreateDimensionLinear", "SetAttribute", + "SetViewportView", ]; /// Recorded baseline of the `Query` variant order. @@ -159,6 +162,7 @@ const QUERY_BASELINE: &[&str] = &[ "GetDimensionMeasurement", "GetAttributes", "GetBlockEntities", + "GetViewportView", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 32f7d257b..8575cad69 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -311,6 +311,28 @@ impl DocApiBackend for HostSession<'_> { Ok(out) } + fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { + let handle = obj_to_handle(id); + let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; + let EntityType::Viewport(mut vp) = entity else { + return Err(ApiError::Unsupported("SetViewportView is only for Viewport".into())); + }; + vp.view_target = acadrust::types::Vector3::new(view_target[0], view_target[1], view_target[2]); + vp.view_height = view_height; + if !self.scene_mut().update_entity(EntityType::Viewport(vp)) { + return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); + } + Ok(()) + } + + fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { + let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; + let EntityType::Viewport(vp) = entity else { + return Err(ApiError::Unsupported("GetViewportView is only for Viewport".into())); + }; + Ok(([vp.view_target.x, vp.view_target.y, vp.view_target.z], vp.view_height)) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -978,6 +1000,40 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 4 remaining: set_view + viewport-view query ──────────────────── + + #[test] + fn phase4_set_view_and_view_query() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let vp = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateViewport( + ocs_doc_api::ops::ViewportSpec { + center: [50.0, 50.0, 0.0], width: 40.0, height: 30.0, + view_target: [0.0, 0.0, 0.0], view_height: 100.0, + }))).unwrap()); + + // set_view retargets + re-zooms in place. + dispatch(&mut host, DocApiEnvelope::op(Operation::SetViewportView { + id: vp, view_target: [20.0, 30.0, 0.0], view_height: 50.0 })).unwrap(); + let Some(EntityType::Viewport(v)) = host.document().get_entity(obj_to_handle(vp)) else { panic!("viewport not found") }; + assert!((v.view_target.x - 20.0).abs() < 1e-6 && (v.view_height - 50.0).abs() < 1e-6); + + // viewport_view query reads it back. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetViewportView { id: vp }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::ViewportView { target, height } => { + assert!((target[0] - 20.0).abs() < 1e-6 && (*height - 50.0).abs() < 1e-6); + } + other => panic!("expected viewport view, got {other:?}"), + } + + // set_view on a non-viewport is Unsupported. + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Line { start: [0.0; 3], end: [1.0; 3] }))).unwrap()); + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::SetViewportView { + id: line, view_target: [0.0; 3], view_height: 1.0 })).is_err()); + } + // ── Phase 3 remaining: attributes + nested block traversal ────────────── #[test] From 46a59225203c1d8d00483588f945e8ac8c43d10a Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:28:51 +0200 Subject: [PATCH 15/31] ocs_doc_api: Phase 5 typed raster image create - CreateRasterImage(RasterImageSpec) op (append-only). - backend add_raster_image (RasterImage with file_path/insertion/u/v/size; the scene auto-registers the ImageDefinition on add_entity). - facade create_raster_image; spec RasterImage constructor; api_reference + binding_schema regenerated; mocks + baseline. - Test phase5_create_raster_image_registers_definition (asserts definition_handle auto-registered + 4-corner bounds) -> 610 host lib + 3 consistency green. - Phase 5 complete. --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 ++-- crates/ocs_doc_api/examples/common/mod.rs | 3 ++ crates/ocs_doc_api/spec/entities.toml | 7 ++- crates/ocs_doc_api/src/backend.rs | 3 ++ crates/ocs_doc_api/src/executor.rs | 6 +++ crates/ocs_doc_api/src/facade.rs | 13 ++++++ crates/ocs_doc_api/src/gen/api_reference.md | 6 ++- crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/ops.rs | 14 ++++++ crates/ocs_doc_api/tests/mock_backend.rs | 3 ++ .../tests/spec_wire_consistency.rs | 4 ++ src/app/doc_api.rs | 44 +++++++++++++++++++ 12 files changed, 109 insertions(+), 6 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 653ca79b0..a413b20f5 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -102,9 +102,11 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal `Region`, `Body`, `Surface`, `Face3D`, `Dimension`, `Hatch`) instead of "Other"; `RasterImage` has coarse bounds (insertion + u·width + v·height); generic `delete` works on all. Covered by `phase5_*` test. -- **Remaining:** typed create/property ops for `RasterImage`/`Table`/light; - these largely stay read-only `EntityView` DTOs (typed ops only where the spec - declares an action mapping). +- **Done (typed create):** `create_raster_image(file_path, insertion, u, v, size)` + → `Operation::CreateRasterImage`; the host auto-registers the `ImageDefinition` + (asserted by `phase5_create_raster_image_registers_definition`). Covered by that + test. `Table`/light stay read-only `EntityView` DTOs (typed ops only where the + spec declares an action mapping). ## 🔧 Outstanding methods on supported families diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index da2614520..b52e9781c 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -204,6 +204,9 @@ impl DocApiBackend for MockBackend { fn block_entities(&self, _block_name: &str) -> ApiResult> { Ok(Vec::new()) } + fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { + Ok(self.alloc("RasterImage")) + } fn 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)); diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index f28d8df80..72d3f2314 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -398,7 +398,12 @@ name = "RasterImage" acadrust_variant = "RasterImage" handle = "Entity" collection = "entities" -description = "Raster image (read-mostly): GetEntity kind + coarse bounds (insertion + u*width + v*height); generic delete. No typed create in v1." +description = "Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete." + +[[family.constructor]] +name = "create_raster_image" +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]" }] +op = "CreateRasterImage" [[family]] name = "Table" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index edca3215f..ba25033d4 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -81,6 +81,9 @@ pub trait DocApiBackend { /// A viewport's view (target WCS + zoom height). Viewport-only. fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>; + /// Add a `RASTER_IMAGE` (host auto-registers the ImageDefinition). + fn add_raster_image(&mut self, spec: &crate::ops::RasterImageSpec) -> ApiResult; + /// 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. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 319339b6e..57ad09c2c 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -247,6 +247,12 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_raster_image(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 2912d5905..5305469ec 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -509,6 +509,19 @@ impl CurveCollection { let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; 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 { + 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 { let receipt = self.session.apply_op(Operation::CreateHatch(crate::ops::HatchSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 25e7cad9a..38766a90f 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -165,7 +165,11 @@ Paper-space viewport: a width×height window at center looking at view_target wi ## `Entity` (acadrust `RasterImage`, collection `entities`) -Raster image (read-mostly): GetEntity kind + coarse bounds (insertion + u*width + v*height); generic delete. No typed create in v1. +Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete. + +### Constructors (`doc.entities()`) + +- **create_raster_image**(`file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]`) -> `Entity` ## `Entity` (acadrust `Table`, collection `entities`) diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 682db65ab..28ecb9a28 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, SolidPrimitive, TextSpec, ViewportSpec}; +use crate::ops::{BoolOp, Curve2Spec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TextSpec, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -44,4 +44,6 @@ pub enum Operation { SetAttribute { id: ObjectId, tag: String, value: String }, // viewport view SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }, + // media + CreateRasterImage(RasterImageSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 3e139ce2e..749ddb753 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -115,6 +115,19 @@ pub struct DimensionSpec { pub definition_point: [f64; 3], } +/// Construction spec for a `RASTER_IMAGE`: an image file placed at +/// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and +/// pixel `size` (width, height). The host auto-registers the ImageDefinition. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RasterImageSpec { + pub file_path: String, + pub insertion_point: [f64; 3], + pub u_vector: [f64; 3], + pub v_vector: [f64; 3], + /// Pixel size (width, height). + pub size: [f64; 2], +} + /// A rigid similarity (rotation + translation + optional uniform scale/reflection) /// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` /// (non-serde). Columns are the new basis axes; `origin` is the translation. @@ -171,6 +184,7 @@ impl crate::gen::Operation { CreateDimensionLinear(_) => "CreateDimensionLinear", SetAttribute { .. } => "SetAttribute", SetViewportView { .. } => "SetViewportView", + CreateRasterImage(_) => "CreateRasterImage", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index e6f6c36a6..2bd1cd1fe 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -144,6 +144,9 @@ impl DocApiBackend for MockBackend { fn block_entities(&self, _block_name: &str) -> ApiResult> { Ok(Vec::new()) } + fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { + Ok(self.alloc("RasterImage")) + } fn 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)); diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 1309a00bd..b636e3e5d 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -60,6 +60,9 @@ fn operation_variants() -> Vec { 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], + }), ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -147,6 +150,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateDimensionLinear", "SetAttribute", "SetViewportView", + "CreateRasterImage", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 8575cad69..d0d27d0ec 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -333,6 +333,20 @@ impl DocApiBackend for HostSession<'_> { Ok(([vp.view_target.x, vp.view_target.y, vp.view_target.z], vp.view_height)) } + fn add_raster_image(&mut self, spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { + use acadrust::entities::RasterImage; + use acadrust::types::{Vector2, Vector3}; + let mut img = RasterImage::default(); + img.file_path = spec.file_path.clone(); + img.insertion_point = Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]); + img.u_vector = Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]); + img.v_vector = Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]); + img.size = Vector2::new(spec.size[0], spec.size[1]); + // add_entity auto-registers the ImageDefinition (scene/entity.rs). + let handle = self.scene_mut().add_entity(EntityType::RasterImage(img)); + Ok(handle_to_obj(handle)) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -1000,6 +1014,36 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Phase 5 remaining: typed raster image create ───────────────────────── + + #[test] + fn phase5_create_raster_image_registers_definition() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let img = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateRasterImage( + ocs_doc_api::ops::RasterImageSpec { + file_path: "C:/img/photo.png".into(), + insertion_point: [10.0, 20.0, 0.0], + u_vector: [0.5, 0.0, 0.0], + v_vector: [0.0, 0.5, 0.0], + size: [100.0, 50.0], + }))).unwrap()); + let handle = obj_to_handle(img); + let Some(EntityType::RasterImage(i)) = host.document().get_entity(handle) else { panic!("image not found") }; + assert_eq!(i.file_path, "C:/img/photo.png"); + // The host auto-registered an ImageDefinition (definition_handle set). + assert!(i.definition_handle.is_some(), "ImageDefinition was auto-registered"); + // Bounds = insertion + u*100 + v*50 = (60, 45) (4-corner bracket). + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetBounds { id: img }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6, "{b:?}"); + assert!((b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + } + // ── Phase 4 remaining: set_view + viewport-view query ──────────────────── #[test] From e216a7bf947aa24644d9e1bc843026a33b5687f5 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:44:37 +0200 Subject: [PATCH 16/31] =?UTF-8?q?ocs=5Fdoc=5Fapi:=20outstanding=20methods?= =?UTF-8?q?=20=E2=80=94=20loft=20+=20bulge-arc=20polyline=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Loft{profiles} op (append-only); executor resolves each profile to curve sets all-or-nothing; backend loft() -> brep::loft on XY plane; facade solids().loft. - entity_to_profile_curves: convert LwVertex.bulge to geom2d::Curve::Arc via bulge_arc_segment (theta = 4*atan(bulge)), so bulged polyline profiles sweep correctly instead of erroring Unsupported. - spec loft; api_reference + binding_schema regenerated; mocks + baseline. - Tests: loft_two_profiles_produces_solid, bulge_arc_polyline_profile_converts_to_arc (semicircle radius=5 for bulge=1) -> 612 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 ++- crates/ocs_doc_api/examples/common/mod.rs | 8 +++ crates/ocs_doc_api/spec/entities.toml | 5 ++ crates/ocs_doc_api/src/backend.rs | 4 ++ crates/ocs_doc_api/src/executor.rs | 16 +++++ crates/ocs_doc_api/src/facade.rs | 7 ++ crates/ocs_doc_api/src/gen/api_reference.md | 1 + crates/ocs_doc_api/src/gen/ops_gen.rs | 2 + crates/ocs_doc_api/src/ops.rs | 1 + crates/ocs_doc_api/tests/mock_backend.rs | 9 +++ .../tests/spec_wire_consistency.rs | 2 + src/app/doc_api.rs | 67 +++++++++++++++++++ src/app/doc_api_convert.rs | 67 +++++++++++++------ 13 files changed, 176 insertions(+), 21 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index a413b20f5..c9fe0a5eb 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -112,10 +112,14 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal | Method | Family | Status | Blocker | |---|---|---|---| -| `loft(sections)` | Solid | not started | multi-profile kernel op + spec | -| Bulge-arc polyline profiles | Solid sweep | partial | `entity_to_profile_curves` emits straight segments; bulge arcs need arc-segment conversion | | `GetCentroid`/`GetVolume` accuracy | Solid | mesh approx | render-mesh LOD tolerance vs query tolerance | +**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`. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index b52e9781c..8ddc9a849 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -207,6 +207,14 @@ impl DocApiBackend for MockBackend { fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { Ok(self.alloc("RasterImage")) } + fn loft(&mut self, sections: &[Vec]) -> ApiResult { + if sections.len() < 2 { + return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); + } + let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; + self.store_solid(&body) + } fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 72d3f2314..16f1ec48f 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -68,6 +68,11 @@ name = "revolve" args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] op = "Revolve" +[[family.constructor]] +name = "loft" +args = [{ name = "profiles", ty = "&[EntityRef]" }] +op = "Loft" + [[family.method]] name = "intersect" kind = "op" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index ba25033d4..47991ec74 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -84,6 +84,10 @@ pub trait DocApiBackend { /// Add a `RASTER_IMAGE` (host auto-registers the ImageDefinition). fn add_raster_image(&mut self, spec: &crate::ops::RasterImageSpec) -> ApiResult; + /// 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: &[Vec]) -> ApiResult; + /// 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. /// Default: existence + not-locked (backends narrow the family check). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 57ad09c2c..7af52512a 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -253,6 +253,22 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // Resolve every profile to its curve set BEFORE push_undo (all-or-nothing). + if profiles.len() < 2 { + return Err(ApiError::validation(name, "loft needs >= 2 profiles")); + } + 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(curves); + } + b.push_undo(name); + let id = b.loft(§ions)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 5305469ec..43cb6d75a 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -452,6 +452,13 @@ impl SolidCollection { .ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?; Ok(Solid::new(self.session.clone(), id)) } + /// Loft a solid through >= 2 profile entities (polylines/circles/arcs). + pub fn loft(&self, profiles: &[impl HasId]) -> ApiResult { + let ids: Vec = 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()))?; + Ok(Solid::new(self.session.clone(), id)) + } } #[derive(Clone)] diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 38766a90f..b4f17ebd6 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -18,6 +18,7 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from - **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` - **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` - **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` +- **loft**(`profiles: &[EntityRef]`) -> `Solid` ### Methods diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 28ecb9a28..29a2a5792 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -46,4 +46,6 @@ pub enum Operation { SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }, // media CreateRasterImage(RasterImageSpec), + // multi-profile solid + Loft { profiles: Vec }, } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 749ddb753..e041f89b0 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -185,6 +185,7 @@ impl crate::gen::Operation { SetAttribute { .. } => "SetAttribute", SetViewportView { .. } => "SetViewportView", CreateRasterImage(_) => "CreateRasterImage", + Loft { .. } => "Loft", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 2bd1cd1fe..f6e8b39af 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -147,6 +147,15 @@ impl DocApiBackend for MockBackend { fn add_raster_image(&mut self, _spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { Ok(self.alloc("RasterImage")) } + fn loft(&mut self, sections: &[Vec]) -> ApiResult { + if sections.len() < 2 { + return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); + } + // Mock: store a unit cuboid as the loft result. + let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; + self.store_solid(&body) + } fn set_viewport_view(&mut self, id: ObjectId, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index b636e3e5d..94303ab7e 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -63,6 +63,7 @@ fn operation_variants() -> Vec { 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], }), + Operation::Loft { profiles: vec![] }, ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -151,6 +152,7 @@ const OPERATION_BASELINE: &[&str] = &[ "SetAttribute", "SetViewportView", "CreateRasterImage", + "Loft", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d0d27d0ec..13c3ec1d4 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -347,6 +347,17 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn loft(&mut self, sections: &[Vec]) -> ApiResult { + // brep::loft takes (Plane, Vec) per section; all profiles are on XY. + let plane = cadkernel::space::Plane::orthonormal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Other, "failed to construct XY plane"))?; + let loft_sections: Vec<(cadkernel::space::Plane, Vec)> = + sections.iter().map(|s| (plane, s.clone())).collect(); + let body = cadkernel::brep::loft(&loft_sections) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "brep::loft failed"))?; + self.store_solid(&body) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -1014,6 +1025,62 @@ mod tests { assert!((t.insertion_point.x - 15.0).abs() < 1e-6); } + // ── Outstanding methods: loft + bulge-arc profiles ─────────────────────── + + #[test] + fn loft_two_profiles_produces_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Two circles at different Z (profiles for loft). + let c1 = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0, 0.0, 0.0], radius: 5.0 }))).unwrap()); + let c2 = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Circle { centre: [0.0, 0.0, 10.0], radius: 2.0 }))).unwrap()); + let result = dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles: vec![c1, c2] })); + // Loft may be geometry-sensitive; assert either a positive-volume solid or + // a structured geometry error (no panic). + match result { + Ok(receipt) => { + let id = receipt.outcome.and_then(|o| o.new_id()).unwrap(); + let v = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id }])).unwrap(); + match &v.query_results[0] { + QueryResult::Volume(vol) => assert!(*vol > 0.0, "loft volume {vol}"), + other => panic!("expected volume, got {other:?}"), + } + } + Err(ApiError::Geometry { .. } | ApiError::Validation { .. } | ApiError::Unsupported(_)) => {} + Err(e) => panic!("unexpected loft error: {e}"), + } + } + + #[test] + fn bulge_arc_polyline_profile_converts_to_arc() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A polyline with a bulge (arc) segment: square with one curved side. + let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Polyline { + points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], + closed: false, + }))).unwrap()); + // Add a bulge to the first segment by editing the vertex. + let handle = obj_to_handle(poly); + let Some(EntityType::LwPolyline(mut pl)) = host.document().get_entity(handle).cloned() else { panic!("polyline not found") }; + pl.vertices[0].bulge = 1.0; // 90-degree arc (tan(π/2/4) = 1) + host.scene_mut().update_entity(EntityType::LwPolyline(pl)); + + // profile_curves now converts the bulge to a Curve::Arc (not Unsupported). + let curves = host.profile_curves(poly).expect("bulge profile must convert"); + // Open polyline, 3 vertices -> 2 segments: arc (bulged first), line. + assert_eq!(curves.len(), 2, "2 segments (arc + line)"); + assert!(matches!(curves[0], cadkernel::geom2d::Curve::Arc(_)), "first segment is an arc"); + assert!(matches!(curves[1], cadkernel::geom2d::Curve::Line(_)), "second is a line"); + // bulge=1 -> included angle θ = 4·atan(1) = π (a semicircle); radius = chord/2 = 5. + if let cadkernel::geom2d::Curve::Arc(arc) = &curves[0] { + assert!((arc.radius - 5.0).abs() < 0.01, "arc radius {} (semicircle)", arc.radius); + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 4123e6dc9..bdbcb78a2 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -30,26 +30,22 @@ pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult= 2 vertices")); } - // Fail explicitly on bulge arcs rather than silently producing a - // chord-substituted (wrong-shape) profile for Extrude/Revolve. - if pl.vertices.iter().any(|v| v.bulge != 0.0) { - return Err(ApiError::Unsupported( - "polyline profiles with bulge (arc) segments are not yet supported for sweep ops".into(), - )); - } - // Build a straight-segment polyline chain. - let pts: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); - let mut segs = Vec::with_capacity(pts.len()); - for i in 0..pts.len() { - let start = pts[i]; - let end = if i + 1 < pts.len() { - pts[i + 1] - } else if pl.is_closed { - pts[0] + // 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 { - break; - }; - segs.push(KCurve::Line(KLine { start, end })); + segs.push(bulge_arc_segment(start, end, a.bulge)?); + } } segs } @@ -193,6 +189,39 @@ fn v3(p: [f64; 3]) -> Vector3 { Vector3::new(p[0], p[1], p[2]) } +/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a +/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise. +fn bulge_arc_segment(start: [f64; 2], end: [f64; 2], bulge: f64) -> ApiResult { + use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; + 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 (chord/2)/tan(θ/2). + 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(); + // Perpendicular to chord (rotate chord unit vector by +90° for CCW bulge). + 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 { let entity = match spec { From 8417375e5a0e68a7e2147695b83ad0d6eda50701 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:55:36 +0200 Subject: [PATCH 17/31] ocs_doc_api: fix stray impl-closing brace in both mock backends The loft/attributes/viewport mock methods were appended after the impl block's closing brace, orphaning add_vertex..finalize_op and leaving a duplicate brace. Move the impl close to the true end in both tests/mock_backend.rs and examples/common/mod.rs. Crate tests (8) + examples compile green again. --- crates/ocs_doc_api/examples/common/mod.rs | 1 - crates/ocs_doc_api/tests/mock_backend.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 8ddc9a849..bb47211c0 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -225,7 +225,6 @@ impl DocApiBackend for MockBackend { fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { 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) { Ok(()) diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index f6e8b39af..685a44103 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -166,7 +166,6 @@ impl DocApiBackend for MockBackend { fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { 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) { Ok(()) From a9c278e32a5885e5872acf38b233117a7e8a634c Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:22:01 +0200 Subject: [PATCH 18/31] ocs_doc_api: update docs + fix review-drift findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib.rs: export all 14 facade handles (add ArcCurve/Ellipse/Spline/Ray/XLine/Text/ MText/Dimension + Circle) — public surface matches collection return types. - build.rs: fix step-1 comment (enums are hand-maintained, not 'regenerated'); generic_reference_md: Clone (not Copy) + full 14-handle list (was stale 6). - ops.rs/query.rs: correct 'hand-maintained' module doc comments (were 'GENERATED'). - layouts.json: extend to all 22 Operation + 12 Query variants (was stale phase-1). - binding_schema.json regenerated (complete layouts + derived handles). - generate.py: inject 'id': self.id for SetTextContent/SetAttribute/SetViewportView/ Transform/Delete/AddVertex (was malformed id-less payloads); _apply_op/_apply_query accept want=None; extend PY_TYPES (String/&str/Vec returns/new handles). - README.md: Clone (not Copy) + full scope (2D curves, annotations, containers, viewports, media) replacing stale 'Not yet' list. - Plan (.kilo): mark implemented & published; pointer to live crate docs. --- crates/ocs_doc_api/README.md | 20 +++++++++++---- .../ocs_doc_api/bindings/python/generate.py | 25 ++++++++++++------- crates/ocs_doc_api/build.rs | 12 +++++---- crates/ocs_doc_api/src/gen/api_reference.md | 5 ++-- .../ocs_doc_api/src/gen/binding_schema.json | 21 ++++++++++++++-- crates/ocs_doc_api/src/gen/layouts.json | 21 ++++++++++++++-- crates/ocs_doc_api/src/lib.rs | 5 ++-- crates/ocs_doc_api/src/ops.rs | 7 +++--- crates/ocs_doc_api/src/query.rs | 6 +++-- 9 files changed, 90 insertions(+), 32 deletions(-) diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md index 7da56b893..bbbc6c433 100644 --- a/crates/ocs_doc_api/README.md +++ b/crates/ocs_doc_api/README.md @@ -41,7 +41,7 @@ fn intersect_solids(api: &DocApi) -> ApiResult<()> { ([`InProcess`]) or over the `ocs_plugin_api` IPC channel ([`OcsPluginApiIpc`]); only the `Transport` differs. The host executes them through one crate-owned executor. -- **Stable across async IPC** — handles are `Copy`, `Send`, `Sync` `{ObjectId, +- **Stable across async IPC** — handles are `Clone`, `Send`, `Sync` `{ObjectId, Arc}` tokens, safe to share across plugin worker threads. A stale handle's methods return `ApiError::UnknownId`, never a panic. @@ -67,10 +67,20 @@ fn intersect_solids(api: &DocApi) -> ApiResult<()> { booleans (union/intersect/subtract), transform, and queries (bounds/volume/centroid). Host resolves bodies via the `solid_models` cache (lift-on-miss from ACIS SAT). -- **Minimal 2D** — line, circle, polyline, point (construction + bounds); used as - boolean/sweep inputs. -- Not yet: sweep/revolve profiles, `AddVertex`, non-solid transforms, annotations, - containers (insert/block), viewports, media. See `IMPLEMENTATION.md`. +- **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 diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py index ba725ea21..b48acbc2f 100644 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ b/crates/ocs_doc_api/bindings/python/generate.py @@ -62,8 +62,14 @@ class {handle}: PY_TYPES = { "Solid": "'Solid'", "Line": "'Line'", "Circle": "'Circle'", "Polyline": "'Polyline'", "Point": "'Point'", "Entity": "'Entity'", - "f64": "float", "bool": "bool", "usize": "int", - "[f64; 3]": "tuple", "ObjectId": "ObjectId", "()": "None", "None": "None", + "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>": "list", + "([f64; 3], f64)": "tuple", "Vec": "list", "Vec": "list", } def py_type(rust_ty: str) -> str: @@ -115,16 +121,17 @@ def render_method(m: Dict[str, Any]) -> str: 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"): + 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") - elif variant == "AddVertex": - body_items.append("'id': self.id") - for a in args: - body_items.append(f"'{a['name']}': {a['name']}") else: for a in args: body_items.append(f"'{a['name']}': {a['name']}") @@ -151,11 +158,11 @@ class Document: _transport: Any _tab: int - def _apply_op(self, op, want): + def _apply_op(self, op, want=None): env = DocApiEnvelope(ENVELOPE_VERSION, ("Op", op)) return self._transport.apply(env) - def _apply_query(self, q, want): + def _apply_query(self, q, want=None): env = DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [q])) return self._transport.apply(env) diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index 084227843..d76626f5e 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -110,9 +110,10 @@ fn main() { let gen_dir = manifest_dir.join("src/gen"); fs::create_dir_all(&gen_dir).unwrap(); - // 1. Regenerate the ops/query enum snapshots (deterministic; these are the - // curated v1 variants — the spec drives docs/schema, the enums are the - // stable wire vocabulary appended at end only). + // 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()); @@ -149,8 +150,9 @@ fn fmt_args(args: &[Arg]) -> String { fn generic_reference_md() -> String { r#"## Generic methods (every handle) -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a -`Copy`, `Send`, `Sync` token `{ ObjectId, session }` and supports: +Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, +`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a +`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - **`id()` -> `ObjectId`** — the stable entity identity. - **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index b4f17ebd6..6adefe030 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -178,8 +178,9 @@ Table (read-only): GetEntity kind; generic delete. No typed create/bounds in v1. ## Generic methods (every handle) -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `Entity`) is a -`Copy`, `Send`, `Sync` token `{ ObjectId, session }` and supports: +Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, +`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a +`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - **`id()` -> `ObjectId`** — the stable entity identity. - **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index a405f67cb..69a2d72a2 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -410,7 +410,18 @@ "Delete { id: ObjectId }", "CreateMany(Vec)", "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)" + "DeleteMany(Vec)", + "CreateInsert(InsertSpec)", + "CreateViewport(ViewportSpec)", + "CreateText(TextSpec)", + "CreateMText(MTextSpec)", + "SetTextContent { id: ObjectId, value: String }", + "CreateHatch(HatchSpec)", + "CreateDimensionLinear(DimensionSpec)", + "SetAttribute { id: ObjectId, tag: String, value: String }", + "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", + "CreateRasterImage(RasterImageSpec)", + "Loft { profiles: Vec }" ] }, "Query": { @@ -420,7 +431,13 @@ "GetCentroid { id: ObjectId }", "GetVolume { id: ObjectId }", "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision" + "GetGeometryRevision", + "GetTextContent { id: ObjectId }", + "GetHatchBoundary { id: ObjectId }", + "GetDimensionMeasurement { id: ObjectId }", + "GetAttributes { id: ObjectId }", + "GetBlockEntities { block_name: String }", + "GetViewportView { id: ObjectId }" ] } }, diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json index 4f78f25a3..4a9edb9d5 100644 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ b/crates/ocs_doc_api/src/gen/layouts.json @@ -12,7 +12,18 @@ "Delete { id: ObjectId }", "CreateMany(Vec)", "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)" + "DeleteMany(Vec)", + "CreateInsert(InsertSpec)", + "CreateViewport(ViewportSpec)", + "CreateText(TextSpec)", + "CreateMText(MTextSpec)", + "SetTextContent { id: ObjectId, value: String }", + "CreateHatch(HatchSpec)", + "CreateDimensionLinear(DimensionSpec)", + "SetAttribute { id: ObjectId, tag: String, value: String }", + "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", + "CreateRasterImage(RasterImageSpec)", + "Loft { profiles: Vec }" ] }, "Query": { @@ -22,7 +33,13 @@ "GetCentroid { id: ObjectId }", "GetVolume { id: ObjectId }", "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision" + "GetGeometryRevision", + "GetTextContent { id: ObjectId }", + "GetHatchBoundary { id: ObjectId }", + "GetDimensionMeasurement { id: ObjectId }", + "GetAttributes { id: ObjectId }", + "GetBlockEntities { block_name: String }", + "GetViewportView { id: ObjectId }" ] } } diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs index 45a2c205a..5521d1578 100644 --- a/crates/ocs_doc_api/src/lib.rs +++ b/crates/ocs_doc_api/src/lib.rs @@ -46,8 +46,9 @@ pub use revision::GeometryRevision; pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt}; pub use facade::{ - CurveCollection, DocApi, Document, Entity, EntityCollection, HasId, Line, OpGroup, Point, - Polyline, QueryBatch, QueryResults, Solid, SolidCollection, + ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, + EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, + Solid, SolidCollection, Spline, Text, XLine, }; pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive}; pub use query::{Aabb, EntityView, Query, QueryResult}; diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index e041f89b0..a1f47bf00 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -1,7 +1,8 @@ //! Write operations — the typed, transport-agnostic vocabulary for changing the -//! document (plan §5). The `Operation` enum itself is GENERATED into -//! `crate::gen` by `build.rs` from `spec/entities.toml`; this module re-exports -//! it and holds the hand-written payload mirrors. +//! 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. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 4b9f82076..b65702149 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -1,6 +1,8 @@ //! Read queries — typed, transport-agnostic reads of entity state (plan §5). -//! The `Query` enum is GENERATED into `crate::gen` by `build.rs`; this module -//! re-exports it and holds the hand-written result DTOs. +//! 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 +//! hand-written result DTOs. use serde::{Deserialize, Serialize}; From 2522979285518faac4e3ae14418fc86511e53a3e Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:35:23 +0200 Subject: [PATCH 19/31] ocs_doc_api: enrich API reference generator api_reference.md now includes (regenerated from spec + layouts.json): - Wire vocabulary section: every Operation (22) + Query (12) variant with payloads. - Errors section: ApiError variant table. - Envelope & transport section: DocApiEnvelope framing, Receipt, transports. - Method/constructor lines now show fixed fields (e.g. SolidBoolean erase_sources + op value) and clean zero-arg () rendering. Generator: fmt_fixed/fmt_call_args helpers; reads layouts.json for the wire vocab. 612 host lib + 8 crate + 3 consistency green. --- crates/ocs_doc_api/build.rs | 93 +++++++++++- crates/ocs_doc_api/src/gen/api_reference.md | 152 ++++++++++++++------ 2 files changed, 198 insertions(+), 47 deletions(-) diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index d76626f5e..018fe3806 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -141,10 +141,34 @@ 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() }; + if tbl.is_empty() { + return String::new(); + } + let parts: Vec = tbl.iter().map(|(k, v)| format!("{k}={v}")).collect(); + format!("; fixed {}", parts.join(", ")) +} + fn fmt_args(args: &[Arg]) -> String { + // Renders `(...)` with the args, or `()` for a zero-arg method. + if args.is_empty() { + return String::new(); + } args.iter().map(|a| format!("{}: {}", a.name, a.ty)).collect::>().join(", ") } +/// The parenthesised argument list: `(a: T, b: U)` or `()` for zero args. +fn fmt_call_args(args: &[Arg]) -> String { + if args.is_empty() { + return "()".to_string(); + } + format!("({})", fmt_args(args)) +} + /// The generic handle methods + cross-cutting surface (applies to every handle; /// documented once here rather than per-family). fn generic_reference_md() -> String { @@ -231,10 +255,12 @@ fn api_reference_md(spec: &Spec) -> String { for c in &fam.constructor { let ret = c.returns.as_deref().unwrap_or(handle); let bulk = if c.bulk { " *(bulk op)*" } else { "" }; + let op = c.op.as_deref().unwrap_or("-"); + let fixed = fmt_fixed(c.fixed.as_ref()); s.push_str(&format!( - "- **{}**(`{}`) -> `{ret}`{bulk}{}\n", + "- **{}**{} -> `{ret}`{bulk} — `{op}`{fixed}{}\n", c.name, - fmt_args(&c.args), + fmt_call_args(&c.args), status_suffix(c.status.as_deref()) )); } @@ -245,10 +271,11 @@ fn api_reference_md(spec: &Spec) -> String { s.push_str("### Methods\n\n"); for m in &fam.method { let target = m.op.as_deref().or(m.query.as_deref()).unwrap_or("-"); + let fixed = fmt_fixed(m.fixed.as_ref()); s.push_str(&format!( - "- **{}**(`{}`) -> `{}` — {} `{}`{}\n", + "- **{}**{} -> `{}` — {} `{}`{fixed}{}\n", m.name, - fmt_args(&m.args), + fmt_call_args(&m.args), m.returns.as_deref().unwrap_or("()"), m.kind, target, @@ -265,9 +292,67 @@ fn api_reference_md(spec: &Spec) -> String { for k in &spec.kernel_map { s.push_str(&format!("| {} | `{}` | {} |\n", k.op, k.kernel, k.returns)); } + s.push('\n'); + s.push_str(&wire_vocabulary_md()); + s.push('\n'); + s.push_str(&error_and_envelope_md()); s } +/// The wire vocabulary section: every `Operation`/`Query` variant with its payload, +/// read from `src/gen/layouts.json` (the curated wire-layout snapshot). +fn wire_vocabulary_md() -> String { + let layouts = 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!({}), + }; + let mut s = String::from("## Wire vocabulary\n\nEvery `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch.\n\n"); + s.push_str("### `Operation`\n\n"); + if let Some(variants) = layouts.get("Operation").and_then(|o| o.get("enum")).and_then(|v| v.as_array()) { + for v in variants { + if let Some(name) = v.as_str() { + s.push_str(&format!("- `{name}`\n")); + } + } + } + s.push_str("\n### `Query`\n\n"); + if let Some(variants) = layouts.get("Query").and_then(|o| o.get("enum")).and_then(|v| v.as_array()) { + for v in variants { + if let Some(name) = v.as_str() { + s.push_str(&format!("- `{name}`\n")); + } + } + } + s +} + +/// The error model + envelope/transport section. +fn error_and_envelope_md() -> String { + r#"## Errors (`ApiError`) + +| Variant | Meaning | +|---|---| +| `Validation { op, reason }` | Input rejected **before** any 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 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 +executor produced. + +## Envelope & transport + +`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or +`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's +envelope version (bridges must check it). Transports: `InProcess` (host) and +`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: +Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries +`outcome` (per-op result), `query_results`, and `new_revision`. +"# + .to_string() +} + /// The merged, self-contained binding handover schema (§10.2). Object model + /// method signatures + op/query mapping + traced wire layouts inlined per node. /// The handle types for the binding schema, derived from the spec's families diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 6adefe030..ef6245751 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -10,83 +10,83 @@ B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ### Constructors (`doc.solids()`) -- **create_cuboid**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` -- **create_sphere**(`centre: [f64; 3], radius: f64`) -> `Solid` -- **create_cylinder**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid` -- **create_cone**(`base: [f64; 3], radius: f64, height: f64`) -> `Solid` -- **create_torus**(`centre: [f64; 3], major_radius: f64, minor_radius: f64`) -> `Solid` -- **create_wedge**(`origin: [f64; 3], size: [f64; 3]`) -> `Solid` -- **extrude**(`profile: EntityRef, direction: [f64; 3]`) -> `Solid` -- **revolve**(`profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64`) -> `Solid` -- **loft**(`profiles: &[EntityRef]`) -> `Solid` +- **create_cuboid**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Cuboid" +- **create_sphere**(centre: [f64; 3], radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Sphere" +- **create_cylinder**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cylinder" +- **create_cone**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cone" +- **create_torus**(centre: [f64; 3], major_radius: f64, minor_radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Torus" +- **create_wedge**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Wedge" +- **extrude**(profile: EntityRef, direction: [f64; 3]) -> `Solid` — `Extrude` +- **revolve**(profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64) -> `Solid` — `Revolve` +- **loft**(profiles: &[EntityRef]) -> `Solid` — `Loft` ### Methods -- **intersect**(`other: Solid`) -> `Solid` — op `SolidBoolean` -- **union**(`other: Solid`) -> `Solid` — op `SolidBoolean` -- **subtract**(`other: Solid`) -> `Solid` — op `SolidBoolean` -- **volume**(``) -> `f64` — query `GetVolume` -- **centroid**(``) -> `[f64; 3]` — query `GetCentroid` -- **intersects**(`other: Solid`) -> `bool` — query `GetIntersects` +- **intersect**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Intersection" +- **union**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Union" +- **subtract**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Difference" +- **volume**() -> `f64` — query `GetVolume` +- **centroid**() -> `[f64; 3]` — query `GetCentroid` +- **intersects**(other: Solid) -> `bool` — query `GetIntersects` ## `Line` (acadrust `Line`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_line**(`start: [f64; 3], end: [f64; 3]`) -> `Line` +- **create_line**(start: [f64; 3], end: [f64; 3]) -> `Line` — `CreateCurve`; fixed curve="Line" ## `Circle` (acadrust `Circle`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_circle**(`centre: [f64; 3], radius: f64`) -> `Circle` +- **create_circle**(centre: [f64; 3], radius: f64) -> `Circle` — `CreateCurve`; fixed curve="Circle" ## `Polyline` (acadrust `LwPolyline`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_polyline**(`points: &[[f64; 3]], closed: bool`) -> `Polyline` +- **create_polyline**(points: &[[f64; 3]], closed: bool) -> `Polyline` — `CreateCurve`; fixed curve="Polyline" ### Methods -- **add_vertex**(`at: usize, point: [f64; 3]`) -> `()` — op `AddVertex` +- **add_vertex**(at: usize, point: [f64; 3]) -> `()` — op `AddVertex` ## `Point` (acadrust `Point`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_point**(`position: [f64; 3]`) -> `Point` -- **create_points**(`positions: &[[f64; 3]]`) -> `Vec` *(bulk op)* +- **create_point**(position: [f64; 3]) -> `Point` — `CreateCurve`; fixed curve="Point" +- **create_points**(positions: &[[f64; 3]]) -> `Vec` *(bulk op)* — `CreateMany`; fixed curve="Point" ## `ArcCurve` (acadrust `Arc`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_arc**(`centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64`) -> `ArcCurve` +- **create_arc**(centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64) -> `ArcCurve` — `CreateCurve`; fixed curve="Arc" ## `Ellipse` (acadrust `Ellipse`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_ellipse**(`centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64`) -> `Ellipse` +- **create_ellipse**(centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64) -> `Ellipse` — `CreateCurve`; fixed curve="Ellipse" ## `Spline` (acadrust `Spline`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_spline**(`degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]`) -> `Spline` +- **create_spline**(degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]) -> `Spline` — `CreateCurve`; fixed curve="Spline" ## `Ray` (acadrust `Ray`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_ray**(`origin: [f64; 3], direction: [f64; 3]`) -> `Ray` +- **create_ray**(origin: [f64; 3], direction: [f64; 3]) -> `Ray` — `CreateCurve`; fixed curve="Ray" ## `XLine` (acadrust `XLine`, collection `curves`) ### Constructors (`doc.curves()`) -- **create_xline**(`origin: [f64; 3], direction: [f64; 3]`) -> `XLine` +- **create_xline**(origin: [f64; 3], direction: [f64; 3]) -> `XLine` — `CreateCurve`; fixed curve="XLine" ## `Text` (acadrust `Text`, collection `curves`) @@ -94,12 +94,12 @@ Single-line text annotation. content()/set_content() read+replace the value; tra ### Constructors (`doc.curves()`) -- **create_text**(`value: &str, insertion_point: [f64; 3], height: f64, rotation: f64`) -> `Text` +- **create_text**(value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Text` — `CreateText` ### Methods -- **content**(``) -> `String` — query `GetTextContent` -- **set_content**(`value: &str`) -> `()` — op `SetTextContent` +- **content**() -> `String` — query `GetTextContent` +- **set_content**(value: &str) -> `()` — op `SetTextContent` ## `MText` (acadrust `MText`, collection `curves`) @@ -107,12 +107,12 @@ Multi-line text annotation. content()/set_content() read+replace the value; tran ### Constructors (`doc.curves()`) -- **create_mtext**(`value: &str, insertion_point: [f64; 3], height: f64`) -> `MText` +- **create_mtext**(value: &str, insertion_point: [f64; 3], height: f64) -> `MText` — `CreateMText` ### Methods -- **content**(``) -> `String` — query `GetTextContent` -- **set_content**(`value: &str`) -> `()` — op `SetTextContent` +- **content**() -> `String` — query `GetTextContent` +- **set_content**(value: &str) -> `()` — op `SetTextContent` ## `Entity` (acadrust `Hatch`, collection `curves`) @@ -120,11 +120,11 @@ Filled region over a closed polyline boundary. boundary() returns the loops; bou ### Constructors (`doc.curves()`) -- **create_hatch**(`boundary: &[[f64; 2]], solid: bool`) -> `Entity` +- **create_hatch**(boundary: &[[f64; 2]], solid: bool) -> `Entity` — `CreateHatch` ### Methods -- **boundary**(``) -> `Vec>` — query `GetHatchBoundary` +- **boundary**() -> `Vec>` — query `GetHatchBoundary` ## `Dimension` (acadrust `Dimension`, collection `curves`) @@ -132,11 +132,11 @@ Linear dimension between two measured points. measurement() reads the distance; ### Constructors (`doc.curves()`) -- **create_dimension_linear**(`first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]`) -> `Dimension` +- **create_dimension_linear**(first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]) -> `Dimension` — `CreateDimensionLinear` ### Methods -- **measurement**(``) -> `f64` — query `GetDimensionMeasurement` +- **measurement**() -> `f64` — query `GetDimensionMeasurement` ## `Entity` (acadrust `Insert`, collection `entities`) @@ -144,12 +144,12 @@ Block reference. Place an existing BlockRecord by name; transform moves its inse ### Constructors (`doc.entities()`) -- **create_insert**(`block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64`) -> `Entity` +- **create_insert**(block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> `Entity` — `CreateInsert` ### Methods -- **attributes**(``) -> `Vec<(String, String)>` — query `GetAttributes` -- **set_attribute**(`tag: &str, value: &str`) -> `()` — op `SetAttribute` +- **attributes**() -> `Vec<(String, String)>` — query `GetAttributes` +- **set_attribute**(tag: &str, value: &str) -> `()` — op `SetAttribute` ## `Entity` (acadrust `Viewport`, collection `entities`) @@ -157,12 +157,12 @@ Paper-space viewport: a width×height window at center looking at view_target wi ### Constructors (`doc.entities()`) -- **create_viewport**(`center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64`) -> `Entity` +- **create_viewport**(center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64) -> `Entity` — `CreateViewport` ### Methods -- **viewport_view**(``) -> `([f64; 3], f64)` — query `GetViewportView` -- **set_view**(`view_target: [f64; 3], view_height: f64`) -> `()` — op `SetViewportView` +- **viewport_view**() -> `([f64; 3], f64)` — query `GetViewportView` +- **set_view**(view_target: [f64; 3], view_height: f64) -> `()` — op `SetViewportView` ## `Entity` (acadrust `RasterImage`, collection `entities`) @@ -170,7 +170,7 @@ Raster image: create_raster_image(file_path, insertion, u, v, size) places an im ### Constructors (`doc.entities()`) -- **create_raster_image**(`file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]`) -> `Entity` +- **create_raster_image**(file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]) -> `Entity` — `CreateRasterImage` ## `Entity` (acadrust `Table`, collection `entities`) @@ -233,3 +233,69 @@ modeling-scale work. | Transform | `cadkernel::brep::transform` | Option | | GetBounds | `cadkernel::brep::body_bounds` | Option | | GetVolume | `cadkernel::brep::mesh_body + divergence` | computed | + +## Wire vocabulary + +Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch. + +### `Operation` + +- `CreateSolid(SolidPrimitive)` +- `CreateCurve(Curve2Spec)` +- `Extrude { profile: ObjectId, direction: [f64; 3] }` +- `Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }` +- `Transform { id: ObjectId, placement: PlacementSpec }` +- `SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }` +- `AddVertex { id: ObjectId, at: usize, point: [f64; 3] }` +- `Delete { id: ObjectId }` +- `CreateMany(Vec)` +- `TransformMany { ids: Vec, placement: PlacementSpec }` +- `DeleteMany(Vec)` +- `CreateInsert(InsertSpec)` +- `CreateViewport(ViewportSpec)` +- `CreateText(TextSpec)` +- `CreateMText(MTextSpec)` +- `SetTextContent { id: ObjectId, value: String }` +- `CreateHatch(HatchSpec)` +- `CreateDimensionLinear(DimensionSpec)` +- `SetAttribute { id: ObjectId, tag: String, value: String }` +- `SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }` +- `CreateRasterImage(RasterImageSpec)` +- `Loft { profiles: Vec }` + +### `Query` + +- `GetEntity { id: ObjectId }` +- `GetBounds { id: ObjectId }` +- `GetCentroid { id: ObjectId }` +- `GetVolume { id: ObjectId }` +- `GetIntersects { a: ObjectId, b: ObjectId }` +- `GetGeometryRevision` +- `GetTextContent { id: ObjectId }` +- `GetHatchBoundary { id: ObjectId }` +- `GetDimensionMeasurement { id: ObjectId }` +- `GetAttributes { id: ObjectId }` +- `GetBlockEntities { block_name: String }` +- `GetViewportView { id: ObjectId }` + +## Errors (`ApiError`) + +| Variant | Meaning | +|---|---| +| `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 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 +executor produced. + +## Envelope & transport + +`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or +`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's +envelope version (bridges must check it). Transports: `InProcess` (host) and +`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: +Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries +`outcome` (per-op result), `query_results`, and `new_revision`. From 8d8c1208806cede2b7b87d285c9903aa950206bf Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:31:23 +0200 Subject: [PATCH 20/31] =?UTF-8?q?ocs=5Fdoc=5Fapi:=20Phase=202c-ii=20?= =?UTF-8?q?=E2=80=94=20dimension=20sub-types=20(radius/diameter/angular)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateDimensionRadius/CreateDimensionDiameter (DimensionRadialSpec) + CreateDimensionAngular (DimensionAngularSpec) ops (append-only). - backend add_dimension_radius/diameter (DimensionRadius/DimensionDiameter::new) + add_dimension_angular (DimensionAngular3Pt::new computes angle); dimension_measurement returns degrees for angular sub-types. - facade create_dimension_radius/diameter/angular; spec constructors; api_reference + binding_schema regenerated; mocks + baseline. - Test phase2cii_dimension_radius_diameter_angular -> 613 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 7 +- crates/ocs_doc_api/examples/common/mod.rs | 14 ++++ crates/ocs_doc_api/spec/entities.toml | 15 +++++ crates/ocs_doc_api/src/backend.rs | 9 +++ crates/ocs_doc_api/src/executor.rs | 18 +++++ crates/ocs_doc_api/src/facade.rs | 26 +++++++ crates/ocs_doc_api/src/gen/api_reference.md | 3 + crates/ocs_doc_api/src/gen/ops_gen.rs | 6 +- crates/ocs_doc_api/src/ops.rs | 23 +++++++ crates/ocs_doc_api/tests/mock_backend.rs | 14 ++++ .../tests/spec_wire_consistency.rs | 6 ++ src/app/doc_api.rs | 67 +++++++++++++++++++ 12 files changed, 205 insertions(+), 3 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index c9fe0a5eb..a1af7e731 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -70,8 +70,11 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal 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. Radius/diameter/ - angular dimension sub-types are a later refinement. + (`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. (`Angular2Ln` variant handled in + `measurement`; a dedicated constructor is a later refinement.) - **Unblocked:** `add_vertex` (polyline) and sweep profiles (Line/Circle/Arc/LwPolyline) are supported (Phase 0). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index bb47211c0..c6fa42fe8 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -183,6 +183,20 @@ 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 { + let id = self.alloc("Dimension"); + let d = ((spec.center[0] - spec.point[0]).powi(2) + (spec.center[1] - spec.point[1]).powi(2) + (spec.center[2] - spec.point[2]).powi(2)).sqrt(); + self.dimension_measurements.insert(id, d); + Ok(id) + } + fn add_dimension_diameter(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult { + self.add_dimension_radius(spec) + } + fn add_dimension_angular(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult { + let id = self.alloc("Dimension"); + self.dimension_measurements.insert(id, 90.0); + Ok(id) + } fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 16f1ec48f..969a3893e 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -331,6 +331,21 @@ name = "create_dimension_linear" args = [{ name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "definition_point", ty = "[f64; 3]" }] op = "CreateDimensionLinear" +[[family.constructor]] +name = "create_dimension_radius" +args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] +op = "CreateDimensionRadius" + +[[family.constructor]] +name = "create_dimension_diameter" +args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] +op = "CreateDimensionDiameter" + +[[family.constructor]] +name = "create_dimension_angular" +args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] +op = "CreateDimensionAngular" + [[family.method]] name = "measurement" kind = "query" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 47991ec74..3f1af4237 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -63,6 +63,15 @@ pub trait DocApiBackend { /// Add a linear `DIMENSION`; returns the fresh `ObjectId`. fn add_dimension_linear(&mut self, spec: &crate::ops::DimensionSpec) -> ApiResult; + /// Add a radial `DIMENSION`; returns the fresh `ObjectId`. + fn add_dimension_radius(&mut self, spec: &crate::ops::DimensionRadialSpec) -> ApiResult; + + /// Add a diameter `DIMENSION`; returns the fresh `ObjectId`. + fn add_dimension_diameter(&mut self, spec: &crate::ops::DimensionRadialSpec) -> ApiResult; + + /// Add a 3-point angular `DIMENSION`; returns the fresh `ObjectId`. + fn add_dimension_angular(&mut self, spec: &crate::ops::DimensionAngularSpec) -> ApiResult; + /// A dimension's measured value (distance for linear/radius, degrees for angular). fn dimension_measurement(&self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 7af52512a..ee22506d8 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -269,6 +269,24 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_dimension_radius(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::CreateDimensionDiameter(spec) => { + b.push_undo(name); + let id = b.add_dimension_diameter(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::CreateDimensionAngular(spec) => { + b.push_undo(name); + let id = b.add_dimension_angular(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 43cb6d75a..d0874dac9 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -539,6 +539,32 @@ impl CurveCollection { Ok(Entity::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 { + 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 { + self.create_dim_radial(Operation::CreateDimensionDiameter, center, point) + } + fn create_dim_radial( + &self, + op: impl Fn(crate::ops::DimensionRadialSpec) -> Operation, + center: [f64; 3], + point: [f64; 3], + ) -> ApiResult { + 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 { + 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 { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index ef6245751..4fad81f5f 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -133,6 +133,9 @@ Linear dimension between two measured points. measurement() reads the distance; ### Constructors (`doc.curves()`) - **create_dimension_linear**(first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]) -> `Dimension` — `CreateDimensionLinear` +- **create_dimension_radius**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionRadius` +- **create_dimension_diameter**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionDiameter` +- **create_dimension_angular**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular` ### Methods diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 29a2a5792..d9dcd5e51 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TextSpec, ViewportSpec}; +use crate::ops::{BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TextSpec, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -48,4 +48,8 @@ pub enum Operation { CreateRasterImage(RasterImageSpec), // multi-profile solid Loft { profiles: Vec }, + // dimension sub-types + CreateDimensionRadius(DimensionRadialSpec), + CreateDimensionDiameter(DimensionRadialSpec), + CreateDimensionAngular(DimensionAngularSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index a1f47bf00..69fb5f900 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -116,6 +116,26 @@ pub struct DimensionSpec { pub definition_point: [f64; 3], } +/// Construction spec for a radial/diameter `DIMENSION`: the circle center and a +/// point on the circle (radius) or a chord point (diameter). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DimensionRadialSpec { + /// Center of the arc/circle (radius) or first chord point (diameter). + pub center: [f64; 3], + /// A point on the arc/circle (radius) or the far chord point (diameter). + pub point: [f64; 3], +} + +/// Construction spec for a 3-point angular `DIMENSION`: vertex + one point on +/// each leg, plus the dimension-arc location. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DimensionAngularSpec { + pub vertex: [f64; 3], + pub first_point: [f64; 3], + pub second_point: [f64; 3], + pub arc_location: [f64; 3], +} + /// Construction spec for a `RASTER_IMAGE`: an image file placed at /// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and /// pixel `size` (width, height). The host auto-registers the ImageDefinition. @@ -187,6 +207,9 @@ impl crate::gen::Operation { SetViewportView { .. } => "SetViewportView", CreateRasterImage(_) => "CreateRasterImage", Loft { .. } => "Loft", + CreateDimensionRadius(_) => "CreateDimensionRadius", + CreateDimensionDiameter(_) => "CreateDimensionDiameter", + CreateDimensionAngular(_) => "CreateDimensionAngular", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 685a44103..f83592fee 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -123,6 +123,20 @@ 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 { + let id = self.alloc("Dimension"); + let d = ((spec.center[0] - spec.point[0]).powi(2) + (spec.center[1] - spec.point[1]).powi(2) + (spec.center[2] - spec.point[2]).powi(2)).sqrt(); + self.dimension_measurements.insert(id, d); + Ok(id) + } + fn add_dimension_diameter(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult { + self.add_dimension_radius(spec) + } + fn add_dimension_angular(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult { + let id = self.alloc("Dimension"); + self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees + Ok(id) + } fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 94303ab7e..0d9418f43 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -64,6 +64,9 @@ fn operation_variants() -> Vec { 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] }), ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -153,6 +156,9 @@ const OPERATION_BASELINE: &[&str] = &[ "SetViewportView", "CreateRasterImage", "Loft", + "CreateDimensionRadius", + "CreateDimensionDiameter", + "CreateDimensionAngular", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 13c3ec1d4..186b2f6ba 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -248,6 +248,34 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_dimension_radius(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult { + use acadrust::entities::{Dimension, DimensionRadius}; + use acadrust::types::Vector3; + let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); + let dim = DimensionRadius::new(v3(spec.center), v3(spec.point)); + let handle = self.scene_mut().add_entity(EntityType::Dimension(Dimension::Radius(dim))); + Ok(handle_to_obj(handle)) + } + + fn add_dimension_diameter(&mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec) -> ApiResult { + use acadrust::entities::{Dimension, DimensionDiameter}; + use acadrust::types::Vector3; + let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); + let dim = DimensionDiameter::new(v3(spec.center), v3(spec.point)); + let handle = self.scene_mut().add_entity(EntityType::Dimension(Dimension::Diameter(dim))); + Ok(handle_to_obj(handle)) + } + + fn add_dimension_angular(&mut self, spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult { + use acadrust::entities::{Dimension, DimensionAngular3Pt}; + use acadrust::types::Vector3; + let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); + let mut dim = DimensionAngular3Pt::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point)); + dim.definition_point = v3(spec.arc_location); + let handle = self.scene_mut().add_entity(EntityType::Dimension(Dimension::Angular3Pt(dim))); + Ok(handle_to_obj(handle)) + } + fn dimension_measurement(&self, id: ObjectId) -> ApiResult { let entity = self.document().get_entity(obj_to_handle(id)).ok_or(ApiError::UnknownId(id))?; let EntityType::Dimension(d) = entity else { @@ -258,6 +286,8 @@ impl DocApiBackend for HostSession<'_> { acadrust::entities::Dimension::Linear(x) => x.measurement(), acadrust::entities::Dimension::Radius(x) => x.measurement(), acadrust::entities::Dimension::Diameter(x) => x.measurement(), + acadrust::entities::Dimension::Angular3Pt(x) => x.measurement_degrees(), + acadrust::entities::Dimension::Angular2Ln(x) => x.measurement_degrees(), _ => return Err(ApiError::Unsupported("dimension measurement for this sub-type".into())), }) } @@ -1081,6 +1111,43 @@ mod tests { } } + // ── Phase 2c-ii: dimension sub-types (radius/diameter/angular) ────────── + + #[test] + fn phase2cii_dimension_radius_diameter_angular() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Radius: center (0,0,0), point on circle (4,0,0) -> radius 4. + let rad = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateDimensionRadius( + ocs_doc_api::ops::DimensionRadialSpec { center: [0.0; 3], point: [4.0, 0.0, 0.0] }))).unwrap()); + // Diameter: chord points (0,0,0) and (6,0,0) -> diameter 6. + let dia = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateDimensionDiameter( + ocs_doc_api::ops::DimensionRadialSpec { center: [0.0; 3], point: [6.0, 0.0, 0.0] }))).unwrap()); + // Angular: vertex (0,0,0), leg points (10,0,0) and (0,10,0) -> 90 degrees. + let ang = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateDimensionAngular( + ocs_doc_api::ops::DimensionAngularSpec { + vertex: [0.0; 3], first_point: [10.0, 0.0, 0.0], second_point: [0.0, 10.0, 0.0], arc_location: [5.0, 5.0, 0.0], + }))).unwrap()); + + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetDimensionMeasurement { id: rad }, + Query::GetDimensionMeasurement { id: dia }, + Query::GetDimensionMeasurement { id: ang }, + ])).unwrap(); + match &receipt.query_results[0] { + QueryResult::DimensionMeasurement(v) => assert!((*v - 4.0).abs() < 1e-4, "radius {v}"), + other => panic!("expected measurement, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::DimensionMeasurement(v) => assert!((*v - 6.0).abs() < 1e-4, "diameter {v}"), + other => panic!("expected measurement, got {other:?}"), + } + match &receipt.query_results[2] { + QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"), + other => panic!("expected measurement, got {other:?}"), + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test] From f8ae54a351175162041b266ab11f27dcb9a76d73 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:43:34 +0200 Subject: [PATCH 21/31] =?UTF-8?q?ocs=5Fdoc=5Fapi:=20Phase=203-ii=20?= =?UTF-8?q?=E2=80=94=20typed=20AttributeDefinition=20create=20(Phase=203?= =?UTF-8?q?=20complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateAttributeDefinition(AttributeDefinitionSpec) op (append-only). - backend add_attribute_definition (ATTDEF tag/prompt/default/insertion/height/rotation). - facade create_attribute_definition; entity_kind_name AttributeDefinition/AttributeEntity. - spec AttributeDefinition family; api_reference + binding_schema regenerated; mocks + baseline. - Test phase3ii_create_attribute_definition -> 614 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 5 ++- crates/ocs_doc_api/examples/common/mod.rs | 6 +++ crates/ocs_doc_api/spec/entities.toml | 15 ++++++++ crates/ocs_doc_api/src/backend.rs | 3 ++ crates/ocs_doc_api/src/executor.rs | 6 +++ crates/ocs_doc_api/src/facade.rs | 14 +++++++ crates/ocs_doc_api/src/gen/api_reference.md | 8 ++++ .../ocs_doc_api/src/gen/binding_schema.json | 7 ++++ crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/ops.rs | 13 +++++++ crates/ocs_doc_api/tests/mock_backend.rs | 6 +++ .../tests/spec_wire_consistency.rs | 4 ++ src/app/doc_api.rs | 38 +++++++++++++++++++ src/app/doc_api_convert.rs | 2 + 14 files changed, 128 insertions(+), 3 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index a1af7e731..511b269c8 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -87,8 +87,9 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal (`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. -- **Remaining:** typed `AttributeDefinition` (in-block attribute-definition) - create. +- **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)` → diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index c6fa42fe8..97a8c51e8 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -200,6 +200,12 @@ impl DocApiBackend for MockBackend { fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } + fn add_attribute_definition(&mut self, spec: &ocs_doc_api::ops::AttributeDefinitionSpec) -> ApiResult { + if spec.tag.is_empty() { + return Err(ApiError::validation("CreateAttributeDefinition", "empty tag")); + } + Ok(self.alloc("AttributeDefinition")) + } fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index 969a3893e..ba7ad316c 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -380,6 +380,21 @@ op = "SetAttribute" args = [{ name = "tag", ty = "&str" }, { name = "value", ty = "&str" }] returns = "()" +# ============================================================================ +# ATTRIBUTE-DEFINITION FAMILY (phase 3-ii) — in-block attribute definitions +# ============================================================================ +[[family]] +name = "AttributeDefinition" +acadrust_variant = "AttributeDefinition" +handle = "Entity" +collection = "entities" +description = "In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point." + +[[family.constructor]] +name = "create_attribute_definition" +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" }] +op = "CreateAttributeDefinition" + # ============================================================================ # PAPER-SPACE FAMILY (phase 4) — viewports # ============================================================================ diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 3f1af4237..6432ed2da 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -72,6 +72,9 @@ pub trait DocApiBackend { /// Add a 3-point angular `DIMENSION`; returns the fresh `ObjectId`. fn add_dimension_angular(&mut self, spec: &crate::ops::DimensionAngularSpec) -> ApiResult; + /// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`. + fn add_attribute_definition(&mut self, spec: &crate::ops::AttributeDefinitionSpec) -> ApiResult; + /// A dimension's measured value (distance for linear/radius, degrees for angular). fn dimension_measurement(&self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index ee22506d8..c2c3b7f08 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -287,6 +287,12 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_attribute_definition(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index d0874dac9..4cb8e40d9 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -631,6 +631,20 @@ impl EntityCollection { 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 { + 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 { let receipt = self.session.apply_op(Operation::CreateInsert(crate::ops::InsertSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 4fad81f5f..57fadbf00 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -154,6 +154,14 @@ Block reference. Place an existing BlockRecord by name; transform moves its inse - **attributes**() -> `Vec<(String, String)>` — query `GetAttributes` - **set_attribute**(tag: &str, value: &str) -> `()` — op `SetAttribute` +## `Entity` (acadrust `AttributeDefinition`, collection `entities`) + +In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point. + +### Constructors (`doc.entities()`) + +- **create_attribute_definition**(tag: &str, prompt: &str, default_value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Entity` — `CreateAttributeDefinition` + ## `Entity` (acadrust `Viewport`, collection `entities`) Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 69a2d72a2..ed300c3d8 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -335,6 +335,13 @@ ], "name": "Insert" }, + { + "acadrust_variant": "AttributeDefinition", + "collection": "entities", + "handle": "Entity", + "methods": [], + "name": "AttributeDefinition" + }, { "acadrust_variant": "Viewport", "collection": "entities", diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index d9dcd5e51..d88a3cf3f 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TextSpec, ViewportSpec}; +use crate::ops::{AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, TextSpec, ViewportSpec}; /// A typed write operation (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -52,4 +52,6 @@ pub enum Operation { CreateDimensionRadius(DimensionRadialSpec), CreateDimensionDiameter(DimensionRadialSpec), CreateDimensionAngular(DimensionAngularSpec), + // attribute definitions + CreateAttributeDefinition(AttributeDefinitionSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 69fb5f900..0fe193af2 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -136,6 +136,18 @@ pub struct DimensionAngularSpec { pub arc_location: [f64; 3], } +/// Construction spec for an `ATTDEF` (attribute definition): the tag, prompt, +/// and default value for an in-block attribute, at an insertion point. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AttributeDefinitionSpec { + pub tag: String, + pub prompt: String, + pub default_value: String, + pub insertion_point: [f64; 3], + pub height: f64, + pub rotation: f64, +} + /// Construction spec for a `RASTER_IMAGE`: an image file placed at /// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and /// pixel `size` (width, height). The host auto-registers the ImageDefinition. @@ -210,6 +222,7 @@ impl crate::gen::Operation { CreateDimensionRadius(_) => "CreateDimensionRadius", CreateDimensionDiameter(_) => "CreateDimensionDiameter", CreateDimensionAngular(_) => "CreateDimensionAngular", + CreateAttributeDefinition(_) => "CreateAttributeDefinition", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index f83592fee..1e8c46688 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -140,6 +140,12 @@ impl DocApiBackend for MockBackend { fn dimension_measurement(&self, id: ObjectId) -> ApiResult { self.dimension_measurements.get(&id).copied().ok_or(ApiError::UnknownId(id)) } + fn add_attribute_definition(&mut self, spec: &ocs_doc_api::ops::AttributeDefinitionSpec) -> ApiResult { + if spec.tag.is_empty() { + return Err(ApiError::validation("CreateAttributeDefinition", "empty tag")); + } + Ok(self.alloc("AttributeDefinition")) + } fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 0d9418f43..0486d3ada 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -67,6 +67,9 @@ fn operation_variants() -> 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::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, + }), ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -159,6 +162,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateDimensionRadius", "CreateDimensionDiameter", "CreateDimensionAngular", + "CreateAttributeDefinition", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 186b2f6ba..e771bc2eb 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -292,6 +292,20 @@ impl DocApiBackend for HostSession<'_> { }) } + fn add_attribute_definition(&mut self, spec: &ocs_doc_api::ops::AttributeDefinitionSpec) -> ApiResult { + use acadrust::entities::AttributeDefinition; + use acadrust::types::Vector3; + let mut att = AttributeDefinition::default(); + att.tag = spec.tag.clone(); + att.prompt = spec.prompt.clone(); + att.default_value = spec.default_value.clone(); + att.insertion_point = Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]); + att.height = spec.height; + att.rotation = spec.rotation; + let handle = self.scene_mut().add_entity(EntityType::AttributeDefinition(att)); + Ok(handle_to_obj(handle)) + } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { let handle = obj_to_handle(id); let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; @@ -1148,6 +1162,30 @@ mod tests { } } + // ── Phase 3-ii: typed AttributeDefinition create ────────────────────────── + + #[test] + fn phase3ii_create_attribute_definition() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let attdef = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateAttributeDefinition( + ocs_doc_api::ops::AttributeDefinitionSpec { + tag: "DOOR_NO".into(), prompt: "Door number?".into(), default_value: "D-1".into(), + insertion_point: [5.0, 5.0, 0.0], height: 2.5, rotation: 0.0, + }))).unwrap()); + let handle = obj_to_handle(attdef); + let Some(EntityType::AttributeDefinition(a)) = host.document().get_entity(handle) else { panic!("attdef not found") }; + assert_eq!(a.tag, "DOOR_NO"); + assert_eq!(a.default_value, "D-1"); + assert!((a.height - 2.5).abs() < 1e-9); + // GetEntity reports the AttributeDefinition kind. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetEntity { id: attdef }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(e) => assert_eq!(e.kind, "AttributeDefinition"), + other => panic!("expected entity, got {other:?}"), + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index bdbcb78a2..17be1df27 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -318,6 +318,8 @@ pub fn entity_kind_name(entity: &EntityType) -> &'static str { EntityType::Viewport(_) => "Viewport", EntityType::Text(_) => "Text", EntityType::MText(_) => "MText", + EntityType::AttributeDefinition(_) => "AttributeDefinition", + EntityType::AttributeEntity(_) => "AttributeEntity", // Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind. EntityType::RasterImage(_) => "RasterImage", EntityType::Table(_) => "Table", From 240831edcd017eb20f51d870ae30dc5d1b2db496 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:02:21 +0200 Subject: [PATCH 22/31] =?UTF-8?q?ocs=5Fdoc=5Fapi:=20Phase=205-ii=20?= =?UTF-8?q?=E2=80=94=20typed=20Table=20create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateTable(TableSpec{insertion_point, data[row][col]}) op (append-only). - backend add_table via Table::new(insertion_point, rows, cols) + set_cell_text (Table::default() pre-creates 3x3, so use Table::new for exact dims). - executor validates non-empty rectangular grid; facade create_table. - spec Table constructor; api_reference + binding_schema regenerated; mocks + baseline. - Test phase5ii_create_table_from_grid (3x2 grid, non-rectangular rejected) -> 615 host lib + 3 consistency green. Phase 5 complete. --- crates/ocs_doc_api/IMPLEMENTATION.md | 9 ++-- crates/ocs_doc_api/examples/common/mod.rs | 7 +++ crates/ocs_doc_api/spec/entities.toml | 7 ++- crates/ocs_doc_api/src/backend.rs | 3 ++ crates/ocs_doc_api/src/executor.rs | 11 +++++ crates/ocs_doc_api/src/facade.rs | 10 ++++ crates/ocs_doc_api/src/gen/api_reference.md | 6 ++- crates/ocs_doc_api/src/gen/ops_gen.rs | 4 +- crates/ocs_doc_api/src/ops.rs | 10 ++++ crates/ocs_doc_api/tests/mock_backend.rs | 7 +++ .../tests/spec_wire_consistency.rs | 2 + src/app/doc_api.rs | 48 +++++++++++++++++++ 12 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 511b269c8..a0630584d 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -107,10 +107,11 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal `RasterImage` has coarse bounds (insertion + u·width + v·height); generic `delete` works on all. Covered by `phase5_*` test. - **Done (typed create):** `create_raster_image(file_path, insertion, u, v, size)` - → `Operation::CreateRasterImage`; the host auto-registers the `ImageDefinition` - (asserted by `phase5_create_raster_image_registers_definition`). Covered by that - test. `Table`/light stay read-only `EntityView` DTOs (typed ops only where the - spec declares an action mapping). + → `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 diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 97a8c51e8..5e49cb300 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -206,6 +206,13 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("AttributeDefinition")) } + fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { + let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); + if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { + return Err(ApiError::validation("CreateTable", "table needs a non-empty rectangular grid")); + } + Ok(self.alloc("Table")) + } fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index ba7ad316c..e8442a563 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -445,7 +445,12 @@ name = "Table" acadrust_variant = "Table" handle = "Entity" collection = "entities" -description = "Table (read-only): GetEntity kind; generic delete. No typed create/bounds in v1." +description = "Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete." + +[[family.constructor]] +name = "create_table" +args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec]" }] +op = "CreateTable" # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 6432ed2da..3bed14d8e 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -75,6 +75,9 @@ pub trait DocApiBackend { /// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`. fn add_attribute_definition(&mut self, spec: &crate::ops::AttributeDefinitionSpec) -> ApiResult; + /// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`. + fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult; + /// A dimension's measured value (distance for linear/radius, degrees for angular). fn dimension_measurement(&self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index c2c3b7f08..735ed8bc3 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -293,6 +293,17 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // 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")); + } + b.push_undo(name); + let id = b.add_table(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 4cb8e40d9..d7ce04372 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -631,6 +631,16 @@ impl EntityCollection { Ok(Entity::new(self.session.clone(), id)) } + /// 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]) -> ApiResult { + 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 { let receipt = self.session.apply_op(Operation::CreateAttributeDefinition(crate::ops::AttributeDefinitionSpec { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 57fadbf00..ed7c2b9ac 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -185,7 +185,11 @@ Raster image: create_raster_image(file_path, insertion, u, v, size) places an im ## `Entity` (acadrust `Table`, collection `entities`) -Table (read-only): GetEntity kind; generic delete. No typed create/bounds in v1. +Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete. + +### Constructors (`doc.entities()`) + +- **create_table**(insertion_point: [f64; 3], data: &[Vec]) -> `Entity` — `CreateTable` ## Generic methods (every handle) diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index d88a3cf3f..c4c1f6266 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::id::ObjectId; -use crate::ops::{AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, SolidPrimitive, 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 (plan §5). Each variant is ONE atomic host call = one /// undo step. Append new variants at the END only. @@ -54,4 +54,6 @@ pub enum Operation { CreateDimensionAngular(DimensionAngularSpec), // attribute definitions CreateAttributeDefinition(AttributeDefinitionSpec), + // tables + CreateTable(TableSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 0fe193af2..32e02250c 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -148,6 +148,15 @@ pub struct AttributeDefinitionSpec { pub rotation: f64, } +/// Construction spec for a `TABLE`: an insertion point plus a grid of cell +/// strings (`data[row][column]`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TableSpec { + pub insertion_point: [f64; 3], + /// Grid of cell text values, rows × columns. + pub data: Vec>, +} + /// Construction spec for a `RASTER_IMAGE`: an image file placed at /// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and /// pixel `size` (width, height). The host auto-registers the ImageDefinition. @@ -223,6 +232,7 @@ impl crate::gen::Operation { CreateDimensionDiameter(_) => "CreateDimensionDiameter", CreateDimensionAngular(_) => "CreateDimensionAngular", CreateAttributeDefinition(_) => "CreateAttributeDefinition", + CreateTable(_) => "CreateTable", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 1e8c46688..2488f29df 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -146,6 +146,13 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("AttributeDefinition")) } + fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { + let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); + if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { + return Err(ApiError::validation("CreateTable", "table needs a non-empty rectangular grid")); + } + Ok(self.alloc("Table")) + } fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { if !self.entity_exists(id) { return Err(ApiError::UnknownId(id)); diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index 0486d3ada..c646aabbe 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -70,6 +70,7 @@ fn operation_variants() -> Vec { 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![] }), ]; // Derive the variant names by serializing a probe of each and reading the // bincode variant index -> name map is not available; instead match by @@ -163,6 +164,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateDimensionDiameter", "CreateDimensionAngular", "CreateAttributeDefinition", + "CreateTable", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index e771bc2eb..1a2bb9b21 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -306,6 +306,25 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { + use acadrust::entities::Table; + use acadrust::types::Vector3; + let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); + let mut table = Table::new( + Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]), + spec.data.len(), + cols, + ); + // Fill cells from the grid (each cell = text). + for (r, row) in spec.data.iter().enumerate() { + for (c, text) in row.iter().enumerate() { + table.set_cell_text(r, c, text); + } + } + let handle = self.scene_mut().add_entity(EntityType::Table(table)); + Ok(handle_to_obj(handle)) + } + fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { let handle = obj_to_handle(id); let entity = self.document().get_entity(handle).cloned().ok_or(ApiError::UnknownId(id))?; @@ -1186,6 +1205,35 @@ mod tests { } } + // ── Phase 5-ii: typed Table create ─────────────────────────────────────── + + #[test] + fn phase5ii_create_table_from_grid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let data = vec![ + vec!["Name".to_string(), "Value".to_string()], + vec!["Length".to_string(), "100.0".to_string()], + vec!["Width".to_string(), "50.0".to_string()], + ]; + let table = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateTable( + ocs_doc_api::ops::TableSpec { insertion_point: [10.0, 10.0, 0.0], data: data.clone() }))).unwrap()); + let handle = obj_to_handle(table); + let Some(EntityType::Table(t)) = host.document().get_entity(handle) else { panic!("table not found") }; + assert_eq!(t.rows.len(), 3); + assert_eq!(t.columns.len(), 2); + // Non-rectangular grid is a validation error. + let bad = vec![vec!["a".to_string()], vec!["b".to_string(), "c".to_string()]]; + assert!(dispatch(&mut host, DocApiEnvelope::op(Operation::CreateTable( + ocs_doc_api::ops::TableSpec { insertion_point: [0.0; 3], data: bad }))).is_err()); + // GetEntity reports kind Table. + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetEntity { id: table }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Table"), + other => panic!("expected entity, got {other:?}"), + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test] From 612e8e13e026572977cd640b5b225ea61c38b0b7 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:24:25 +0200 Subject: [PATCH 23/31] =?UTF-8?q?ocs=5Fdoc=5Fapi:=20Phase=202c-iii=20?= =?UTF-8?q?=E2=80=94=202-line=20angular=20dimension=20(all=20dimension=20s?= =?UTF-8?q?ub-types=20done)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateDimensionAngular2Ln(DimensionAngularSpec) op (append-only). - backend add_dimension_angular2ln (DimensionAngular2Ln::new computes angle). - facade create_dimension_angular2ln; spec constructor; api_reference + binding_schema regenerated; mocks + baseline. - IMPLEMENTATION.md: fix stale Status line (all 5 phases complete, current test counts); mark all dimension sub-types done. - Test phase2ciii_dimension_angular2ln (90-degree 2-line angle) -> 616 host lib + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 16 +++++---- crates/ocs_doc_api/examples/common/mod.rs | 5 +++ crates/ocs_doc_api/spec/entities.toml | 5 +++ crates/ocs_doc_api/src/backend.rs | 3 ++ crates/ocs_doc_api/src/executor.rs | 6 ++++ crates/ocs_doc_api/src/facade.rs | 8 +++++ crates/ocs_doc_api/src/gen/api_reference.md | 1 + crates/ocs_doc_api/src/gen/ops_gen.rs | 2 ++ crates/ocs_doc_api/src/ops.rs | 1 + crates/ocs_doc_api/tests/mock_backend.rs | 5 +++ .../tests/spec_wire_consistency.rs | 2 ++ src/app/doc_api.rs | 35 +++++++++++++++++++ 12 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index a0630584d..2e6bc0ca7 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -49,11 +49,11 @@ Phases are **spec-additive**: each is a `[[family]]`/`[[family.method]]` block i `ARCHITECTURE.md` § Workflow). No protocol change; new `Operation`/`Query` variants append at enum end. -**Status:** Phase 0 (supported-family methods), Phase 2a (full 2D curves), -Phase 3 (block references), Phase 4 (viewports), Phase 5 (media read-mostly) are -**done** (all tested; 601 host lib + 8 crate unit tests green). Remaining sub-items: -Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal -(P3), set_view + view query (P4), typed media ops (P5). +**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: 615 host lib + 8 crate unit + 3 spec↔wire consistency tests green. ### Phase 2 — full 2D + annotations - **Done (2a):** typed curve families `ArcCurve`/`Ellipse`/`Spline`/`Ray`/`XLine` — @@ -73,8 +73,10 @@ Phase 2b (annotations/hatch/dimension typed ops), attributes + nested traversal (`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. (`Angular2Ln` variant handled in - `measurement`; a dedicated constructor is a later refinement.) + 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). diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 5e49cb300..3d982cc62 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -206,6 +206,11 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("AttributeDefinition")) } + fn add_dimension_angular2ln(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult { + let id = self.alloc("Dimension"); + self.dimension_measurements.insert(id, 90.0); + Ok(id) + } fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index e8442a563..b885693dd 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -346,6 +346,11 @@ name = "create_dimension_angular" args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] op = "CreateDimensionAngular" +[[family.constructor]] +name = "create_dimension_angular2ln" +args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] +op = "CreateDimensionAngular2Ln" + [[family.method]] name = "measurement" kind = "query" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 3bed14d8e..0e004d6e3 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -78,6 +78,9 @@ pub trait DocApiBackend { /// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`. fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult; + /// Add a 2-line angular `DIMENSION`; returns the fresh `ObjectId`. + fn add_dimension_angular2ln(&mut self, spec: &crate::ops::DimensionAngularSpec) -> ApiResult; + /// A dimension's measured value (distance for linear/radius, degrees for angular). fn dimension_measurement(&self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 735ed8bc3..e6c626e42 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -304,6 +304,12 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + let id = b.add_dimension_angular2ln(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } }; Ok(Receipt { outcome: Some(outcome), diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index d7ce04372..dabc2d8be 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -539,6 +539,14 @@ impl CurveCollection { 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 { + 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 { self.create_dim_radial(Operation::CreateDimensionRadius, center, point) diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index ed7c2b9ac..1119bcf76 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -136,6 +136,7 @@ Linear dimension between two measured points. measurement() reads the distance; - **create_dimension_radius**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionRadius` - **create_dimension_diameter**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionDiameter` - **create_dimension_angular**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular` +- **create_dimension_angular2ln**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular2Ln` ### Methods diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index c4c1f6266..be0246f20 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -56,4 +56,6 @@ pub enum Operation { CreateAttributeDefinition(AttributeDefinitionSpec), // tables CreateTable(TableSpec), + // dimension sub-types (2-line angular) + CreateDimensionAngular2Ln(DimensionAngularSpec), } diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index 32e02250c..f32284dce 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -233,6 +233,7 @@ impl crate::gen::Operation { CreateDimensionAngular(_) => "CreateDimensionAngular", CreateAttributeDefinition(_) => "CreateAttributeDefinition", CreateTable(_) => "CreateTable", + CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", } } } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 2488f29df..82909cd86 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -146,6 +146,11 @@ impl DocApiBackend for MockBackend { } Ok(self.alloc("AttributeDefinition")) } + fn add_dimension_angular2ln(&mut self, _spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult { + let id = self.alloc("Dimension"); + self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees + Ok(id) + } fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index c646aabbe..012137b27 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -71,6 +71,7 @@ fn operation_variants() -> Vec { 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 @@ -165,6 +166,7 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateDimensionAngular", "CreateAttributeDefinition", "CreateTable", + "CreateDimensionAngular2Ln", ]; /// Recorded baseline of the `Query` variant order. diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 1a2bb9b21..1c3197257 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -306,6 +306,16 @@ impl DocApiBackend for HostSession<'_> { Ok(handle_to_obj(handle)) } + fn add_dimension_angular2ln(&mut self, spec: &ocs_doc_api::ops::DimensionAngularSpec) -> ApiResult { + use acadrust::entities::{Dimension, DimensionAngular2Ln}; + use acadrust::types::Vector3; + let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); + let mut dim = DimensionAngular2Ln::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point)); + dim.dimension_arc = v3(spec.arc_location); + let handle = self.scene_mut().add_entity(EntityType::Dimension(Dimension::Angular2Ln(dim))); + Ok(handle_to_obj(handle)) + } + fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { use acadrust::entities::Table; use acadrust::types::Vector3; @@ -1234,6 +1244,31 @@ mod tests { } } + // ── Phase 2c-iii: 2-line angular dimension ──────────────────────────────── + + #[test] + fn phase2ciii_dimension_angular2ln() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Angle between line (0,0,0)->(10,0,0) and (0,0,0)->(0,10,0) = 90 degrees. + let ang = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateDimensionAngular2Ln( + ocs_doc_api::ops::DimensionAngularSpec { + vertex: [0.0; 3], first_point: [10.0, 0.0, 0.0], second_point: [0.0, 10.0, 0.0], arc_location: [5.0, 5.0, 0.0], + }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetDimensionMeasurement { id: ang }, + Query::GetEntity { id: ang }, + ])).unwrap(); + match &receipt.query_results[0] { + QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"), + other => panic!("expected measurement, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"), + other => panic!("expected entity, got {other:?}"), + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test] From 6997dfe6183cff5d8668843c0ab561d15a8458ee Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:40:07 +0200 Subject: [PATCH 24/31] ocs_doc_api: accurate GetVolume/GetCentroid via fine tessellation - mass_properties: query path now uses fine mesh_body(0.1, 1e-4) for near-analytic volume/centroid; the render-mesh metrics cache (coarse display LOD) no longer shadows query accuracy. Memoized per (handle, epoch); cold-tess budget kept. - IMPLEMENTATION.md: accuracy item done. - Test accurate_volume_centroid_sphere_and_cube (sphere within 0.5% of 4/3*pi*r^3, centroid at centre, cube exact) -> 617 host lib green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 9 +++-- src/app/doc_api.rs | 52 ++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 2e6bc0ca7..6666c36a0 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -117,9 +117,12 @@ tested: 615 host lib + 8 crate unit + 3 spec↔wire consistency tests green. ## 🔧 Outstanding methods on supported families -| Method | Family | Status | Blocker | -|---|---|---|---| -| `GetCentroid`/`GetVolume` accuracy | Solid | mesh approx | render-mesh LOD tolerance vs query tolerance | +**Completed (accuracy):** `GetVolume`/`GetCentroid` now 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 no longer shadows query +accuracy; results memoized per (handle, geometry_epoch). 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_*` diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 1c3197257..160279bc7 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -631,23 +631,19 @@ impl HostSession<'_> { return Ok((v, c)); } } - // Render-mesh metrics cache (avoids re-tessellation + B-rep clone). - if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { - if metrics.volume.abs() > 0.0 { - self.mass_cache.insert(handle, (epoch, metrics.volume, metrics.centroid)); - return Ok((metrics.volume, metrics.centroid)); - } - } - // Cold-cache fallback: bounded full tessellation. + // Accuracy path: fine tessellation so the divergence volume/centroid + // converge to near-analytic (the render-mesh metrics cache is the coarse + // LOD for display, NOT for query accuracy). Bounded by the cold-tess budget; + // memoized per (handle, epoch) so repeated queries on the same solid are O(1). const COLD_TESS_BUDGET: usize = 256; if self.cold_tess_used >= COLD_TESS_BUDGET { return Err(ApiError::Unsupported( - "volume/centroid cold-tessellation budget exceeded for this session; regenerate the mesh or query fewer distinct solids".into(), + "volume/centroid cold-tessellation budget exceeded for this session; query fewer distinct solids".into(), )); } self.cold_tess_used += 1; let body = self.resolve_body(id)?; - let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); + let mesh = cadkernel::brep::mesh_body(&body, 0.1, 1e-4); let (v, c) = mesh_volume_centroid(&mesh); self.mass_cache.insert(handle, (epoch, v, c)); Ok((v, c)) @@ -1244,6 +1240,42 @@ mod tests { } } + // ── Outstanding method: accurate volume/centroid (fine tessellation) ───── + + #[test] + fn accurate_volume_centroid_sphere_and_cube() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Sphere r=5: analytic volume = 4/3 * pi * 125 ≈ 523.599; centroid at centre. + let ball = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid( + SolidPrimitive::Sphere { centre: [10.0, 20.0, 30.0], radius: 5.0 }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetVolume { id: ball }, Query::GetCentroid { id: ball }])).unwrap(); + let analytic = 4.0 / 3.0 * std::f64::consts::PI * 125.0; + match &receipt.query_results[0] { + QueryResult::Volume(v) => { + let err = ((*v - analytic) / analytic).abs(); + assert!(err < 0.005, "sphere volume {v} vs analytic {analytic} (err {err:.4})"); + } + other => panic!("expected volume, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Centroid(c) => { + assert!((c[0] - 10.0).abs() < 0.05 && (c[1] - 20.0).abs() < 0.05 && (c[2] - 30.0).abs() < 0.05, + "sphere centroid {c:?}"); + } + other => panic!("expected centroid, got {other:?}"), + } + // Cube 10^3 is exact (planar faces: divergence is exact regardless of LOD). + let cube = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateSolid( + SolidPrimitive::Cuboid { origin: [0.0; 3], size: [10.0; 3] }))).unwrap()); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::GetVolume { id: cube }])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Volume(v) => assert!((*v - 1000.0).abs() < 1.0, "cube volume {v}"), + other => panic!("expected volume, got {other:?}"), + } + } + // ── Phase 2c-iii: 2-line angular dimension ──────────────────────────────── #[test] From 28c8e0ef1ae657c8120ce6b3a2285b4aae5ca1c8 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:46:41 +0200 Subject: [PATCH 25/31] ocs_doc_api: read-mostly family bounds (Leader/Mesh/Face3D/MLine/Helix) - entity_bounds: vertex/point-carrying read-mostly families (Leader.vertices, Mesh.vertices, Face3D 4 corners, MLine vertex positions, Helix start point) via a points-to-AABB helper. Region/Body/Surface/MultiLeader stay Unsupported. - IMPLEMENTATION.md: read-mostly bounds done. - Test read_mostly_family_bounds_leader_and_mesh -> 618 host lib green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 8 +++-- src/app/doc_api.rs | 48 ++++++++++++++++++++++++++++ src/app/doc_api_convert.rs | 23 +++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 6666c36a0..537f35d69 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -106,8 +106,12 @@ tested: 615 host lib + 8 crate unit + 3 spec↔wire consistency tests green. - **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"; - `RasterImage` has coarse bounds (insertion + u·width + v·height); generic - `delete` works on all. Covered by `phase5_*` test. + 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` diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 160279bc7..9c58225d5 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -1301,6 +1301,54 @@ mod tests { } } + // ── Read-mostly family bounds (Leader/Mesh/Face3D/MLine/Helix) ────────── + + #[test] + fn read_mostly_family_bounds_leader_and_mesh() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Leader with vertices at (0,0,0),(5,5,0),(10,0,0) -> bounds x[0,10] y[0,5]. + let mut leader = acadrust::entities::Leader::default(); + leader.vertices = vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(5.0, 5.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ]; + let leader_h = host.document_mut().add_entity(EntityType::Leader(leader)).unwrap(); + // Mesh with vertices in a unit cube at (2..3, 2..3, 0). + let mut mesh = acadrust::entities::Mesh::default(); + mesh.vertices = vec![ + acadrust::types::Vector3::new(2.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 3.0, 0.0), + acadrust::types::Vector3::new(2.0, 3.0, 0.0), + ]; + let mesh_h = host.document_mut().add_entity(EntityType::Mesh(mesh)).unwrap(); + + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![ + Query::GetBounds { id: handle_to_obj(leader_h) }, + Query::GetBounds { id: handle_to_obj(mesh_h) }, + Query::GetEntity { id: handle_to_obj(leader_h) }, + ])).unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 10.0).abs() < 1e-6, "{b:?}"); + assert!((b.min[1] - 0.0).abs() < 1e-6 && (b.max[1] - 5.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!((b.min[0] - 2.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, "{b:?}"); + } + other => panic!("expected bounds, got {other:?}"), + } + match &receipt.query_results[2] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Leader"), + other => panic!("expected entity, got {other:?}"), + } + } + // ── Phase 5 remaining: typed raster image create ───────────────────────── #[test] diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 17be1df27..784a1a833 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -473,8 +473,31 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult 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) => points_aabb([&h.start_point].into_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. `UnknownId` if empty (no points to bound). +fn points_aabb(pts: Vec<&Vector3>, id: ObjectId) -> ApiResult { + if pts.is_empty() { + return Err(ApiError::UnknownId(id)); + } + 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 }) +} From ac54d6079b310fae738d1e89027980bb48742b27 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:23:00 +0200 Subject: [PATCH 26/31] ocs_doc_api: fix review findings (layouts, Helix bounds, points_aabb, mass-properties budget, docs) - layouts.json: append the 6 missing ops (CreateDimensionRadius/Diameter/Angular, CreateAttributeDefinition, CreateTable, CreateDimensionAngular2Ln); regenerate binding_schema.json + api_reference.md wire vocabulary (all 28 ops). - Helix bounds: bound the evaluated spline control polygon (was a degenerate single-start-point AABB). - points_aabb: empty -> Geometry::Empty('entity has no points to bound'), not UnknownId (the entity exists; don't misclassify as stale/deleted). - mass_properties: accuracy-first fine tessellation + graceful degradation to the approximate meshes.metrics value when the cold-tess budget is exhausted (large distinct-solid batches no longer hard-fail). - IMPLEMENTATION.md: Volume/Centroid rows + accuracy paragraph + test count (618) consistent with the final design. - 618 host lib + 8 crate + 3 consistency green. --- crates/ocs_doc_api/IMPLEMENTATION.md | 17 +++++++++-------- crates/ocs_doc_api/src/gen/api_reference.md | 6 ++++++ .../ocs_doc_api/src/gen/binding_schema.json | 8 +++++++- crates/ocs_doc_api/src/gen/layouts.json | 8 +++++++- src/app/doc_api.rs | 10 +++++++++- src/app/doc_api_convert.rs | 19 ++++++++++++++----- 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 537f35d69..5bd4113ca 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -15,8 +15,8 @@ content). | 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` → `meshes.metrics` cache, mesh-divergence fallback | -| Centroid | `centroid()` | `GetCentroid` → `meshes.metrics` cache, mesh-divergence fallback | +| 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) @@ -53,7 +53,7 @@ variants append at enum end. (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: 615 host lib + 8 crate unit + 3 spec↔wire consistency tests green. +tested: 618 host lib + 8 crate unit + 3 spec↔wire consistency tests green. ### Phase 2 — full 2D + annotations - **Done (2a):** typed curve families `ArcCurve`/`Ellipse`/`Spline`/`Ray`/`XLine` — @@ -121,12 +121,13 @@ tested: 615 host lib + 8 crate unit + 3 spec↔wire consistency tests green. ## 🔧 Outstanding methods on supported families -**Completed (accuracy):** `GetVolume`/`GetCentroid` now compute via a **fine +**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 no longer shadows query -accuracy; results memoized per (handle, geometry_epoch). Verified by -`accurate_volume_centroid_sphere_and_cube` (sphere volume within 0.5% of 4/3πr³, -centroid at centre, cube exact). +render-mesh metrics cache is the coarse display LOD and does not drive queries; +results memoized per (handle, geometry_epoch); when the cold-tess budget 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_*` diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 1119bcf76..2f40d9acf 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -278,6 +278,12 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read- - `SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }` - `CreateRasterImage(RasterImageSpec)` - `Loft { profiles: Vec }` +- `CreateDimensionRadius(DimensionRadialSpec)` +- `CreateDimensionDiameter(DimensionRadialSpec)` +- `CreateDimensionAngular(DimensionAngularSpec)` +- `CreateAttributeDefinition(AttributeDefinitionSpec)` +- `CreateTable(TableSpec)` +- `CreateDimensionAngular2Ln(DimensionAngularSpec)` ### `Query` diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index ed300c3d8..3484d81e6 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -428,7 +428,13 @@ "SetAttribute { id: ObjectId, tag: String, value: String }", "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }" + "Loft { profiles: Vec }", + "CreateDimensionRadius(DimensionRadialSpec)", + "CreateDimensionDiameter(DimensionRadialSpec)", + "CreateDimensionAngular(DimensionAngularSpec)", + "CreateAttributeDefinition(AttributeDefinitionSpec)", + "CreateTable(TableSpec)", + "CreateDimensionAngular2Ln(DimensionAngularSpec)" ] }, "Query": { diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json index 4a9edb9d5..c743ba25d 100644 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ b/crates/ocs_doc_api/src/gen/layouts.json @@ -23,7 +23,13 @@ "SetAttribute { id: ObjectId, tag: String, value: String }", "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }" + "Loft { profiles: Vec }", + "CreateDimensionRadius(DimensionRadialSpec)", + "CreateDimensionDiameter(DimensionRadialSpec)", + "CreateDimensionAngular(DimensionAngularSpec)", + "CreateAttributeDefinition(AttributeDefinitionSpec)", + "CreateTable(TableSpec)", + "CreateDimensionAngular2Ln(DimensionAngularSpec)" ] }, "Query": { diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 9c58225d5..b34d75f6e 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -633,10 +633,18 @@ impl HostSession<'_> { } // Accuracy path: fine tessellation so the divergence volume/centroid // converge to near-analytic (the render-mesh metrics cache is the coarse - // LOD for display, NOT for query accuracy). Bounded by the cold-tess budget; + // display LOD, NOT for query accuracy). Bounded by the cold-tess budget; // memoized per (handle, epoch) so repeated queries on the same solid are O(1). const COLD_TESS_BUDGET: usize = 256; if self.cold_tess_used >= COLD_TESS_BUDGET { + // Budget exhausted: serve a documented-approximate value from the render + // mesh metrics cache rather than failing (large distinct-solid batches + // degrade gracefully instead of erroring). Accuracy-first up to the budget. + if let Some(metrics) = self.scene().meshes.get(&handle).map(|m| m.metrics) { + if metrics.volume.abs() > 0.0 { + return Ok((metrics.volume, metrics.centroid)); + } + } return Err(ApiError::Unsupported( "volume/centroid cold-tessellation budget exceeded for this session; query fewer distinct solids".into(), )); diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 784a1a833..9ccfb1aa0 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -5,7 +5,7 @@ use acadrust::entities::{Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine}; use acadrust::types::{Vector2, Vector3}; use acadrust::EntityType; -use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, ObjectId}; +use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, ObjectId}; /// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for /// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). @@ -481,17 +481,26 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult points_aabb(m.vertices.iter().map(|v| &v.position).collect(), id)?, - EntityType::Helix(h) => points_aabb([&h.start_point].into_iter().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. `UnknownId` if empty (no points to bound). -fn points_aabb(pts: Vec<&Vector3>, id: ObjectId) -> ApiResult { +/// 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 { if pts.is_empty() { - return Err(ApiError::UnknownId(id)); + return Err(ApiError::geometry(GeometryErrorKind::Empty, "entity has no points to bound")); } let mut min = [f64::INFINITY; 3]; let mut max = [f64::NEG_INFINITY; 3]; From 919128e087b0b490b4efbb696b852dbeeaa0599c Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:03:34 +0200 Subject: [PATCH 27/31] ocs_doc_api: fix branch-review findings (tab auth, bulge center, budget, caps, dead code) CRITICAL: - execute_doc_api: reject tab_id != bound tab (confused deputy); test doc_api_tab_mismatch_is_rejected. - bulge_arc_segment: fix negative/CW bulge center (apothem magnitude + sign picks side; was double-sign-mirrored); test CW-vs-CCW center offset. - cold-tess budget: move mass_cache/cold_tess_used to per-tab Scene so the budget persists across DocApi dispatches (was reset per request); budget-exhausted -> approximate meshes.metrics fallback instead of error. WARNING: - Loft: add BULK_ITEM_CAP (was uncapped -> unbounded kernel call from one request). - CreateMany: pre-validate EntitySpec::Curve (ellipse ratio) in prepare phase -> all-or-nothing restored; regression test. - Partial Ellipse: offset start/end_parameter by rot_z on rotation; regression test. - resolve_body: add with_body borrow accessor (host overrides to borrow cache in place, O(1)); bounds() uses it instead of deep-cloning the B-rep per query. - add_raster_image: validate file path (non-empty, image extension, reject UNC/traversal); regression test. SUGGESTION: - deploy: bump API_VERSION 5 -> 6 so DocApi plugins are gated at handshake on old hosts (append-only wire + defaulted method keep old V2-V5 plugins loading). - dead code: wire query_name into apply_queries error labels; host uses crate's ObjectId::from_handle/to_handle (drop local duplicates); delete is_null/backend_mut. - mock drift: test mock now stores curve specs + computes curve bounds (aligns with example mock); Spline/Dimension bounds delegate to points_aabb. 623 host lib + 8 crate + 3 consistency + 34 plugin_api green. --- crates/ocs_doc_api/src/backend.rs | 15 +- crates/ocs_doc_api/src/executor.rs | 63 +++++-- crates/ocs_doc_api/src/id.rs | 4 - crates/ocs_doc_api/src/transport/inproc.rs | 4 - crates/ocs_doc_api/tests/mock_backend.rs | 44 ++++- crates/ocs_plugin_api/src/manifest.rs | 9 +- src/app/doc_api.rs | 193 +++++++++++++++++++-- src/app/doc_api_convert.rs | 37 ++-- src/app/plugin_host.rs | 6 - src/scene/mod.rs | 12 ++ 10 files changed, 316 insertions(+), 71 deletions(-) diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 0e004d6e3..5f5d5962a 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -19,9 +19,22 @@ pub type KernelBody = cadkernel::brep::Body; pub trait DocApiBackend { // ── identity / solid lookup ──────────────────────────────────────────── /// Resolve a live B-rep body for `id` (solid cache; re-lift from AcisData on - /// miss). `UnknownId` if `id` is not a live solid. + /// miss). `UnknownId` if `id` is not a live solid. NOTE: returns an OWNED body + /// (a clone of the cache entry); use `with_body` for read-only queries. fn resolve_body(&mut self, id: ObjectId) -> ApiResult; + /// Borrow the cached body read-only for a query, avoiding the `resolve_body` + /// deep clone. Default: falls back to `resolve_body` (clone). The host overrides + /// this to borrow the `solid_models` cache in place (O(1), no copy). + fn with_body( + &mut self, + id: ObjectId, + f: &mut dyn FnMut(&KernelBody) -> ApiResult, + ) -> ApiResult { + let body = self.resolve_body(id)?; + f(&body) + } + /// Store a new solid body as a `Solid3D` entity; returns the fresh `ObjectId`. /// Mirrors host `add_solid_model` (edge wires + `solid_to_sat` + /// `set_sat_document` + `register_solid_model`). diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index e6c626e42..a6e198e28 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -103,14 +103,19 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { Prepared::Solid(crate::geom::make_solid(p).map_err(|e| at_index(name, i, e))?) } - EntitySpec::Curve(c) => Prepared::Curve(c.clone()), + EntitySpec::Curve(c) => { + validate_curve_spec(c).map_err(|e| at_index(name, i, e))?; + Prepared::Curve(c.clone()) + } }; prepared.push(prep); } @@ -254,6 +259,11 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { + // Bound the section count like the bulk ops (one envelope can carry many + // ids; an uncapped loft would be an unbounded kernel call on the UI thread). + if profiles.len() > BULK_ITEM_CAP { + return Err(over_cap(name, profiles.len())); + } // Resolve every profile to its curve set BEFORE push_undo (all-or-nothing). if profiles.len() < 2 { return Err(ApiError::validation(name, "loft needs >= 2 profiles")); @@ -336,24 +346,25 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes } 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)?), - Query::GetBounds { id } => QueryResult::Bounds(b.bounds(*id)?), - Query::GetCentroid { id } => QueryResult::Centroid(b.centroid(*id)?), - Query::GetVolume { id } => QueryResult::Volume(b.volume(*id)?), + Query::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)?; - let bb = b.bounds(*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)?), - Query::GetHatchBoundary { id } => QueryResult::HatchBoundary(b.hatch_boundary(*id)?), - Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement(b.dimension_measurement(*id)?), - Query::GetAttributes { id } => QueryResult::Attributes(b.attributes(*id)?), - Query::GetBlockEntities { block_name } => QueryResult::BlockEntities(b.block_entities(block_name)?), + 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)?; + let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?; QueryResult::ViewportView { target, height } } }; @@ -366,6 +377,15 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes }) } +/// Attach the query name to an error for better diagnostics (uses `query_name`). +fn label(query: &'static str, e: ApiError) -> ApiError { + match e { + ApiError::Validation { reason, .. } => ApiError::validation(query, reason), + ApiError::UnknownId(id) => ApiError::validation(query, format!("unknown ObjectId {id:?}")), + other => other, + } +} + fn profile_curves( b: &B, profile: ObjectId, @@ -377,6 +397,21 @@ fn profile_curves( b.profile_curves(profile) } +/// 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: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { if b.entity_exists(id) { Ok(()) diff --git a/crates/ocs_doc_api/src/id.rs b/crates/ocs_doc_api/src/id.rs index 447761fae..c19e6fc33 100644 --- a/crates/ocs_doc_api/src/id.rs +++ b/crates/ocs_doc_api/src/id.rs @@ -17,10 +17,6 @@ impl ObjectId { self.0 } - pub const fn is_null(self) -> bool { - self.0 == 0 - } - /// Build from a raw u64 (e.g. an `acadrust::Handle`'s value). pub const fn from_u64(v: u64) -> Self { Self(v) diff --git a/crates/ocs_doc_api/src/transport/inproc.rs b/crates/ocs_doc_api/src/transport/inproc.rs index 5cdca1310..e8328e0a7 100644 --- a/crates/ocs_doc_api/src/transport/inproc.rs +++ b/crates/ocs_doc_api/src/transport/inproc.rs @@ -19,10 +19,6 @@ impl InProcess { pub fn new(backend: B) -> Self { Self { backend: Mutex::new(backend) } } - /// Borrow the backend mutably (test/introspection helper). - pub fn backend_mut(&mut self) -> &mut B { - self.backend.get_mut().expect("in-process backend poisoned") - } /// Borrow the backend immutably (test/introspection helper). pub fn backend(&self) -> std::sync::MutexGuard<'_, B> { self.backend.lock().expect("in-process backend poisoned") diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 82909cd86..43b7c6f14 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -12,6 +12,38 @@ use ocs_doc_api::{ HasId, ObjectId, PlacementSpec, }; +/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and +/// the example mock — all three must answer curve bounds identically). +fn curve_bounds(spec: &Curve2Spec) -> Aabb { + match spec { + Curve2Spec::Line { start, end } => Aabb { + min: [start[0].min(end[0]), start[1].min(end[1]), start[2].min(end[2])], + max: [start[0].max(end[0]), start[1].max(end[1]), start[2].max(end[2])], + }, + Curve2Spec::Circle { centre, radius } | 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, .. } => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for p in points { + for i in 0..3 { + min[i] = min[i].min(p[i]); + max[i] = max[i].max(p[i]); + } + } + Aabb { min, max } + } + Curve2Spec::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 }, + } +} + /// In-memory mock backend: HashMap + a tiny entity table. /// Tracks undo steps + revision bumps to assert per-op semantics. #[derive(Default)] @@ -29,6 +61,9 @@ struct MockBackend { dimension_measurements: HashMap, /// Stored insert attributes. attributes_store: HashMap>, + /// Stored 2D curve specs so `bounds()` works on curve entities (aligned with + /// the example mock — both mocks must answer curve bounds the same way). + curves: HashMap, /// Stored viewport views (target, height). viewport_views: HashMap, } @@ -72,7 +107,9 @@ impl DocApiBackend for MockBackend { Curve2Spec::Ray { .. } => "Ray", Curve2Spec::XLine { .. } => "XLine", }; - Ok(self.alloc(kind)) + let id = self.alloc(kind); + self.curves.insert(id, spec.clone()); + Ok(id) } fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { if spec.block_name.is_empty() { @@ -237,6 +274,11 @@ impl DocApiBackend for MockBackend { Err(ApiError::Unsupported("profiles not mocked".into())) } fn bounds(&mut self, id: ObjectId) -> ApiResult { + // Solids via the kernel body; 2D curves from their stored spec (aligned + // with the example mock — both mocks answer curve bounds identically). + if let Some(spec) = self.curves.get(&id) { + return Ok(curve_bounds(spec)); + } let body = self.body(id)?; let bb = cadkernel::brep::body_bounds(body) .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index c0113e126..b72648a72 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -19,7 +19,14 @@ /// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 /// adds the `BuiltinPlugin::on_load` lifecycle callback. -pub const API_VERSION: u32 = 5; +/// Host ABI major version. Bumped to 6 when the DocApi v2 surface +/// (`PluginRequest::DocApiRequest` / `PluginResponse::DocApiResponse` + +/// `HostApi::doc_api_dispatch`) was added: plugins that call DocApi require an +/// API-6 host and are rejected at handshake by older (API ≤ 5) hosts, instead of +/// failing at runtime with a bincode-decode transport error. The DocApi wire +/// changes are append-only and `doc_api_dispatch` is a defaulted method, so +/// older V2–V5 plugins still load unchanged on an API-6 host. +pub const API_VERSION: u32 = 6; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index b34d75f6e..028cb9df1 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -19,8 +19,17 @@ use super::plugin_host::HostSession; /// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by // the in-process path. Deserializes the envelope, runs the crate executor, // serializes the `Receipt` (or `ApiError`) back to bytes. -pub fn execute_doc_api(host: &mut HostSession<'_>, _tab_id: u64, bytes: &[u8]) -> Result, String> { +pub fn execute_doc_api(host: &mut HostSession<'_>, tab_id: u64, bytes: &[u8]) -> Result, String> { use ocs_doc_api::{DocApiEnvelope, EnvelopeBody}; + // Authorization: the request must name the SAME tab the HostSession is bound to. + // The HostSession is built per-tab by the dispatch pump; a plugin naming a + // different tab_id would otherwise reach a tab it isn't bound to (confused deputy). + if tab_id != host.tab_id() { + return Err(format!( + "DocApi tab mismatch: request names tab {tab_id} but the bound tab is {}", + host.tab_id() + )); + } let envelope: DocApiEnvelope = bincode::deserialize(bytes) .map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?; let result: ApiResult = match envelope.body { @@ -30,11 +39,13 @@ pub fn execute_doc_api(host: &mut HostSession<'_>, _tab_id: u64, bytes: &[u8]) - bincode::serialize(&result).map_err(|e| format!("Receipt serialize: {e}")) } +// ObjectId <-> acadrust::Handle converters delegate to the crate's helpers +// (single source of truth; no local duplicates). fn obj_to_handle(id: ObjectId) -> Handle { - Handle::new(id.as_u64()) + id.to_handle() } fn handle_to_obj(h: Handle) -> ObjectId { - ObjectId::from_u64(h.value()) + ObjectId::from_handle(h) } impl DocApiBackend for HostSession<'_> { @@ -49,6 +60,18 @@ impl DocApiBackend for HostSession<'_> { .ok_or(ApiError::UnknownId(id)) } + fn with_body( + &mut self, + id: ObjectId, + f: &mut dyn FnMut(&KernelBody) -> ApiResult, + ) -> ApiResult { + let handle = obj_to_handle(id); + // Lift-on-miss, then borrow the cache entry in place — O(1), no deep clone. + self.scene_mut().restore_solid_models(&[handle]); + let body = self.scene().solid_models.get(&handle).ok_or(ApiError::UnknownId(id))?; + f(body) + } + fn store_solid(&mut self, body: &KernelBody) -> ApiResult { // Pre-flight the display prep BEFORE committing the entity: if the mesh // cannot be produced, fail now — no entity, no rollback, no revision bump @@ -409,6 +432,10 @@ impl DocApiBackend for HostSession<'_> { fn add_raster_image(&mut self, spec: &ocs_doc_api::ops::RasterImageSpec) -> ApiResult { use acadrust::entities::RasterImage; use acadrust::types::{Vector2, Vector3}; + // Validate the plugin-supplied path before embedding it in the document: + // non-empty, a known image extension, and not a UNC/network path or + // parent-traversal (which could point the host at unintended resources). + validate_image_path(&spec.file_path)?; let mut img = RasterImage::default(); img.file_path = spec.file_path.clone(); img.insertion_point = Vector3::new(spec.insertion_point[0], spec.insertion_point[1], spec.insertion_point[2]); @@ -574,15 +601,15 @@ impl DocApiBackend for HostSession<'_> { fn bounds(&mut self, id: ObjectId) -> ApiResult { let handle = obj_to_handle(id); - // Lift-on-miss for solids (consistent with volume/centroid): resolve_body - // repopulates a cold solid_models cache from SAT. A solid whose cache entry - // was dropped (e.g. by a host-side update_entity) no longer misreports - // `Unsupported` (plan review). + // Lift-on-miss for solids (consistent with volume/centroid); borrow the cache + // read-only via with_body (O(1), no B-rep deep clone). if matches!(self.document().get_entity(handle), Some(EntityType::Solid3D(_))) { - let body = self.resolve_body(id)?; - let bb = cadkernel::brep::body_bounds(&body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - return Ok(Aabb { min: bb.min, max: bb.max }); + let mut f = |body: &KernelBody| -> ApiResult { + let bb = cadkernel::brep::body_bounds(body) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; + Ok(Aabb { min: bb.min, max: bb.max }) + }; + return self.with_body(id, &mut f); } crate::app::doc_api_convert::entity_bounds(self.document().get_entity(handle), id) } @@ -626,7 +653,7 @@ impl HostSession<'_> { fn mass_properties(&mut self, id: ObjectId) -> ApiResult<(f64, [f64; 3])> { let handle = obj_to_handle(id); let epoch = self.scene().geometry_epoch; - if let Some(&(cached_epoch, v, c)) = self.mass_cache.get(&handle) { + if let Some(&(cached_epoch, v, c)) = self.scene().doc_api_mass_cache.get(&handle) { if cached_epoch == epoch { return Ok((v, c)); } @@ -635,8 +662,10 @@ impl HostSession<'_> { // converge to near-analytic (the render-mesh metrics cache is the coarse // display LOD, NOT for query accuracy). Bounded by the cold-tess budget; // memoized per (handle, epoch) so repeated queries on the same solid are O(1). + // The budget + cache live on the per-tab Scene so they persist across + // DocApi dispatches (not reset per request). const COLD_TESS_BUDGET: usize = 256; - if self.cold_tess_used >= COLD_TESS_BUDGET { + if self.scene().doc_api_cold_tess_used >= COLD_TESS_BUDGET { // Budget exhausted: serve a documented-approximate value from the render // mesh metrics cache rather than failing (large distinct-solid batches // degrade gracefully instead of erroring). Accuracy-first up to the budget. @@ -646,18 +675,47 @@ impl HostSession<'_> { } } return Err(ApiError::Unsupported( - "volume/centroid cold-tessellation budget exceeded for this session; query fewer distinct solids".into(), + "volume/centroid cold-tessellation budget exceeded for this tab; query fewer distinct solids".into(), )); } - self.cold_tess_used += 1; + self.scene_mut().doc_api_cold_tess_used += 1; let body = self.resolve_body(id)?; let mesh = cadkernel::brep::mesh_body(&body, 0.1, 1e-4); let (v, c) = mesh_volume_centroid(&mesh); - self.mass_cache.insert(handle, (epoch, v, c)); + self.scene_mut().doc_api_mass_cache.insert(handle, (epoch, v, c)); Ok((v, c)) } } +/// Validate a raster-image file path before embedding it in a document. +/// Rejects empty paths, UNC/network paths, parent-traversal, and non-image extensions. +fn validate_image_path(path: &str) -> ApiResult<()> { + let p = path.trim(); + if p.is_empty() { + return Err(ApiError::validation("CreateRasterImage", "empty image file path")); + } + // UNC / network / device paths. + if p.starts_with("\\\\") || p.starts_with("//") { + return Err(ApiError::Unsupported("network/UNC image paths are not allowed".into())); + } + // Parent-traversal anywhere in the path. + if p.split(['/', '\\']).any(|seg| seg == "..") { + return Err(ApiError::Unsupported("parent-traversal ('..') in image path is not allowed".into())); + } + // Known image extension (last component). + let ext_ok = p.rsplit('.').next().map(|ext| { + matches!(ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "bmp" | "gif" | "tif" | "tiff" | "webp" | "tga") + }).unwrap_or(false); + if !ext_ok { + return Err(ApiError::validation( + "CreateRasterImage", + format!("unsupported image file extension in {path:?}"), + )); + } + Ok(()) +} + fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { cadkernel::brep::Placement { x_axis: p.x_axis, @@ -687,7 +745,8 @@ mod tests { fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { let bytes = bincode::serialize(&env).unwrap(); - let out = execute_doc_api(host, 0, &bytes).expect("dispatch failed"); + let tab_id = host.tab_id(); + let out = execute_doc_api(host, tab_id, &bytes).expect("dispatch failed"); bincode::deserialize(&out).expect("receipt deserialize") } @@ -1156,6 +1215,25 @@ mod tests { if let cadkernel::geom2d::Curve::Arc(arc) = &curves[0] { assert!((arc.radius - 5.0).abs() < 0.01, "arc radius {} (semicircle)", arc.radius); } + + // Negative (CW) bulge: the center must be on the CW side (mirror of CCW). + // A single 90° bulge segment start=(0,0), end=(1,0), bulge=-0.4142 (~90° CW): + // correct center is BELOW the chord; the buggy double-sign put it above. + let neg = crate::app::doc_api_convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], -0.4142) + .expect("negative bulge arc"); + if let cadkernel::geom2d::Curve::Arc(arc) = &neg { + // 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative. + assert!((arc.radius - 0.7071).abs() < 0.01, "cw arc radius {}", arc.radius); + assert!(arc.centre[1] < 0.0, "CW arc center y must be below the chord, got {:?}", arc.centre); + } else { + panic!("expected an arc for negative bulge"); + } + // And the CCW mirror: center above. + let pos = crate::app::doc_api_convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142) + .expect("positive bulge arc"); + if let cadkernel::geom2d::Curve::Arc(arc) = &pos { + assert!(arc.centre[1] > 0.0, "CCW arc center y must be above the chord, got {:?}", arc.centre); + } } // ── Phase 2c-ii: dimension sub-types (radius/diameter/angular) ────────── @@ -1309,6 +1387,75 @@ mod tests { } } + // ── Review-fix regression tests (Loft cap, CreateMany validation, Ellipse + // rotation, raster path validation) ───────────────────────────────────── + + #[test] + fn fix_loft_over_cap_is_rejected() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1; + let profiles: Vec = (0..over).map(|i| ObjectId::from_u64(i as u64)).collect(); + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles })).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + } + + #[test] + fn fix_create_many_invalid_curve_is_all_or_nothing() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let rev_before = host.scene().geometry_epoch; + // A valid point + an invalid ellipse (ratio > 1) in one CreateMany batch. + let specs = vec![ + ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), + ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { + centre: [0.0; 3], major_axis: [1.0; 3], ratio: 2.0, start: 0.0, end: std::f64::consts::TAU, + }), + ]; + let err = dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMany(specs))).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + // All-or-nothing: NO entity was created (the point would have been added first). + assert_eq!(host.scene().geometry_epoch, rev_before, "no mutation on rejected CreateMany"); + } + + #[test] + fn fix_partial_ellipse_rotates_sweep() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Partial ellipse arc start=0,end=pi/2; rotate by +pi/2 -> sweep becomes pi/2..pi. + let ell = new_id(&dispatch(&mut host, DocApiEnvelope::op(Operation::CreateCurve( + Curve2Spec::Ellipse { centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: std::f64::consts::FRAC_PI_2 }))).unwrap()); + let rot90 = ocs_doc_api::PlacementSpec { + x_axis: [0.0, 1.0, 0.0], y_axis: [-1.0, 0.0, 0.0], z_axis: [0.0, 0.0, 1.0], origin: [0.0; 3], + }; + dispatch(&mut host, DocApiEnvelope::op(Operation::Transform { id: ell, placement: rot90 })).unwrap(); + let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { panic!("ellipse not found") }; + let half_pi = std::f64::consts::FRAC_PI_2; + assert!((e.start_parameter - half_pi).abs() < 1e-6, "start {} (expected {})", e.start_parameter, half_pi); + assert!((e.end_parameter - std::f64::consts::PI).abs() < 1e-6, "end {} (expected pi)", e.end_parameter); + // major_axis rotated from +X to +Y. + assert!((e.major_axis.y - 4.0).abs() < 1e-6, "major_axis {:?}", e.major_axis); + } + + #[test] + fn fix_raster_image_path_validation() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let mk = |path: &str| Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { + file_path: path.into(), insertion_point: [0.0; 3], u_vector: [0.5, 0.0, 0.0], v_vector: [0.0, 0.5, 0.0], size: [10.0, 10.0], + }); + // Valid path succeeds. + assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/photo.png"))).is_ok()); + // UNC path rejected. + assert!(dispatch(&mut host, DocApiEnvelope::op(mk("\\\\server\\share\\x.png"))).is_err()); + // Parent-traversal rejected. + assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/../../etc/passwd.png"))).is_err()); + // Non-image extension rejected. + assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/evil.exe"))).is_err()); + // Empty path rejected. + assert!(dispatch(&mut host, DocApiEnvelope::op(mk(""))).is_err()); + } + // ── Read-mostly family bounds (Leader/Mesh/Face3D/MLine/Helix) ────────── #[test] @@ -1603,6 +1750,18 @@ mod tests { assert_eq!(host.scene().geometry_epoch, before, "no entity on unknown block"); } + #[test] + fn doc_api_tab_mismatch_is_rejected() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A request naming a DIFFERENT tab than the bound one is rejected. + let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] })); + let bytes = bincode::serialize(&env).unwrap(); + let wrong_tab = host.tab_id().wrapping_add(999); + let err = execute_doc_api(&mut host, wrong_tab, &bytes).unwrap_err(); + assert!(err.contains("tab mismatch"), "{err}"); + } + #[test] fn doc_api_unknown_id_surfaces_structured_error() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index 9ccfb1aa0..f4dd1c424 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -115,6 +115,9 @@ pub fn transform_entity_geometry( 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) => { @@ -191,7 +194,7 @@ 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. -fn bulge_arc_segment(start: [f64; 2], end: [f64; 2], bulge: f64) -> ApiResult { +pub(crate) fn bulge_arc_segment(start: [f64; 2], end: [f64; 2], bulge: f64) -> ApiResult { use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; let chord_x = end[0] - start[0]; let chord_y = end[1] - start[1]; @@ -207,11 +210,13 @@ fn bulge_arc_segment(start: [f64; 2], end: [f64; 2], bulge: f64) -> ApiResult= 0.0 { 1.0 } else { -1.0 }; let perp_x = -chord_y / chord_len * sign; let perp_y = chord_x / chord_len * sign; @@ -392,15 +397,7 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in &s.control_points { - 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)]; - } - Aabb { min, max } - } + EntityType::Spline(s) => points_aabb(s.control_points.iter().collect(), id)?, EntityType::Viewport(vp) => Aabb { min: [ vp.center.x - vp.width / 2.0, @@ -443,16 +440,10 @@ pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - let pts = [d.first_point, d.second_point, d.definition_point]; - 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)]; - } - Aabb { min, max } - } + 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]; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index dca04abb2..3e5977de2 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -18,10 +18,6 @@ pub(crate) struct HostSession<'a> { doc_store: Option>, /// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`). doc_api_pending: Option, - /// Memoized volume/centroid per (handle, geometry_epoch) for DocApi mass queries. - pub(crate) mass_cache: std::collections::HashMap, - /// Per-session count of cold-cache mass tessellations (budget for query DoS). - pub(crate) cold_tess_used: usize, } impl<'a> HostSession<'a> { @@ -31,8 +27,6 @@ impl<'a> HostSession<'a> { tab, doc_store: None, doc_api_pending: None, - mass_cache: std::collections::HashMap::new(), - cold_tess_used: 0, } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9642523f1..3fb4226c7 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1804,6 +1804,14 @@ pub struct Scene { pub block_meshes: HashMap, /// Kernel B-reps used by solid operations and exact-geometry saves. pub solid_models: HashMap, + /// Memoized DocApi volume/centroid per (handle, geometry_epoch) so repeated + /// mass queries on the same solid are O(1). Persists across DocApi dispatches. + #[cfg(not(target_arch = "wasm32"))] + pub doc_api_mass_cache: std::collections::HashMap, + /// Per-tab cold-tessellation budget for expensive DocApi volume/centroid + /// computations (bounded DoS surface). Persists across DocApi dispatches. + #[cfg(not(target_arch = "wasm32"))] + pub doc_api_cold_tess_used: usize, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). @@ -2067,6 +2075,10 @@ impl Scene { material_base_dir: None, block_meshes: HashMap::default(), solid_models: HashMap::default(), + #[cfg(not(target_arch = "wasm32"))] + doc_api_mass_cache: std::collections::HashMap::default(), + #[cfg(not(target_arch = "wasm32"))] + doc_api_cold_tess_used: 0, images: HashMap::default(), active_viewport: None, bg_color: [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0], From 0965e188c95bf15477f485bd4300f7cbb1bffed0 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:23:33 +0200 Subject: [PATCH 28/31] ocs_doc_api: enrich + update all documentation (architecture flow diagram, guarantees, counts) - ARCHITECTURE.md: add 'Flow: from the single source of truth to a committed change' mermaid diagram (spec/entities.toml + layouts.json -> codegen -> facade -> wire -> transport -> executor -> host backend/Scene); fix stale claims (hand-maintained enums not 'GENERATED'; accuracy-first mass properties + Scene cold-tess budget; with_body borrow; tab auth); add Review round 3 change-log; update Known deviations. - README.md: Core guarantees now include Loft/query caps, accurate mass properties (Scene budget persists + graceful fallback), and tab authorization. - IMPLEMENTATION.md: accuracy paragraph (Scene budget + fallback) + current test counts (623 host lib + 8 crate + 3 consistency + 34 plugin_api). - Regenerate api_reference.md (26 sections) + binding_schema.json (28 ops / 12 queries / 14 handles / 19 families). - 623 host lib + 8 crate + 3 consistency + 34 plugin_api green. --- crates/ocs_doc_api/ARCHITECTURE.md | 173 ++++++++++++++++++++++++--- crates/ocs_doc_api/IMPLEMENTATION.md | 11 +- crates/ocs_doc_api/README.md | 12 +- 3 files changed, 174 insertions(+), 22 deletions(-) diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md index c0045ee37..d5cfd1888 100644 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -12,8 +12,9 @@ read `README.md` (the user-facing surface). │ Transport::apply. OpGroup (client compensation), query_batch, assert_revision. │ ├─ Wire (envelope/ops/query) DocApiEnvelope = Op(single) | Queries(batch) -│ bincode + serde. Operation/Query enums are GENERATED (src/gen) from the spec. -│ Append-only variants → stable discriminants. +│ 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. @@ -35,6 +36,104 @@ read `README.md` (the user-facing surface). 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: + +```mermaid +flowchart TD + subgraph SRC["Single source of truth"] + SPEC["spec/entities.toml
families · constructors · methods · kernel map"] + LAYOUTS["src/gen/layouts.json
Operation/Query wire layouts"] + end + + subgraph GEN["build.rs codegen (cargo build, CI drift-checked)"] + OPS["src/gen/ops_gen.rs
Operation enum (append-only)"] + QRY["src/gen/query_gen.rs
Query enum (append-only)"] + APIREF["src/gen/api_reference.md"] + SCHEMA["src/gen/binding_schema.json
(object model + layouts inlined)"] + end + + subgraph FACADE["Facade (facade.rs) — plugin-facing, object-centric"] + API["DocApi (root)"] + DOC["Document (per tab)"] + COLL["collections: solids() / curves() / entities()"] + HND["typed handles: Solid · Line · ArcCurve · Text · ...
methods build ops (intersect, transform, create_*)"] + end + + subgraph WIRE["Wire (envelope/ops/query)"] + ENV["DocApiEnvelope
Op(Operation) | Queries(Vec)"] + REC["Receipt: outcome + query_results + new_revision"] + end + + subgraph TRANS["Transport (the only plugin↔host boundary)"] + INP["InProcess (host / tests)"] + IPC["OcsPluginApiIpc (plugin)
PluginRequest::DocApiRequest{tab_id, bytes}"] + end + + subgraph EXEC["Executor (executor.rs) — crate-owned, one impl"] + AO["apply_op: validate+compute → push_undo → mutate → finalize_op"] + AQ["apply_queries: read-only batch (capped)"] + end + + subgraph HOST["Host backend (src/app/doc_api.rs — HostSession)"] + BE["DocApiBackend impl
resolve_body · store_solid · transform · add_* · bounds · volume · centroid"] + TAB["tab auth: reject tab_id ≠ bound tab"] + SCN["Scene (per tab)
solid_models · meshes · erase_entities
geometry_epoch · doc_api_mass_cache · doc_api_cold_tess_used"] + CAD["CadDocument (acadrust)
entities · block_records · handles"] + end + + SPEC -->|spec → enum names + mapping| OPS + SPEC -->|spec → enum names + mapping| QRY + SPEC -->|spec → reference| APIREF + SPEC -->|spec → schema| SCHEMA + LAYOUTS -->|layouts inlined| SCHEMA + + API --> DOC --> COLL --> HND + HND -->|method → Operation / Query| ENV + ENV --> INP + ENV --> IPC + INP --> AO + IPC -->|bincode → host routes DocApiRequest| AO + AO --> TAB + AQ --> TAB + TAB --> BE + BE --> SCN + BE --> CAD + SCN --> REC + CAD --> REC + REC -->|deserialized| HND + + classDef truth fill:#1f6feb,stroke:#0b3d91,color:#fff + classDef gen fill:#2da44e,stroke:#14532d,color:#fff + classDef hot fill:#bf8700,stroke:#7a5200,color:#fff + class SPEC,LAYOUTS truth + class OPS,QRY,APIREF,SCHEMA gen + class AO,AQ,TAB,SCN hot +``` + +**How to read it:** + +- **Blue (source of truth)** — `spec/entities.toml` + `layouts.json`. Change + *these* to change the API surface; everything else regenerates or maps onto them. +- **Green (codegen)** — `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. +- **Amber (hot paths)** — the executor's per-op invariant and the host's tab + authorization + per-tab `Scene` state (solid cache, geometry epoch, the + persistent cold-tessellation budget). This is where atomicity and the DoS + boundaries live. +- **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`): @@ -168,11 +267,19 @@ return `UnknownId` (structured), never a panic. documented trade of per-op autocommit and the reason bulk ops exist. Measured estimate: ~10⁵ sequential creates+transforms ≈ minutes; the same as one `CreateMany`+`TransformMany` ≈ ~1 s. Use bulk ops for N ≳ a few hundred. -- **`volume`/`centroid`** serve from `scene.meshes[handle].metrics` when cached; - the kernel-mesh divergence is a cold-cache fallback. -- **`resolve_body` lift-on-miss** is cheap when the cache is warm (no-op). +- **`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. --- @@ -273,11 +380,14 @@ Two CRITICAL and six WARNING findings were fixed: resolves solids through `resolve_body` (lift-on-miss), consistent with `volume`/`centroid`. -6. **`volume`/`centroid` use the metrics cache** (was WARNING / perf). They +6. **`volume`/`centroid` accuracy + budget** (was WARNING / perf). They re-tessellated the full body (`mesh_body`) + cloned the B-rep per query. Now - they serve from `scene.meshes[handle].metrics` when cached (refreshed on - store/update), with the kernel-mesh divergence as a cold-cache fallback. The - duplicated divergence loops were also merged into one `mesh_volume_centroid`. + 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`). @@ -325,13 +435,46 @@ Two CRITICAL and six WARNING findings were fixed: 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) — host-internal, +- **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. -- **`extrude`/`revolve`/`add_vertex`/non-solid `transform`** return - `ApiError::Unsupported` in v1 (profile conversion + per-family transform land in - later phases — see `IMPLEMENTATION.md`). -- **`volume`/`centroid`** are mesh-approximate (render-mesh LOD tolerance), not - exact analytic values. +- **`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. diff --git a/crates/ocs_doc_api/IMPLEMENTATION.md b/crates/ocs_doc_api/IMPLEMENTATION.md index 5bd4113ca..b095a23a6 100644 --- a/crates/ocs_doc_api/IMPLEMENTATION.md +++ b/crates/ocs_doc_api/IMPLEMENTATION.md @@ -53,7 +53,7 @@ variants append at enum end. (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: 618 host lib + 8 crate unit + 3 spec↔wire consistency tests green. +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` — @@ -124,10 +124,11 @@ tested: 618 host lib + 8 crate unit + 3 spec↔wire consistency tests green. **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); when the cold-tess budget 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). +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_*` diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md index bbbc6c433..29912bbfb 100644 --- a/crates/ocs_doc_api/README.md +++ b/crates/ocs_doc_api/README.md @@ -56,8 +56,16 @@ fn intersect_solids(api: &DocApi) -> ApiResult<()> { `brep::combine` is pure; `erase_sources` runs only on success. - **Bulk ops** (`CreateMany`/`TransformMany`/`DeleteMany`, and read-only query batching) are single ops, **all-or-nothing via upfront validation** (no - rollback), item-capped — for bulk data (see `ARCHITECTURE.md` § Performance). -- **Structured errors** — `ApiError::{Validation, Geometry, UnknownId, + 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. From d1b8fc93f50db2c0785ae4be5f35289ab3036e62 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:34:39 +0200 Subject: [PATCH 29/31] ocs_doc_api: convert architecture flow diagram to readable text format Replace the mermaid flowchart (hard to read raw) with a clean ASCII/text diagram tracing the same flow from the single source of truth (spec/entities.toml + layouts.json) through codegen, facade, wire, transport, executor, and the host backend/Scene, plus a layer-by-layer legend. --- crates/ocs_doc_api/ARCHITECTURE.md | 138 ++++++++++++----------------- 1 file changed, 58 insertions(+), 80 deletions(-) diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md index d5cfd1888..b06bd34e8 100644 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -43,95 +43,73 @@ 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: -```mermaid -flowchart TD - subgraph SRC["Single source of truth"] - SPEC["spec/entities.toml
families · constructors · methods · kernel map"] - LAYOUTS["src/gen/layouts.json
Operation/Query wire layouts"] - end - - subgraph GEN["build.rs codegen (cargo build, CI drift-checked)"] - OPS["src/gen/ops_gen.rs
Operation enum (append-only)"] - QRY["src/gen/query_gen.rs
Query enum (append-only)"] - APIREF["src/gen/api_reference.md"] - SCHEMA["src/gen/binding_schema.json
(object model + layouts inlined)"] - end - - subgraph FACADE["Facade (facade.rs) — plugin-facing, object-centric"] - API["DocApi (root)"] - DOC["Document (per tab)"] - COLL["collections: solids() / curves() / entities()"] - HND["typed handles: Solid · Line · ArcCurve · Text · ...
methods build ops (intersect, transform, create_*)"] - end - - subgraph WIRE["Wire (envelope/ops/query)"] - ENV["DocApiEnvelope
Op(Operation) | Queries(Vec)"] - REC["Receipt: outcome + query_results + new_revision"] - end - - subgraph TRANS["Transport (the only plugin↔host boundary)"] - INP["InProcess (host / tests)"] - IPC["OcsPluginApiIpc (plugin)
PluginRequest::DocApiRequest{tab_id, bytes}"] - end - - subgraph EXEC["Executor (executor.rs) — crate-owned, one impl"] - AO["apply_op: validate+compute → push_undo → mutate → finalize_op"] - AQ["apply_queries: read-only batch (capped)"] - end - - subgraph HOST["Host backend (src/app/doc_api.rs — HostSession)"] - BE["DocApiBackend impl
resolve_body · store_solid · transform · add_* · bounds · volume · centroid"] - TAB["tab auth: reject tab_id ≠ bound tab"] - SCN["Scene (per tab)
solid_models · meshes · erase_entities
geometry_epoch · doc_api_mass_cache · doc_api_cold_tess_used"] - CAD["CadDocument (acadrust)
entities · block_records · handles"] - end - - SPEC -->|spec → enum names + mapping| OPS - SPEC -->|spec → enum names + mapping| QRY - SPEC -->|spec → reference| APIREF - SPEC -->|spec → schema| SCHEMA - LAYOUTS -->|layouts inlined| SCHEMA - - API --> DOC --> COLL --> HND - HND -->|method → Operation / Query| ENV - ENV --> INP - ENV --> IPC - INP --> AO - IPC -->|bincode → host routes DocApiRequest| AO - AO --> TAB - AQ --> TAB - TAB --> BE - BE --> SCN - BE --> CAD - SCN --> REC - CAD --> REC - REC -->|deserialized| HND - - classDef truth fill:#1f6feb,stroke:#0b3d91,color:#fff - classDef gen fill:#2da44e,stroke:#14532d,color:#fff - classDef hot fill:#bf8700,stroke:#7a5200,color:#fff - class SPEC,LAYOUTS truth - class OPS,QRY,APIREF,SCHEMA gen - class AO,AQ,TAB,SCN hot +``` +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) ──► 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:** +**How to read it (top to bottom = the path of a request):** -- **Blue (source of truth)** — `spec/entities.toml` + `layouts.json`. Change +- **SINGLE SOURCE OF TRUTH** — `spec/entities.toml` + `layouts.json`. Change *these* to change the API surface; everything else regenerates or maps onto them. -- **Green (codegen)** — `build.rs` derives the wire enums, the human reference, +- **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 +- **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. -- **Amber (hot paths)** — the executor's per-op invariant and the host's tab - authorization + per-tab `Scene` state (solid cache, geometry epoch, the - persistent cold-tessellation budget). This is where atomicity and the DoS - boundaries live. -- **Response** — the host produces a `Receipt` (outcome + query results + new - geometry revision) that the facade turns back into a typed result (`Solid`, +- **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) From d746d635fb194ad5208bd3c83564594016d6ea20 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:00:04 +0200 Subject: [PATCH 30/31] ocs_doc_api: move doc_api_convert.rs into the crate (src/convert.rs) Move the 503-line host-independent entity-conversion module (acadrust EntityType <-> DTO conversions, transform_entity_geometry, entity_to_profile_curves, bulge_arc_segment, entity_bounds, entity_kind_name, points_aabb) from src/app/doc_api_convert.rs into crates/ocs_doc_api/src/convert.rs (host feature). The host file is now a thin re-export (pub use ocs_doc_api::convert::*) so all existing crate::app::doc_api_convert::* paths keep working unchanged. Zero coupling risk (the module only touches acadrust + crate DTOs). 623 host lib + 8 crate + 3 consistency green. --- crates/ocs_doc_api/src/convert.rs | 505 +++++++++++++++++++++++++++++ crates/ocs_doc_api/src/lib.rs | 8 + src/app/doc_api_convert.rs | 512 +----------------------------- 3 files changed, 522 insertions(+), 503 deletions(-) create mode 100644 crates/ocs_doc_api/src/convert.rs diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs new file mode 100644 index 000000000..a98f285ea --- /dev/null +++ b/crates/ocs_doc_api/src/convert.rs @@ -0,0 +1,505 @@ +//! 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::types::{Vector2, Vector3}; +use acadrust::EntityType; + +use crate::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, ObjectId}; + +/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for +/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). +pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult> { + use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine}; + let curves = match entity { + EntityType::Line(l) => vec![KCurve::Line(KLine { + start: [l.start.x, l.start.y], + end: [l.end.x, l.end.y], + })], + EntityType::Circle(c) => vec![KCurve::Circle(KCircle { + centre: [c.center.x, c.center.y], + radius: c.radius, + })], + EntityType::Arc(a) => vec![KCurve::Arc(KArc { + centre: [a.center.x, a.center.y], + radius: a.radius, + start_angle: a.start_angle, + end_angle: a.end_angle, + })], + EntityType::LwPolyline(pl) => { + if pl.vertices.len() < 2 { + return Err(ApiError::validation("profile", "polyline profile needs >= 2 vertices")); + } + // 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( + "profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(), + )) + } + }; + Ok(curves) +} + +/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid +/// entity in place. Supports the common 2D families; `Solid3D` goes through the +/// kernel path instead. Returns the transformed entity (same handle) for the +/// caller to `update_entity`. +pub fn transform_entity_geometry( + entity: &EntityType, + p: &crate::PlacementSpec, +) -> ApiResult { + // 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], + ) + }; + // 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 { + Vector3::new(p[0], p[1], p[2]) +} + +/// 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 { + use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; + 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 { + let entity = match spec { + Curve2Spec::Line { start, end } => { + let mut e = Line::new(); + e.start = v3(*start); + e.end = v3(*end); + EntityType::Line(e) + } + Curve2Spec::Circle { centre, radius } => { + let mut e = Circle::new(); + e.center = v3(*centre); + e.radius = *radius; + EntityType::Circle(e) + } + Curve2Spec::Polyline { points, closed } => { + let mut e = LwPolyline::new(); + e.vertices = points + .iter() + .map(|p| LwVertex { + location: Vector2::new(p[0], p[1]), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }) + .collect(); + e.is_closed = *closed; + EntityType::LwPolyline(e) + } + Curve2Spec::Point { position } => { + let mut e = Point::new(); + e.location = v3(*position); + EntityType::Point(e) + } + Curve2Spec::Arc { centre, radius, start_angle, end_angle } => { + let mut e = Arc::new(); + e.center = v3(*centre); + e.radius = *radius; + e.start_angle = *start_angle; + e.end_angle = *end_angle; + EntityType::Arc(e) + } + 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) { + return Err(ApiError::validation( + "CreateCurve", + format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"), + )); + } + let mut e = Ellipse::new(); + e.center = v3(*centre); + e.major_axis = v3(*major_axis); + e.minor_axis_ratio = *ratio; + e.start_parameter = *start; + e.end_parameter = *end; + EntityType::Ellipse(e) + } + 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(); + e.knots = knots.clone(); + e.weights = weights.clone(); + EntityType::Spline(e) + } + Curve2Spec::Ray { origin, direction } => { + EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) + } + Curve2Spec::XLine { origin, direction } => { + EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) + } + }; + Ok(entity) +} + +/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts). +pub fn entity_kind_name(entity: &EntityType) -> &'static str { + match entity { + EntityType::Solid3D(_) => "Solid3D", + EntityType::Line(_) => "Line", + EntityType::Circle(_) => "Circle", + EntityType::LwPolyline(_) => "LwPolyline", + EntityType::Polyline(_) => "Polyline", + EntityType::Point(_) => "Point", + EntityType::Arc(_) => "Arc", + EntityType::Ellipse(_) => "Ellipse", + EntityType::Spline(_) => "Spline", + EntityType::Ray(_) => "Ray", + EntityType::XLine(_) => "XLine", + EntityType::Insert(_) => "Insert", + EntityType::Viewport(_) => "Viewport", + EntityType::Text(_) => "Text", + EntityType::MText(_) => "MText", + EntityType::AttributeDefinition(_) => "AttributeDefinition", + EntityType::AttributeEntity(_) => "AttributeEntity", + // Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind. + EntityType::RasterImage(_) => "RasterImage", + EntityType::Table(_) => "Table", + EntityType::Leader(_) => "Leader", + EntityType::MultiLeader(_) => "MultiLeader", + EntityType::MLine(_) => "MLine", + EntityType::Mesh(_) => "Mesh", + EntityType::Helix(_) => "Helix", + EntityType::Region(_) => "Region", + EntityType::Body(_) => "Body", + EntityType::Surface(_) => "Surface", + EntityType::Face3D(_) => "Face3D", + EntityType::Dimension(_) => "Dimension", + EntityType::Hatch(_) => "Hatch", + _ => "Other", + } +} + +/// Coarse bounds for a non-solid entity (solids go through the kernel cache). +/// For v1 typed curves we compute the obvious bounds; anything else errors +/// `Unsupported` (viewport/annotation bounds land in later phases). +pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { + let entity = entity.ok_or(ApiError::UnknownId(id))?; + let bb = match entity { + EntityType::Line(l) => Aabb { + min: [ + l.start.x.min(l.end.x), + l.start.y.min(l.end.y), + l.start.z.min(l.end.z), + ], + max: [ + l.start.x.max(l.end.x), + l.start.y.max(l.end.y), + l.start.z.max(l.end.z), + ], + }, + EntityType::Circle(c) => Aabb { + min: [c.center.x - c.radius, c.center.y - c.radius, c.center.z], + max: [c.center.x + c.radius, c.center.y + c.radius, c.center.z], + }, + EntityType::Point(p) => Aabb { + min: [p.location.x, p.location.y, p.location.z], + max: [p.location.x, p.location.y, p.location.z], + }, + EntityType::LwPolyline(pl) if !pl.vertices.is_empty() => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for vtx in &pl.vertices { + let (x, y, z) = (vtx.location.x, vtx.location.y, pl.elevation); + min = [min[0].min(x), min[1].min(y), min[2].min(z)]; + max = [max[0].max(x), max[1].max(y), max[2].max(z)]; + } + Aabb { min, max } + } + 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 { + if pts.is_empty() { + return Err(ApiError::geometry(GeometryErrorKind::Empty, "entity has no points to bound")); + } + 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 }) +} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs index 5521d1578..82336e23d 100644 --- a/crates/ocs_doc_api/src/lib.rs +++ b/crates/ocs_doc_api/src/lib.rs @@ -21,6 +21,8 @@ pub mod backend; pub mod executor; #[cfg(feature = "host")] pub mod geom; +#[cfg(feature = "host")] +pub mod convert; pub mod envelope; pub mod facade; @@ -52,6 +54,12 @@ pub use facade::{ }; pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive}; pub use query::{Aabb, EntityView, Query, QueryResult}; + +#[cfg(feature = "host")] +pub use convert::{ + bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, + entity_to_profile_curves, transform_entity_geometry, +}; pub use transport::Transport; /// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs index f4dd1c424..928f18e20 100644 --- a/src/app/doc_api_convert.rs +++ b/src/app/doc_api_convert.rs @@ -1,503 +1,9 @@ -// DocApi v2 entity conversions (plan §5.1): map the crate's plain-data DTOs to -// acadrust `EntityType` and back. Lives in the host (uses the real acadrust -// types), keeping the crate's DTOs dependency-light. - -use acadrust::entities::{Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine}; -use acadrust::types::{Vector2, Vector3}; -use acadrust::EntityType; -use ocs_doc_api::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, ObjectId}; - -/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for -/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). -pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult> { - use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine}; - let curves = match entity { - EntityType::Line(l) => vec![KCurve::Line(KLine { - start: [l.start.x, l.start.y], - end: [l.end.x, l.end.y], - })], - EntityType::Circle(c) => vec![KCurve::Circle(KCircle { - centre: [c.center.x, c.center.y], - radius: c.radius, - })], - EntityType::Arc(a) => vec![KCurve::Arc(KArc { - centre: [a.center.x, a.center.y], - radius: a.radius, - start_angle: a.start_angle, - end_angle: a.end_angle, - })], - EntityType::LwPolyline(pl) => { - if pl.vertices.len() < 2 { - return Err(ApiError::validation("profile", "polyline profile needs >= 2 vertices")); - } - // 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( - "profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(), - )) - } - }; - Ok(curves) -} - -/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid -/// entity in place. Supports the common 2D families; `Solid3D` goes through the -/// kernel path instead. Returns the transformed entity (same handle) for the -/// caller to `update_entity`. -pub fn transform_entity_geometry( - entity: &EntityType, - p: &ocs_doc_api::PlacementSpec, -) -> ApiResult { - // 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], - ) - }; - // 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 { - Vector3::new(p[0], p[1], p[2]) -} - -/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a -/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise. -pub(crate) fn bulge_arc_segment(start: [f64; 2], end: [f64; 2], bulge: f64) -> ApiResult { - use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; - 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 { - let entity = match spec { - Curve2Spec::Line { start, end } => { - let mut e = Line::new(); - e.start = v3(*start); - e.end = v3(*end); - EntityType::Line(e) - } - Curve2Spec::Circle { centre, radius } => { - let mut e = Circle::new(); - e.center = v3(*centre); - e.radius = *radius; - EntityType::Circle(e) - } - Curve2Spec::Polyline { points, closed } => { - let mut e = LwPolyline::new(); - e.vertices = points - .iter() - .map(|p| LwVertex { - location: Vector2::new(p[0], p[1]), - bulge: 0.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 0, - }) - .collect(); - e.is_closed = *closed; - EntityType::LwPolyline(e) - } - Curve2Spec::Point { position } => { - let mut e = Point::new(); - e.location = v3(*position); - EntityType::Point(e) - } - Curve2Spec::Arc { centre, radius, start_angle, end_angle } => { - let mut e = Arc::new(); - e.center = v3(*centre); - e.radius = *radius; - e.start_angle = *start_angle; - e.end_angle = *end_angle; - EntityType::Arc(e) - } - 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) { - return Err(ApiError::validation( - "CreateCurve", - format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"), - )); - } - let mut e = Ellipse::new(); - e.center = v3(*centre); - e.major_axis = v3(*major_axis); - e.minor_axis_ratio = *ratio; - e.start_parameter = *start; - e.end_parameter = *end; - EntityType::Ellipse(e) - } - 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(); - e.knots = knots.clone(); - e.weights = weights.clone(); - EntityType::Spline(e) - } - Curve2Spec::Ray { origin, direction } => { - EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) - } - Curve2Spec::XLine { origin, direction } => { - EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) - } - }; - Ok(entity) -} - -/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts). -pub fn entity_kind_name(entity: &EntityType) -> &'static str { - match entity { - EntityType::Solid3D(_) => "Solid3D", - EntityType::Line(_) => "Line", - EntityType::Circle(_) => "Circle", - EntityType::LwPolyline(_) => "LwPolyline", - EntityType::Polyline(_) => "Polyline", - EntityType::Point(_) => "Point", - EntityType::Arc(_) => "Arc", - EntityType::Ellipse(_) => "Ellipse", - EntityType::Spline(_) => "Spline", - EntityType::Ray(_) => "Ray", - EntityType::XLine(_) => "XLine", - EntityType::Insert(_) => "Insert", - EntityType::Viewport(_) => "Viewport", - EntityType::Text(_) => "Text", - EntityType::MText(_) => "MText", - EntityType::AttributeDefinition(_) => "AttributeDefinition", - EntityType::AttributeEntity(_) => "AttributeEntity", - // Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind. - EntityType::RasterImage(_) => "RasterImage", - EntityType::Table(_) => "Table", - EntityType::Leader(_) => "Leader", - EntityType::MultiLeader(_) => "MultiLeader", - EntityType::MLine(_) => "MLine", - EntityType::Mesh(_) => "Mesh", - EntityType::Helix(_) => "Helix", - EntityType::Region(_) => "Region", - EntityType::Body(_) => "Body", - EntityType::Surface(_) => "Surface", - EntityType::Face3D(_) => "Face3D", - EntityType::Dimension(_) => "Dimension", - EntityType::Hatch(_) => "Hatch", - _ => "Other", - } -} - -/// Coarse bounds for a non-solid entity (solids go through the kernel cache). -/// For v1 typed curves we compute the obvious bounds; anything else errors -/// `Unsupported` (viewport/annotation bounds land in later phases). -pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - let entity = entity.ok_or(ApiError::UnknownId(id))?; - let bb = match entity { - EntityType::Line(l) => Aabb { - min: [ - l.start.x.min(l.end.x), - l.start.y.min(l.end.y), - l.start.z.min(l.end.z), - ], - max: [ - l.start.x.max(l.end.x), - l.start.y.max(l.end.y), - l.start.z.max(l.end.z), - ], - }, - EntityType::Circle(c) => Aabb { - min: [c.center.x - c.radius, c.center.y - c.radius, c.center.z], - max: [c.center.x + c.radius, c.center.y + c.radius, c.center.z], - }, - EntityType::Point(p) => Aabb { - min: [p.location.x, p.location.y, p.location.z], - max: [p.location.x, p.location.y, p.location.z], - }, - EntityType::LwPolyline(pl) if !pl.vertices.is_empty() => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for vtx in &pl.vertices { - let (x, y, z) = (vtx.location.x, vtx.location.y, pl.elevation); - min = [min[0].min(x), min[1].min(y), min[2].min(z)]; - max = [max[0].max(x), max[1].max(y), max[2].max(z)]; - } - Aabb { min, max } - } - 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 { - if pts.is_empty() { - return Err(ApiError::geometry(GeometryErrorKind::Empty, "entity has no points to bound")); - } - 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 }) -} +// DocApi v2 entity conversions now live in the crate (`ocs_doc_api::convert`), +// moved out of the host to minimize /src surface (the conversion logic is +// host-independent: acadrust + crate DTOs only). Re-exported here so existing +// `crate::app::doc_api_convert::*` paths keep working unchanged. +#[allow(unused_imports)] // re-export for tests + downstream; non-test build may not use all of it +pub use ocs_doc_api::convert::{ + bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, + entity_to_profile_curves, transform_entity_geometry, +}; From f79c8c59bbee446f08bddc4d709bf79a6453b91e Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:14:48 +0200 Subject: [PATCH 31/31] ocs_doc_api: dissolve src/app/doc_api_convert.rs shim into direct crate usage Delete the thin re-export file; src/app/doc_api.rs now uses ocs_doc_api::convert directly (use ocs_doc_api::convert + convert::* call sites). Remove the mod doc_api_convert registration from src/app/mod.rs. The conversion logic already lives in the crate (src/convert.rs) from the prior move; the shim was redundant. 623 host lib + 38 doc_api tests green. --- src/app/doc_api.rs | 19 ++++++++++--------- src/app/doc_api_convert.rs | 9 --------- src/app/mod.rs | 2 -- 3 files changed, 10 insertions(+), 20 deletions(-) delete mode 100644 src/app/doc_api_convert.rs diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 028cb9df1..38154767c 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -13,6 +13,7 @@ use ocs_doc_api::{ use crate::scene::convert::acis_export; use crate::scene::model::solid_model; +use ocs_doc_api::convert; use super::plugin_host::HostSession; @@ -123,7 +124,7 @@ impl DocApiBackend for HostSession<'_> { } fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let entity = crate::app::doc_api_convert::curve_spec_to_entity(spec)?; + let entity = convert::curve_spec_to_entity(spec)?; let handle = self.scene_mut().add_entity(entity); Ok(handle_to_obj(handle)) } @@ -398,8 +399,8 @@ impl DocApiBackend for HostSession<'_> { for h in &br.entity_handles { let id = handle_to_obj(*h); let (kind, bounds) = match self.document().get_entity(*h) { - Some(e) => (crate::app::doc_api_convert::entity_kind_name(e).to_string(), - crate::app::doc_api_convert::entity_bounds(Some(e), id).ok()), + Some(e) => (convert::entity_kind_name(e).to_string(), + convert::entity_bounds(Some(e), id).ok()), None => ("Missing".to_string(), None), }; out.push(EntityView { id, kind, bounds }); @@ -559,7 +560,7 @@ impl DocApiBackend for HostSession<'_> { .ok_or(ApiError::UnknownId(id))?; Ok(EntityView { id, - kind: crate::app::doc_api_convert::entity_kind_name(entity).to_string(), + kind: convert::entity_kind_name(entity).to_string(), bounds: self.bounds(id).ok(), }) } @@ -583,7 +584,7 @@ impl DocApiBackend for HostSession<'_> { .get_entity(handle) .cloned() .ok_or(ApiError::UnknownId(id))?; - let moved = crate::app::doc_api_convert::transform_entity_geometry(&entity, placement)?; + let moved = convert::transform_entity_geometry(&entity, placement)?; if !self.scene_mut().update_entity(moved) { return Err(ApiError::Unsupported(format!("entity {id:?} is on a locked layer"))); } @@ -596,7 +597,7 @@ impl DocApiBackend for HostSession<'_> { .document() .get_entity(obj_to_handle(id)) .ok_or(ApiError::UnknownId(id))?; - crate::app::doc_api_convert::entity_to_profile_curves(entity) + convert::entity_to_profile_curves(entity) } fn bounds(&mut self, id: ObjectId) -> ApiResult { @@ -611,7 +612,7 @@ impl DocApiBackend for HostSession<'_> { }; return self.with_body(id, &mut f); } - crate::app::doc_api_convert::entity_bounds(self.document().get_entity(handle), id) + convert::entity_bounds(self.document().get_entity(handle), id) } fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { @@ -1219,7 +1220,7 @@ mod tests { // Negative (CW) bulge: the center must be on the CW side (mirror of CCW). // A single 90° bulge segment start=(0,0), end=(1,0), bulge=-0.4142 (~90° CW): // correct center is BELOW the chord; the buggy double-sign put it above. - let neg = crate::app::doc_api_convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], -0.4142) + let neg = convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], -0.4142) .expect("negative bulge arc"); if let cadkernel::geom2d::Curve::Arc(arc) = &neg { // 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative. @@ -1229,7 +1230,7 @@ mod tests { panic!("expected an arc for negative bulge"); } // And the CCW mirror: center above. - let pos = crate::app::doc_api_convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142) + let pos = convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142) .expect("positive bulge arc"); if let cadkernel::geom2d::Curve::Arc(arc) = &pos { assert!(arc.centre[1] > 0.0, "CCW arc center y must be above the chord, got {:?}", arc.centre); diff --git a/src/app/doc_api_convert.rs b/src/app/doc_api_convert.rs deleted file mode 100644 index 928f18e20..000000000 --- a/src/app/doc_api_convert.rs +++ /dev/null @@ -1,9 +0,0 @@ -// DocApi v2 entity conversions now live in the crate (`ocs_doc_api::convert`), -// moved out of the host to minimize /src surface (the conversion logic is -// host-independent: acadrust + crate DTOs only). Re-exported here so existing -// `crate::app::doc_api_convert::*` paths keep working unchanged. -#[allow(unused_imports)] // re-export for tests + downstream; non-test build may not use all of it -pub use ocs_doc_api::convert::{ - bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, - entity_to_profile_curves, transform_entity_geometry, -}; diff --git a/src/app/mod.rs b/src/app/mod.rs index 7465c9f20..a5c5651e8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,8 +13,6 @@ pub(crate) mod helpers; mod history; #[cfg(not(target_arch = "wasm32"))] mod doc_api; -#[cfg(not(target_arch = "wasm32"))] -mod doc_api_convert; mod layers; mod model_ops; mod mtext_editor;