Files
OneCAD-Draft/crates/ocs_doc_api/ARCHITECTURE.md
T
Sebastian 0965e188c9 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.
2026-09-06 16:23:33 +02:00

25 KiB
Raw Blame History

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 HAND-MAINTAINED (src/gen,
│    append-only) → stable discriminants; the spec's op/query names must map to
│    them (asserted by spec_wire_consistency tests).
│
├─ Transport (transport/)    trait Transport: Send+Sync { apply, alive }
│    InProcess (host) | OcsPluginApiIpc (plugin). The ONLY plugin↔host boundary.
│    Portability contract: &self + internal serialization + correlation + timeout.
│
├─ Executor (executor.rs)    apply_op / apply_queries  ← crate-owned logic
│    Per-op atomicity: validate+compute BEFORE push_undo; mutate; finalize_op once.
│
├─ Backend (backend.rs)      trait DocApiBackend  ← thin host hook
│    resolve_body / store_solid / update_solid / add_curve / transform_entity /
│    bounds / volume / centroid / revision / push_undo / finalize_op /
│    ensure_transformable / can_remove / ...
│
└─ Host (src/app/doc_api.rs) impl DocApiBackend for HostSession
     Maps to scene/app primitives: register_solid_model, restore_solid_models
     (lift-on-miss), solid_to_sat, edge_wires, erase_entities, meshes.metrics.

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:

