Files
OneCAD-Draft/crates/ocs_doc_api/ARCHITECTURE.md
T
Sebastian d1b8fc93f5 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.
2026-09-06 16:34:39 +02:00

459 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.rs` derives the wire enums, the human reference,
and the binding handover schema. The `Operation`/`Query` enums are the canonical
append-only wire vocabulary; the spec's `op`/`query` names must map to them
(enforced by `spec_wire_consistency` tests).
- **FACADE → WIRE → TRANSPORT** — a plugin's `solid.intersect(&other)` becomes an
`Operation`, wrapped in a `DocApiEnvelope`, and shipped via the one `Transport`
(in-process or IPC). Identical request either way.
- **EXECUTOR + HOST BACKEND** — the hot paths: the executor's per-op invariant,
tab authorization, and the per-tab `Scene` state (solid cache, geometry epoch,
the persistent cold-tessellation budget). Atomicity and the DoS boundaries live here.
- **Receipt (response)** — the host produces a `Receipt` (outcome + query results
+ new geometry revision) that the facade turns back into a typed result (`Solid`,
`Aabb`, `f64`, …).
## Per-op execution model (the load-bearing invariant)
Every write op follows this exact sequence (see `executor::apply_op`):
1. **Validate + compute** — resolve inputs, run kernel geometry (`make_solid`,
`brep::combine`, `brep::transform`), pre-flight fallible host work
(`prepare_solid_model_display`), check `ensure_transformable` / `can_remove`.
*No mutation yet.*
2. **`push_undo(label)`** — begin the host's undo delta capture.
3. **Mutate** — `store_solid` / `update_solid` / `add_curve` / `transform_entity` /
`remove_entity`.
4. **`finalize_op()`** — close the undo delta + republish the document view.
On any error in steps 1–3, the op returns before `finalize_op`: **nothing is
applied, no undo step, no revision bump.** This is the per-op atomicity
guarantee. `brep::combine` is pure; `erase_sources` only runs on success.
The geometry revision advances via the host's internal `bump_geometry` inside
`add_entity`/`update_entity`/`register_prepared_solid_model`. The epoch moves
monotonically per op (one undo step per op); the exact bump count is a host
internal — do not assert on it.
## Object identity and the solid cache
- **`ObjectId`** = `acadrust::Handle` (u64). Kernel keys never cross the API.
- **Solids** live in the host's `scene.solid_models: HashMap<Handle, Body>`
cache. `resolve_body` does **lift-on-miss** (`restore_solid_models`): if a
solid isn't cached, its body is re-lifted from the entity's ACIS SAT. This is
what makes DocApi-created solids visible to host-native booleans and vice
versa, and what makes a reloaded DWG's solids usable.
- `store_solid` / `update_solid` pre-flight the mesh (`prepare_solid_model_display`)
**before** committing, so the post-commit `register_prepared_solid_model`
cannot fail — there is no rollback path that would bump the revision on failure.
## Bulk ops and read batching
- `CreateMany` / `TransformMany` / `DeleteMany` are **single ops**, one undo
step, one publish — but **all-or-nothing via upfront validation**: every
fallible check (make_solid, `ensure_transformable`, `can_remove`) runs before
`push_undo`, so the apply loop cannot fail part-way. Item-capped
(`BULK_ITEM_CAP`) to bound host execution.
- Read-only query batching (`query_batch`) shares one round-trip and never bumps
the revision. Also capped.
- Why: per-op `publish_document_view` is a full-document serialization, so N
sequential ops cost O(N²) — bulk ops collapse that to O(N) once. See
"Performance" below.
## Codegen (build-time, spec-driven)
`build.rs` reads `spec/entities.toml` and regenerates the committed snapshots:
- `src/gen/ops_gen.rs`, `src/gen/query_gen.rs` — the `Operation`/`Query` enums
(the canonical wire vocabulary; **append new variants at the END only**).
- `src/gen/api_reference.md` — human API reference.
- `src/gen/binding_schema.json` — the merged binding-handover schema (object
model + method signatures + op/query mapping + wire layouts), exposed as
`binding_schema_json()` for REPL/scripting bridges.
`cargo build` regenerates them deterministically; CI fails on drift.
---
## Workflow: adding a new entity, family, or method
This is the common task. Follow these steps in order.
### A. Add a new `Operation` or `Query` variant (the wire vocabulary)
1. **Append** the variant to `src/gen/ops_gen.rs` / `src/gen/query_gen.rs`
**at the END** of the enum (never reorder — bincode discriminants are
order-based). Use existing payload DTOs or add new plain-data mirrors in
`src/ops.rs` / `src/query.rs`.
2. Add its name to `Operation::op_name` / `Query::query_name` in `src/ops.rs` /
`src/query.rs`.
3. Regenerate: `cargo build -p ocs_doc_api` — the spec/schema pick up the wire
change. Update `src/gen/layouts.json` so `binding_schema.json` stays
self-contained.
### B. Add a family (e.g. a new entity kind)
1. **Spec** — add a `[[family]]` block in `spec/entities.toml` (name,
`acadrust_variant`, `handle`, `collection`, and its `[[family.method]]`s).
2. **Handle type** — add a `handle!(NewType)` line in `facade.rs` (gives it
`bounds`/`delete`/`transform` + `id()`), plus `HasId`. Add family-specific
methods as an `impl NewType` block if it has geometric actions.
3. **Construction** — add a `create_*` method on the right collection
(`SolidCollection`/`CurveCollection`/`EntityCollection`) building the op.
4. **Backend conversion** — extend `src/app/doc_api_convert.rs`
(`curve_spec_to_entity`, `entity_kind_name`, `entity_bounds`) to map the new
acadrust `EntityType` variant.
5. **Executor** — if the family has a new op variant, add its arm to
`executor::apply_op`.
### C. Add a method to an existing family
1. **Wire** — if it's a new operation/query, do step A first. If it reuses an
existing op (e.g. another boolean), skip to step 3.
2. **Spec** — add a `[[family.method]]` entry under the family (name, kind,
op/query, args, returns, `fixed` fields).
3. **Facade** — add the method to the handle's `impl` block in `facade.rs`;
it should build the `Operation`/`Query` and call `session.apply_op` /
`session.one_query`, returning the typed result.
4. **Executor + backend** — if the op is new, implement its arm in
`executor::apply_op` (driving `DocApiBackend`), and add/override the backend
method in the host (`src/app/doc_api.rs`) mapping to the scene primitive.
5. **Test** — add a mock-backend unit test (`tests/mock_backend.rs`) and, for
host-affecting ops, a `HostSession` integration test in `src/app/doc_api.rs`
(model it on the existing roundtrip tests).
### D. Keep the invariants
- Validate + compute **before** `push_undo`; never mutate in the validate phase.
- One undo step per op; `finalize_op` only on success.
- New op/query variants **append at the end** of the generated enums.
- Host geometry stays in the host (kernel keys never cross the wire); the wire
carries `ObjectId`s and plain-data DTOs.
---
## Error model
`ApiError::{Validation, Geometry, UnknownId, Unsupported, Transport}` —
serializable, so IPC returns the same structured error the in-process executor
produced. Bulk-op validation names the failing index. A stale handle's methods
return `UnknownId` (structured), never a panic.
## Performance notes
- **Per-op publish** (`finalize_op` → `publish_document_view`) is a
full-document serialization; N sequential ops cost O(N²). This is the
documented trade of per-op autocommit and the reason bulk ops exist.
Measured estimate: ~10⁵ sequential creates+transforms ≈ minutes; the same as
one `CreateMany`+`TransformMany` ≈ ~1 s. Use bulk ops for N ≳ a few hundred.
- **`volume`/`centroid`** are **accuracy-first**: a fine tessellation
(`mesh_body(0.1, 1e-4)`) for near-analytic results, memoized per
(handle, geometry_epoch) on the per-tab `Scene`. The cold-tessellation budget
(256/tab) bounds the DoS surface and **persists across DocApi dispatches**;
when exhausted the path degrades to the approximate `meshes.metrics` value
rather than failing.
- **`resolve_body` lift-on-miss** is cheap when the cache is warm (no-op). The
read-only `with_body` accessor borrows the cache in place (O(1)) instead of
deep-cloning the B-rep — `bounds()` uses it.
- **IPC** serializes the envelope once; the `bytes` fields use `serde_bytes` so
the outer frame is a single length-prefixed copy.
- **Tab authorization**: `execute_doc_api` rejects a request whose `tab_id`
doesn't match the bound tab, so a plugin can't reach tabs it isn't bound to.
---
## Changes from the original design (review-driven)
The initial implementation was reviewed (`/review`) and refined in two rounds.
This section records the concrete changes and *why* they were made, so the
reasoning isn't lost. Tests are green after every change (592 host lib + 8 crate
unit + 7 `HostSession` integration tests, incl. the regression tests added for
the CRITICAL fixes).
### Files introduced / modified in `src/app/`
The host-side integration lives in `src/app/`. Concretely:
**New files**
- **`src/app/doc_api.rs`** — the `DocApiBackend` implementation for `HostSession`
(the thin host hook). Contains:
- `execute_doc_api(host, tab_id, bytes)` — the dispatch entry: deserialize the
`DocApiEnvelope`, run the crate executor, serialize `ApiResult<Receipt>`.
- `impl DocApiBackend for HostSession` — `resolve_body` (lift-on-miss via
`restore_solid_models`), `store_solid`/`update_solid` (pre-flight
`prepare_solid_model_display` before commit → `register_prepared_solid_model`),
`add_curve`, `remove_entity` (direct `erase_entities` + locked-layer error),
`can_remove`, `ensure_transformable`, `transform_entity`, `bounds`
(lift-on-miss for solids), `volume`/`centroid` (`meshes.metrics` cache +
divergence fallback), `revision`, `push_undo`/`finalize_op`.
- helpers `obj_to_handle`/`handle_to_obj`, `kernel_placement`,
`mesh_volume_centroid` (single divergence-theorem helper).
- 7 `#[cfg(test)]` integration tests (create/boolean/query roundtrip, unknown-id
error, transform-many all-or-nothing, query-batch cap, 2D-entity roundtrip,
geometric-methods roundtrip, **DWG write→reload roundtrip**).
- **`src/app/doc_api_convert.rs`** — acadrust `EntityType` ↔ DTO conversions:
`curve_spec_to_entity` (Line/Circle/LwPolyline/Point from `Curve2Spec`),
`entity_kind_name`, `entity_bounds` (per-family coarse bounds for non-solids).
**Modified files**
- **`src/app/mod.rs`** — registers the two new modules (`doc_api`,
`doc_api_convert`), both gated `#[cfg(not(target_arch = "wasm32"))]`.
- **`src/app/plugin_host.rs`** — extends `HostSession`:
- a `doc_api_pending: Option<PendingDelta>` field (the in-flight per-op undo delta);
- accessors used by the backend: `scene()`/`scene_mut()`,
`commit_entity_handle(entity)` passthrough, `begin_doc_api_undo(label)`
(→ `app.begin_undo`), `commit_doc_api_undo()` (→ `app.commit_undo_delta` +
`publish_document_view`; **no** extra `bump_geometry` — the entity op already
bumped);
- `impl HostApi for HostSession::doc_api_dispatch` → routes to
`doc_api::execute_doc_api` (gated `not(wasm32)`).
**`ocs_plugin_api` (sibling crate, also touched)**
- `src/ipc/protocol.rs` — appended `PluginRequest::DocApiRequest{tab_id, bytes}`
and `PluginResponse::DocApiResponse{bytes}` (append-only; `serde_bytes` on the
payload).
- `src/host.rs` — appended defaulted `HostApi::doc_api_dispatch` (returns
"not supported"; `HostSession` overrides it). Append-only, object-safe.
- `src/ipc/server.rs` — routes `DocApiRequest` → `host.doc_api_dispatch(...)`.
### Review round 1 — correctness & safety fixes
Two CRITICAL and six WARNING findings were fixed:
1. **Bulk ops are now truly all-or-nothing** (was CRITICAL). The original
`TransformMany`/`DeleteMany`/`CreateMany` only existence-checked ids; a
mid-apply failure (e.g. `store_solid` register failure, or `transform_entity`
hitting a non-solid) left partial state with no undo step. Now the executor
pre-validates *everything fallible* before `push_undo`:
- `TransformMany` → new backend method `ensure_transformable(id)` (v1:
solids-only) validated for **every** id first.
- `DeleteMany` → `can_remove(id)` (existence + not-on-locked-layer) for every id.
- `CreateMany` → its prepare loop was already pure; the residual `store_solid`
post-commit failure is eliminated by the pre-flight in (8).
- Regression test: `doc_api_transform_many_with_non_solid_fails_all_or_nothing`.
2. **Query batch is capped** (was CRITICAL / DoS). `apply_queries` had no bound —
a ≤64 MiB envelope could carry millions of `GetVolume`/`GetCentroid` queries,
each tessellating a solid on the synchronous UI thread. Now capped at
`BULK_ITEM_CAP` like the write bulk ops. Regression test:
`doc_api_query_batch_over_cap_is_rejected`.
3. **Locked-layer erase no longer silently succeeds** (was WARNING).
`HostSession::remove_entity` returns `false` for locked-layer entities; the
executor treated `Ok(false)` as success (`Delete` reported `Deleted`, boolean
`erase_sources` left the second input live). Now:
- backend `remove_entity` returns `Err(Unsupported)` when the entity is on a
locked layer;
- the boolean-erase path validates `can_remove(b)` **before** `update_solid(a)`;
- `Delete`/`DeleteMany` pre-validate `can_remove` before `push_undo`.
4. **One publish per op for deletes** (was WARNING). The backend's `remove_entity`
went through `HostSession::remove_entity`, which calls `publish_document_view`
per invocation → `DeleteMany` triggered N+1 full-document publishes. The backend
now calls `scene.erase_entities` directly (clears `solid_models`/meshes/hatches
+ records the undo delta); the single publish happens at `finalize_op`.
5. **`bounds` lift-on-miss for solids** (was WARNING). `bounds` checked
`solid_models.contains_key` and fell through to a non-solid `Unsupported` for a
solid with a cold cache (e.g. dropped by a host-side `update_entity`). Now it
resolves solids through `resolve_body` (lift-on-miss), consistent with
`volume`/`centroid`.
6. **`volume`/`centroid` accuracy + budget** (was WARNING / perf). They
re-tessellated the full body (`mesh_body`) + cloned the B-rep per query. Now
they compute via a **fine tessellation** (`mesh_body(0.1, 1e-4)`) for
near-analytic accuracy, memoized per (handle, geometry_epoch); the cold-tess
budget lives on the per-tab `Scene` (persists across dispatches) and degrades
to the `meshes.metrics` value when exhausted. The duplicated divergence loops
were merged into one `mesh_volume_centroid` (single source of truth, shared
by the host + both mock backends).
7. **Double revision bump documented** (was WARNING). Solid write ops bump
`geometry_epoch` twice (the entity add/update path + `register_prepared_solid_model`).
This is host-internal and shared with native commands; the meaningful guarantee
(one undo step per op, monotonic epoch) holds and is tested. Documented rather
than changed, to avoid touching shared host scene bump behavior.
8. **`store_solid` failure no longer moves the revision** (was WARNING). The
original rollback path (`rollback_new_entities` on `register_solid_model`
failure) bumped the epoch *again* on a failed op, violating "failed op = no
bump." Now `store_solid`/`update_solid` **pre-flight** `prepare_solid_model_display`
**before** `commit_entity_handle`, so a mesh-prep failure errors before any
mutation — the rollback path is eliminated (and the double `prepare` work is
removed). This also feeds the `CreateMany` all-or-nothing guarantee in (1).
### Review round 2 — cleanups (SUGGESTION)
- **`serde_bytes` on the IPC payload** — `PluginRequest::DocApiRequest.bytes` /
`PluginResponse::DocApiResponse.bytes` now use `#[serde(with = "serde_bytes")]`
so the outer frame is a single length-prefixed copy (was a per-byte seq walk +
a second full copy on 10–20 MB bulk payloads).
- **Dead code removed** — `src/gen/dispatch_gen.rs` (never generated/included),
`Operation::references` (no call sites, false doc comment), the discarded
serde-reflection tracer stub + `serde-reflection` build-dep in `build.rs`
(it reads `layouts.json` directly now), and the dead `Session.tab` field
(transport carries `tab_id`; `DocApi::document(tab)` keeps the arg for
forward-compat but no longer stores it).
### Subsequent additions
- **Rename** `ocs_doc_api_2` → `ocs_doc_api` (directory, package, lib, all refs).
The plan document under `.kilo/` keeps the historical name.
- **DWG roundtrip robustness** — the stable-DWG test writes to
`target/doc_api_roundtrip_intersected.dwg` (kept for inspection). It releases a
stale lock first and falls back to a process-unique path if still locked, so it
never flakes on Windows file locking (error 33).
- **Docs** — `README.md`, this file, `IMPLEMENTATION.md`, enriched
`spec/entities.toml` + the generated `api_reference.md` (constructors, generic
methods, cross-cutting, bulk ops, `unsupported` markers).
- **Examples** — five runnable examples under `examples/` (e1 primitives+queries,
e2 booleans, e3 transform-chain, e4 2D+bulk, e5 failure+guards) over a shared
in-process mock backend (`examples/common/mod.rs`).
- **Tests** — 7 `HostSession` integration tests in `src/app/doc_api.rs`, incl.
roundtrips for 2D entities, geometric methods, and a **DWG write→reload**
roundtrip proving an intersected solid survives with valid ACIS and re-lifts to
the expected volume.
### Review round 3 — branch review fixes (tab auth, geometry, budget, caps)
A full branch review (all six tracks) found 3 CRITICAL + 5 WARNING + suggestions,
all fixed with regression tests:
1. **Tab authorization (confused deputy)** — `execute_doc_api` ignored `tab_id`,
so any plugin could reach any open tab. Now rejects `tab_id != host.tab_id()`
before dispatch (`doc_api_tab_mismatch_is_rejected`).
2. **Negative-bulge arc center mirrored** — `bulge_arc_segment` used signed
`tan(half)` AND a sign flip, double-counting so CW arcs got the center on the
wrong side. Now apothem magnitude + sign-picks-side (CW below / CCW above).
3. **Cold-tess budget ineffective** — the 256-tessellation budget lived on
`HostSession` (rebuilt per dispatch → reset every request). Moved `mass_cache`/
`cold_tess_used` to the per-tab `Scene` so budget + memoization persist; when
exhausted it degrades to the approximate `meshes.metrics` value (no hard-fail).
4. **Loft uncapped** — added `BULK_ITEM_CAP` (was the only uncapped multi-item op
→ unbounded kernel call from one request). Regression test.
5. **CreateMany not all-or-nothing** — Curve specs are pre-validated (ellipse ratio)
in the prepare phase before `push_undo`. Regression test.
6. **Partial-ellipse rotation** — `start`/`end_parameter` now offset by `rot_z`
(the Arc arm already did). Regression test.
7. **`resolve_body` deep-clone** — added `with_body` borrow accessor; the host
borrows the cache in place (O(1)) and `bounds()` uses it (no per-query B-rep copy).
8. **`add_raster_image` unvalidated path** — now validates non-empty + image
extension and rejects UNC/network + parent-traversal paths. Regression test.
9. **Deploy capability gate** — `API_VERSION` 5→6 so DocApi plugins are rejected
at handshake by old hosts (clean refusal, not a runtime decode error); append-only
wire + defaulted `doc_api_dispatch` keep older V2–V5 plugins loading unchanged.
10. **Dead code** — `query_name` wired into `apply_queries` error labels; the host
uses the crate's `ObjectId::from_handle`/`to_handle` (dropped local duplicates);
deleted `is_null`/`backend_mut`; test mock now stores curve specs + computes
curve bounds (aligned with the example mock); Spline/Dimension bounds delegate
to `points_aabb`.
### Known deviations (documented, not bugs)
- **Two `geometry_epoch` bumps per solid write op** (see #7 above) — host-internal,
shared with native commands; monotonic and one-undo-step-per-op are guaranteed.
- **`volume`/`centroid`** are fine-mesh-approximate (`mesh_body(0.1, 1e-4)`), not
exact analytic values; the render-mesh `meshes.metrics` is the coarse display LOD.
- **`Angular2Ln` constructor** exists; `Radius`/`Diameter`/`Angular3Pt`/`Linear`
are the supported dimension sub-types.