25 KiB
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:
SINGLE SOURCE OF TRUTH
spec/entities.toml families · constructors · methods · kernel map
src/gen/layouts.json Operation/Query wire layouts
│
▼ (build.rs codegen — cargo build, CI drift-checked)
CODEGEN OUTPUT
src/gen/ops_gen.rs Operation enum (append-only)
src/gen/query_gen.rs Query enum (append-only)
src/gen/api_reference.md
src/gen/binding_schema.json (object model + layouts inlined)
│
▼
FACADE (facade.rs — what a plugin writes)
DocApi ──► Document ──► collections (solids()/curves()/entities())
│
▼
typed handles (Solid · Line · ArcCurve · Text · …)
method call → builds an Operation or Query
│
▼
WIRE (envelope/ops/query)
DocApiEnvelope = Op(Operation) | Queries(Vec<Query>) ──► request
Receipt = outcome + query_results + new_revision ◄── response
│
▼
TRANSPORT (the ONLY plugin↔host boundary)
InProcess (host / built-in plugins / tests)
OcsPluginApiIpc (plugin) PluginRequest::DocApiRequest{tab_id, bytes}
│ │
└────────────┬───────────────────────┘
▼
EXECUTOR (executor.rs — crate-owned, ONE implementation)
apply_op validate+compute → push_undo → mutate → finalize_op
apply_queries read-only batch (capped)
│
▼
HOST BACKEND (src/app/doc_api.rs — HostSession)
tab authorization reject tab_id ≠ bound tab
DocApiBackend impl resolve_body · store_solid · transform · add_*
bounds · volume · centroid · can_remove
│
├──► Scene (per tab) solid_models · meshes · erase_entities
│ geometry_epoch · doc_api_mass_cache
│ doc_api_cold_tess_used
└──► CadDocument (acadrust) entities · block_records · handles
│
▼ produce
Receipt ──► facade turns it into a typed result (Solid, Aabb, f64, …)
How to read it (top to bottom = the path of a request):
- SINGLE SOURCE OF TRUTH —
spec/entities.toml+layouts.json. Change these to change the API surface; everything else regenerates or maps onto them. - CODEGEN OUTPUT —
build.rsderives the wire enums, the human reference, and the binding handover schema. TheOperation/Queryenums are the canonical append-only wire vocabulary; the spec'sop/querynames must map to them (enforced byspec_wire_consistencytests). - FACADE → WIRE → TRANSPORT — a plugin's
solid.intersect(&other)becomes anOperation, wrapped in aDocApiEnvelope, and shipped via the oneTransport(in-process or IPC). Identical request either way. - EXECUTOR + HOST BACKEND — the hot paths: the executor's per-op invariant,
tab authorization, and the per-tab
Scenestate (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, …).
- new geometry revision) that the facade turns back into a typed result (
Per-op execution model (the load-bearing invariant)
Every write op follows this exact sequence (see executor::apply_op):
- Validate + compute — resolve inputs, run kernel geometry (
make_solid,brep::combine,brep::transform), pre-flight fallible host work (prepare_solid_model_display), checkensure_transformable/can_remove. No mutation yet. push_undo(label)— begin the host's undo delta capture.- Mutate —
store_solid/update_solid/add_curve/transform_entity/remove_entity. 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_bodydoes 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_solidpre-flight the mesh (prepare_solid_model_display) before committing, so the post-commitregister_prepared_solid_modelcannot fail — there is no rollback path that would bump the revision on failure.
Bulk ops and read batching
CreateMany/TransformMany/DeleteManyare single ops, one undo step, one publish — but all-or-nothing via upfront validation: every fallible check (make_solid,ensure_transformable,can_remove) runs beforepush_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_viewis 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— theOperation/Queryenums (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 asbinding_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)
- Append the variant to
src/gen/ops_gen.rs/src/gen/query_gen.rsat the END of the enum (never reorder — bincode discriminants are order-based). Use existing payload DTOs or add new plain-data mirrors insrc/ops.rs/src/query.rs. - Add its name to
Operation::op_name/Query::query_nameinsrc/ops.rs/src/query.rs. - Regenerate:
cargo build -p ocs_doc_api— the spec/schema pick up the wire change. Updatesrc/gen/layouts.jsonsobinding_schema.jsonstays self-contained.
B. Add a family (e.g. a new entity kind)
- Spec — add a
[[family]]block inspec/entities.toml(name,acadrust_variant,handle,collection, and its[[family.method]]s). - Handle type — add a
handle!(NewType)line infacade.rs(gives itbounds/delete/transform+id()), plusHasId. Add family-specific methods as animpl NewTypeblock if it has geometric actions. - Construction — add a
create_*method on the right collection (SolidCollection/CurveCollection/EntityCollection) building the op. - Backend conversion — extend
src/app/doc_api_convert.rs(curve_spec_to_entity,entity_kind_name,entity_bounds) to map the new acadrustEntityTypevariant. - Executor — if the family has a new op variant, add its arm to
executor::apply_op.
C. Add a method to an existing family
- 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.
- Spec — add a
[[family.method]]entry under the family (name, kind, op/query, args, returns,fixedfields). - Facade — add the method to the handle's
implblock infacade.rs; it should build theOperation/Queryand callsession.apply_op/session.one_query, returning the typed result. - Executor + backend — if the op is new, implement its arm in
executor::apply_op(drivingDocApiBackend), and add/override the backend method in the host (src/app/doc_api.rs) mapping to the scene primitive. - Test — add a mock-backend unit test (
tests/mock_backend.rs) and, for host-affecting ops, aHostSessionintegration test insrc/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_oponly 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 oneCreateMany+TransformMany≈ ~1 s. Use bulk ops for N ≳ a few hundred. volume/centroidare accuracy-first: a fine tessellation (mesh_body(0.1, 1e-4)) for near-analytic results, memoized per (handle, geometry_epoch) on the per-tabScene. The cold-tessellation budget (256/tab) bounds the DoS surface and persists across DocApi dispatches; when exhausted the path degrades to the approximatemeshes.metricsvalue rather than failing.resolve_bodylift-on-miss is cheap when the cache is warm (no-op). The read-onlywith_bodyaccessor borrows the cache in place (O(1)) instead of deep-cloning the B-rep —bounds()uses it.- IPC serializes the envelope once; the
bytesfields useserde_bytesso the outer frame is a single length-prefixed copy. - Tab authorization:
execute_doc_apirejects a request whosetab_iddoesn'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— theDocApiBackendimplementation forHostSession(the thin host hook). Contains:execute_doc_api(host, tab_id, bytes)— the dispatch entry: deserialize theDocApiEnvelope, run the crate executor, serializeApiResult<Receipt>.impl DocApiBackend for HostSession—resolve_body(lift-on-miss viarestore_solid_models),store_solid/update_solid(pre-flightprepare_solid_model_displaybefore commit →register_prepared_solid_model),add_curve,remove_entity(directerase_entities+ locked-layer error),can_remove,ensure_transformable,transform_entity,bounds(lift-on-miss for solids),volume/centroid(meshes.metricscache + 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— acadrustEntityType↔ DTO conversions:curve_spec_to_entity(Line/Circle/LwPolyline/Point fromCurve2Spec),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— extendsHostSession:- 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 extrabump_geometry— the entity op already bumped); impl HostApi for HostSession::doc_api_dispatch→ routes todoc_api::execute_doc_api(gatednot(wasm32)).
- a
ocs_plugin_api (sibling crate, also touched)
src/ipc/protocol.rs— appendedPluginRequest::DocApiRequest{tab_id, bytes}andPluginResponse::DocApiResponse{bytes}(append-only;serde_byteson the payload).src/host.rs— appended defaultedHostApi::doc_api_dispatch(returns "not supported";HostSessionoverrides it). Append-only, object-safe.src/ipc/server.rs— routesDocApiRequest→host.doc_api_dispatch(...).
Review round 1 — correctness & safety fixes
Two CRITICAL and six WARNING findings were fixed:
-
Bulk ops are now truly all-or-nothing (was CRITICAL). The original
TransformMany/DeleteMany/CreateManyonly existence-checked ids; a mid-apply failure (e.g.store_solidregister failure, ortransform_entityhitting a non-solid) left partial state with no undo step. Now the executor pre-validates everything fallible beforepush_undo:TransformMany→ new backend methodensure_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 residualstore_solidpost-commit failure is eliminated by the pre-flight in (8).- Regression test:
doc_api_transform_many_with_non_solid_fails_all_or_nothing.
-
Query batch is capped (was CRITICAL / DoS).
apply_querieshad no bound — a ≤64 MiB envelope could carry millions ofGetVolume/GetCentroidqueries, each tessellating a solid on the synchronous UI thread. Now capped atBULK_ITEM_CAPlike the write bulk ops. Regression test:doc_api_query_batch_over_cap_is_rejected. -
Locked-layer erase no longer silently succeeds (was WARNING).
HostSession::remove_entityreturnsfalsefor locked-layer entities; the executor treatedOk(false)as success (DeletereportedDeleted, booleanerase_sourcesleft the second input live). Now:- backend
remove_entityreturnsErr(Unsupported)when the entity is on a locked layer; - the boolean-erase path validates
can_remove(b)beforeupdate_solid(a); Delete/DeleteManypre-validatecan_removebeforepush_undo.
- backend
-
One publish per op for deletes (was WARNING). The backend's
remove_entitywent throughHostSession::remove_entity, which callspublish_document_viewper invocation →DeleteManytriggered N+1 full-document publishes. The backend now callsscene.erase_entitiesdirectly (clearssolid_models/meshes/hatches- records the undo delta); the single publish happens at
finalize_op.
- records the undo delta); the single publish happens at
-
boundslift-on-miss for solids (was WARNING).boundscheckedsolid_models.contains_keyand fell through to a non-solidUnsupportedfor a solid with a cold cache (e.g. dropped by a host-sideupdate_entity). Now it resolves solids throughresolve_body(lift-on-miss), consistent withvolume/centroid. -
volume/centroidaccuracy + 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-tabScene(persists across dispatches) and degrades to themeshes.metricsvalue when exhausted. The duplicated divergence loops were merged into onemesh_volume_centroid(single source of truth, shared by the host + both mock backends). -
Double revision bump documented (was WARNING). Solid write ops bump
geometry_epochtwice (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. -
store_solidfailure no longer moves the revision (was WARNING). The original rollback path (rollback_new_entitiesonregister_solid_modelfailure) bumped the epoch again on a failed op, violating "failed op = no bump." Nowstore_solid/update_solidpre-flightprepare_solid_model_displaybeforecommit_entity_handle, so a mesh-prep failure errors before any mutation — the rollback path is eliminated (and the doublepreparework is removed). This also feeds theCreateManyall-or-nothing guarantee in (1).
Review round 2 — cleanups (SUGGESTION)
serde_byteson the IPC payload —PluginRequest::DocApiRequest.bytes/PluginResponse::DocApiResponse.bytesnow 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-reflectionbuild-dep inbuild.rs(it readslayouts.jsondirectly now), and the deadSession.tabfield (transport carriestab_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, enrichedspec/entities.toml+ the generatedapi_reference.md(constructors, generic methods, cross-cutting, bulk ops,unsupportedmarkers). - 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
HostSessionintegration tests insrc/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:
- Tab authorization (confused deputy) —
execute_doc_apiignoredtab_id, so any plugin could reach any open tab. Now rejectstab_id != host.tab_id()before dispatch (doc_api_tab_mismatch_is_rejected). - Negative-bulge arc center mirrored —
bulge_arc_segmentused signedtan(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). - Cold-tess budget ineffective — the 256-tessellation budget lived on
HostSession(rebuilt per dispatch → reset every request). Movedmass_cache/cold_tess_usedto the per-tabSceneso budget + memoization persist; when exhausted it degrades to the approximatemeshes.metricsvalue (no hard-fail). - Loft uncapped — added
BULK_ITEM_CAP(was the only uncapped multi-item op → unbounded kernel call from one request). Regression test. - CreateMany not all-or-nothing — Curve specs are pre-validated (ellipse ratio)
in the prepare phase before
push_undo. Regression test. - Partial-ellipse rotation —
start/end_parameternow offset byrot_z(the Arc arm already did). Regression test. resolve_bodydeep-clone — addedwith_bodyborrow accessor; the host borrows the cache in place (O(1)) andbounds()uses it (no per-query B-rep copy).add_raster_imageunvalidated path — now validates non-empty + image extension and rejects UNC/network + parent-traversal paths. Regression test.- Deploy capability gate —
API_VERSION5→6 so DocApi plugins are rejected at handshake by old hosts (clean refusal, not a runtime decode error); append-only wire + defaulteddoc_api_dispatchkeep older V2–V5 plugins loading unchanged. - Dead code —
query_namewired intoapply_querieserror labels; the host uses the crate'sObjectId::from_handle/to_handle(dropped local duplicates); deletedis_null/backend_mut; test mock now stores curve specs + computes curve bounds (aligned with the example mock); Spline/Dimension bounds delegate topoints_aabb.
Known deviations (documented, not bugs)
- Two
geometry_epochbumps per solid write op (see #7 above) — host-internal, shared with native commands; monotonic and one-undo-step-per-op are guaranteed. volume/centroidare fine-mesh-approximate (mesh_body(0.1, 1e-4)), not exact analytic values; the render-meshmeshes.metricsis the coarse display LOD.Angular2Lnconstructor exists;Radius/Diameter/Angular3Pt/Linearare the supported dimension sub-types.