flowchart TD
    subgraph SRC["Single source of truth"]
        SPEC["spec/entities.toml<br/>families · constructors · methods · kernel map"]
        LAYOUTS["src/gen/layouts.json<br/>Operation/Query wire layouts"]
    end

    subgraph GEN["build.rs codegen (cargo build, CI drift-checked)"]
        OPS["src/gen/ops_gen.rs<br/>Operation enum (append-only)"]
        QRY["src/gen/query_gen.rs<br/>Query enum (append-only)"]
        APIREF["src/gen/api_reference.md"]
        SCHEMA["src/gen/binding_schema.json<br/>(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 · ...<br/>methods build ops (intersect, transform, create_*)"]
    end

    subgraph WIRE["Wire (envelope/ops/query)"]
        ENV["DocApiEnvelope<br/>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)<br/>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<br/>resolve_body · store_solid · transform · add_* · bounds · volume · centroid"]
        TAB["tab auth: reject tab_id ≠ bound tab"]
        SCN["Scene (per tab)<br/>solid_models · meshes · erase_entities<br/>geometry_epoch · doc_api_mass_cache · doc_api_cold_tess_used"]
        CAD["CadDocument (acadrust)<br/>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):

  1. Validate + compute — resolve inputs, run kernel geometry (make_solid, brep::combine, brep::transform), pre-flight fallible host work (prepare_solid_model_display), check ensure_transformable / can_remove. No mutation yet.
  2. push_undo(label) — begin the host's undo delta capture.
  3. Mutate — store_solid / update_solid / add_curve / transform_entity / remove_entity.
  4. finalize_op() — close the undo delta + republish the document view.

On any error in steps 1–3, the op returns before finalize_op: nothing is applied, no undo step, no revision bump. This is the per-op atomicity guarantee. brep::combine is pure; erase_sources only runs on success.

The geometry revision advances via the host's internal bump_geometry inside add_entity/update_entity/register_prepared_solid_model. The epoch moves monotonically per op (one undo step per op); the exact bump count is a host internal — do not assert on it.

Object identity and the solid cache

  • ObjectId = acadrust::Handle (u64). Kernel keys never cross the API.
  • Solids live in the host's scene.solid_models: HashMap<Handle, Body> cache. resolve_body does lift-on-miss (restore_solid_models): if a solid isn't cached, its body is re-lifted from the entity's ACIS SAT. This is what makes DocApi-created solids visible to host-native booleans and vice versa, and what makes a reloaded DWG's solids usable.
  • store_solid / update_solid pre-flight the mesh (prepare_solid_model_display) before committing, so the post-commit register_prepared_solid_model cannot fail — there is no rollback path that would bump the revision on failure.

Bulk ops and read batching

  • CreateMany / TransformMany / DeleteMany are single ops, one undo step, one publish — but all-or-nothing via upfront validation: every fallible check (make_solid, ensure_transformable, can_remove) runs before push_undo, so the apply loop cannot fail part-way. Item-capped (BULK_ITEM_CAP) to bound host execution.
  • Read-only query batching (query_batch) shares one round-trip and never bumps the revision. Also capped.
  • Why: per-op publish_document_view is a full-document serialization, so N sequential ops cost O(N²) — bulk ops collapse that to O(N) once. See "Performance" below.

Codegen (build-time, spec-driven)

build.rs reads spec/entities.toml and regenerates the committed snapshots:

  • src/gen/ops_gen.rs, src/gen/query_gen.rs — the Operation/Query enums (the canonical wire vocabulary; append new variants at the END only).
  • src/gen/api_reference.md — human API reference.
  • src/gen/binding_schema.json — the merged binding-handover schema (object model + method signatures + op/query mapping + wire layouts), exposed as binding_schema_json() for REPL/scripting bridges.

cargo build regenerates them deterministically; CI fails on drift.


Workflow: adding a new entity, family, or method

This is the common task. Follow these steps in order.

A. Add a new Operation or Query variant (the wire vocabulary)

  1. Append the variant to src/gen/ops_gen.rs / src/gen/query_gen.rs at the END of the enum (never reorder — bincode discriminants are order-based). Use existing payload DTOs or add new plain-data mirrors in src/ops.rs / src/query.rs.
  2. Add its name to Operation::op_name / Query::query_name in src/ops.rs / src/query.rs.
  3. Regenerate: cargo build -p ocs_doc_api — the spec/schema pick up the wire change. Update src/gen/layouts.json so binding_schema.json stays self-contained.

B. Add a family (e.g. a new entity kind)

  1. Spec — add a [[family]] block in spec/entities.toml (name, acadrust_variant, handle, collection, and its [[family.method]]s).
  2. Handle type — add a handle!(NewType) line in facade.rs (gives it bounds/delete/transform + id()), plus HasId. Add family-specific methods as an impl NewType block if it has geometric actions.
  3. Construction — add a create_* method on the right collection (SolidCollection/CurveCollection/EntityCollection) building the op.
  4. Backend conversion — extend src/app/doc_api_convert.rs (curve_spec_to_entity, entity_kind_name, entity_bounds) to map the new acadrust EntityType variant.
  5. Executor — if the family has a new op variant, add its arm to executor::apply_op.

C. Add a method to an existing family

  1. Wire — if it's a new operation/query, do step A first. If it reuses an existing op (e.g. another boolean), skip to step 3.
  2. Spec — add a [[family.method]] entry under the family (name, kind, op/query, args, returns, fixed fields).
  3. Facade — add the method to the handle's impl block in facade.rs; it should build the Operation/Query and call session.apply_op / session.one_query, returning the typed result.
  4. Executor + backend — if the op is new, implement its arm in executor::apply_op (driving DocApiBackend), and add/override the backend method in the host (src/app/doc_api.rs) mapping to the scene primitive.
  5. Test — add a mock-backend unit test (tests/mock_backend.rs) and, for host-affecting ops, a HostSession integration test in src/app/doc_api.rs (model it on the existing roundtrip tests).

D. Keep the invariants

  • Validate + compute before push_undo; never mutate in the validate phase.
  • One undo step per op; finalize_op only on success.
  • New op/query variants append at the end of the generated enums.
  • Host geometry stays in the host (kernel keys never cross the wire); the wire carries ObjectIds and plain-data DTOs.

Error model

ApiError::{Validation, Geometry, UnknownId, Unsupported, Transport} — serializable, so IPC returns the same structured error the in-process executor produced. Bulk-op validation names the failing index. A stale handle's methods return UnknownId (structured), never a panic.

Performance notes

  • Per-op publish (finalize_op → publish_document_view) is a full-document serialization; N sequential ops cost O(N²). This is the documented trade of per-op autocommit and the reason bulk ops exist. Measured estimate: ~10⁵ sequential creates+transforms ≈ minutes; the same as one CreateMany+TransformMany ≈ ~1 s. Use bulk ops for N ≳ a few hundred.
  • volume/centroid are accuracy-first: a fine tessellation (mesh_body(0.1, 1e-4)) for near-analytic results, memoized per (handle, geometry_epoch) on the per-tab Scene. The cold-tessellation budget (256/tab) bounds the DoS surface and persists across DocApi dispatches; when exhausted the path degrades to the approximate meshes.metrics value rather than failing.
  • resolve_body lift-on-miss is cheap when the cache is warm (no-op). The read-only with_body accessor borrows the cache in place (O(1)) instead of deep-cloning the B-rep — bounds() uses it.
  • IPC serializes the envelope once; the bytes fields use serde_bytes so the outer frame is a single length-prefixed copy.
  • Tab authorization: execute_doc_api rejects a request whose tab_id doesn't match the bound tab, so a plugin can't reach tabs it isn't bound to.

Changes from the original design (review-driven)

The initial implementation was reviewed (/review) and refined in two rounds. This section records the concrete changes and why they were made, so the reasoning isn't lost. Tests are green after every change (592 host lib + 8 crate unit + 7 HostSession integration tests, incl. the regression tests added for the CRITICAL fixes).

Files introduced / modified in src/app/

The host-side integration lives in src/app/. Concretely:

New files

  • src/app/doc_api.rs — the DocApiBackend implementation for HostSession (the thin host hook). Contains:
    • execute_doc_api(host, tab_id, bytes) — the dispatch entry: deserialize the DocApiEnvelope, run the crate executor, serialize ApiResult<Receipt>.
    • impl DocApiBackend for HostSession — resolve_body (lift-on-miss via restore_solid_models), store_solid/update_solid (pre-flight prepare_solid_model_display before commit → register_prepared_solid_model), add_curve, remove_entity (direct erase_entities + locked-layer error), can_remove, ensure_transformable, transform_entity, bounds (lift-on-miss for solids), volume/centroid (meshes.metrics cache + divergence fallback), revision, push_undo/finalize_op.
    • helpers obj_to_handle/handle_to_obj, kernel_placement, mesh_volume_centroid (single divergence-theorem helper).
    • 7 #[cfg(test)] integration tests (create/boolean/query roundtrip, unknown-id error, transform-many all-or-nothing, query-batch cap, 2D-entity roundtrip, geometric-methods roundtrip, DWG write→reload roundtrip).
  • src/app/doc_api_convert.rs — acadrust EntityType ↔ DTO conversions: curve_spec_to_entity (Line/Circle/LwPolyline/Point from Curve2Spec), entity_kind_name, entity_bounds (per-family coarse bounds for non-solids).

Modified files

  • src/app/mod.rs — registers the two new modules (doc_api, doc_api_convert), both gated #[cfg(not(target_arch = "wasm32"))].
  • src/app/plugin_host.rs — extends HostSession:
    • a doc_api_pending: Option<PendingDelta> field (the in-flight per-op undo delta);
    • accessors used by the backend: scene()/scene_mut(), commit_entity_handle(entity) passthrough, begin_doc_api_undo(label) (→ app.begin_undo), commit_doc_api_undo() (→ app.commit_undo_delta + publish_document_view; no extra bump_geometry — the entity op already bumped);
    • impl HostApi for HostSession::doc_api_dispatch → routes to doc_api::execute_doc_api (gated not(wasm32)).

ocs_plugin_api (sibling crate, also touched)

  • src/ipc/protocol.rs — appended PluginRequest::DocApiRequest{tab_id, bytes} and PluginResponse::DocApiResponse{bytes} (append-only; serde_bytes on the payload).
  • src/host.rs — appended defaulted HostApi::doc_api_dispatch (returns "not supported"; HostSession overrides it). Append-only, object-safe.
  • src/ipc/server.rs — routes DocApiRequest → host.doc_api_dispatch(...).

Review round 1 — correctness & safety fixes

Two CRITICAL and six WARNING findings were fixed:

  1. Bulk ops are now truly all-or-nothing (was CRITICAL). The original TransformMany/DeleteMany/CreateMany only existence-checked ids; a mid-apply failure (e.g. store_solid register failure, or transform_entity hitting a non-solid) left partial state with no undo step. Now the executor pre-validates everything fallible before push_undo:

    • TransformMany → new backend method ensure_transformable(id) (v1: solids-only) validated for every id first.
    • DeleteMany → can_remove(id) (existence + not-on-locked-layer) for every id.
    • CreateMany → its prepare loop was already pure; the residual store_solid post-commit failure is eliminated by the pre-flight in (8).
    • Regression test: doc_api_transform_many_with_non_solid_fails_all_or_nothing.
  2. Query batch is capped (was CRITICAL / DoS). apply_queries had no bound — a ≤64 MiB envelope could carry millions of GetVolume/GetCentroid queries, each tessellating a solid on the synchronous UI thread. Now capped at BULK_ITEM_CAP like the write bulk ops. Regression test: doc_api_query_batch_over_cap_is_rejected.

  3. Locked-layer erase no longer silently succeeds (was WARNING). HostSession::remove_entity returns false for locked-layer entities; the executor treated Ok(false) as success (Delete reported Deleted, boolean erase_sources left the second input live). Now:

    • backend remove_entity returns Err(Unsupported) when the entity is on a locked layer;
    • the boolean-erase path validates can_remove(b) before update_solid(a);
    • Delete/DeleteMany pre-validate can_remove before push_undo.
  4. One publish per op for deletes (was WARNING). The backend's remove_entity went through HostSession::remove_entity, which calls publish_document_view per invocation → DeleteMany triggered N+1 full-document publishes. The backend now calls scene.erase_entities directly (clears solid_models/meshes/hatches

    • records the undo delta); the single publish happens at finalize_op.
  5. bounds lift-on-miss for solids (was WARNING). bounds checked solid_models.contains_key and fell through to a non-solid Unsupported for a solid with a cold cache (e.g. dropped by a host-side update_entity). Now it resolves solids through resolve_body (lift-on-miss), consistent with volume/centroid.

  6. volume/centroid accuracy + budget (was WARNING / perf). They re-tessellated the full body (mesh_body) + cloned the B-rep per query. Now they compute via a fine tessellation (mesh_body(0.1, 1e-4)) for near-analytic accuracy, memoized per (handle, geometry_epoch); the cold-tess budget lives on the per-tab Scene (persists across dispatches) and degrades to the meshes.metrics value when exhausted. The duplicated divergence loops were merged into one mesh_volume_centroid (single source of truth, shared by the host + both mock backends).

  7. Double revision bump documented (was WARNING). Solid write ops bump geometry_epoch twice (the entity add/update path + register_prepared_solid_model). This is host-internal and shared with native commands; the meaningful guarantee (one undo step per op, monotonic epoch) holds and is tested. Documented rather than changed, to avoid touching shared host scene bump behavior.

  8. store_solid failure no longer moves the revision (was WARNING). The original rollback path (rollback_new_entities on register_solid_model failure) bumped the epoch again on a failed op, violating "failed op = no bump." Now store_solid/update_solid pre-flight prepare_solid_model_display before commit_entity_handle, so a mesh-prep failure errors before any mutation — the rollback path is eliminated (and the double prepare work is removed). This also feeds the CreateMany all-or-nothing guarantee in (1).

Review round 2 — cleanups (SUGGESTION)

  • serde_bytes on the IPC payload — PluginRequest::DocApiRequest.bytes / PluginResponse::DocApiResponse.bytes now use #[serde(with = "serde_bytes")] so the outer frame is a single length-prefixed copy (was a per-byte seq walk + a second full copy on 10–20 MB bulk payloads).
  • Dead code removed — src/gen/dispatch_gen.rs (never generated/included), Operation::references (no call sites, false doc comment), the discarded serde-reflection tracer stub + serde-reflection build-dep in build.rs (it reads layouts.json directly now), and the dead Session.tab field (transport carries tab_id; DocApi::document(tab) keeps the arg for forward-compat but no longer stores it).

Subsequent additions

  • Rename ocs_doc_api_2 → ocs_doc_api (directory, package, lib, all refs). The plan document under .kilo/ keeps the historical name.
  • DWG roundtrip robustness — the stable-DWG test writes to target/doc_api_roundtrip_intersected.dwg (kept for inspection). It releases a stale lock first and falls back to a process-unique path if still locked, so it never flakes on Windows file locking (error 33).
  • Docs — README.md, this file, IMPLEMENTATION.md, enriched spec/entities.toml + the generated api_reference.md (constructors, generic methods, cross-cutting, bulk ops, unsupported markers).
  • Examples — five runnable examples under examples/ (e1 primitives+queries, e2 booleans, e3 transform-chain, e4 2D+bulk, e5 failure+guards) over a shared in-process mock backend (examples/common/mod.rs).
  • Tests — 7 HostSession integration tests in src/app/doc_api.rs, incl. roundtrips for 2D entities, geometric methods, and a DWG write→reload roundtrip proving an intersected solid survives with valid ACIS and re-lifts to the expected volume.

Review round 3 — branch review fixes (tab auth, geometry, budget, caps)

A full branch review (all six tracks) found 3 CRITICAL + 5 WARNING + suggestions, all fixed with regression tests:

  1. Tab authorization (confused deputy) — execute_doc_api ignored tab_id, so any plugin could reach any open tab. Now rejects tab_id != host.tab_id() before dispatch (doc_api_tab_mismatch_is_rejected).
  2. Negative-bulge arc center mirrored — bulge_arc_segment used signed tan(half) AND a sign flip, double-counting so CW arcs got the center on the wrong side. Now apothem magnitude + sign-picks-side (CW below / CCW above).
  3. Cold-tess budget ineffective — the 256-tessellation budget lived on HostSession (rebuilt per dispatch → reset every request). Moved mass_cache/ cold_tess_used to the per-tab Scene so budget + memoization persist; when exhausted it degrades to the approximate meshes.metrics value (no hard-fail).
  4. Loft uncapped — added BULK_ITEM_CAP (was the only uncapped multi-item op → unbounded kernel call from one request). Regression test.
  5. CreateMany not all-or-nothing — Curve specs are pre-validated (ellipse ratio) in the prepare phase before push_undo. Regression test.
  6. Partial-ellipse rotation — start/end_parameter now offset by rot_z (the Arc arm already did). Regression test.
  7. resolve_body deep-clone — added with_body borrow accessor; the host borrows the cache in place (O(1)) and bounds() uses it (no per-query B-rep copy).
  8. add_raster_image unvalidated path — now validates non-empty + image extension and rejects UNC/network + parent-traversal paths. Regression test.
  9. Deploy capability gate — API_VERSION 5→6 so DocApi plugins are rejected at handshake by old hosts (clean refusal, not a runtime decode error); append-only wire + defaulted doc_api_dispatch keep older V2–V5 plugins loading unchanged.
  10. Dead code — query_name wired into apply_queries error labels; the host uses the crate's ObjectId::from_handle/to_handle (dropped local duplicates); deleted is_null/backend_mut; test mock now stores curve specs + computes curve bounds (aligned with the example mock); Spline/Dimension bounds delegate to points_aabb.

Known deviations (documented, not bugs)

  • Two geometry_epoch bumps per solid write op (see #7 above) — host-internal, shared with native commands; monotonic and one-undo-step-per-op are guaranteed.
  • volume/centroid are fine-mesh-approximate (mesh_body(0.1, 1e-4)), not exact analytic values; the render-mesh meshes.metrics is the coarse display LOD.
  • Angular2Ln constructor exists; Radius/Diameter/Angular3Pt/Linear are the supported dimension sub-types.